From 460d41d16f737c2f47d82d0718d6bd44b2a07fec Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Fri, 24 Jul 2026 10:42:06 -0400 Subject: [PATCH] feat(fortal): align visuals with Radix Themes 3.3 Preserve the origin/main component contracts while adding the canonical Fortal theme, visual recipes, effect rendering, and an explicit parity/deferred-capability manifest. Render the generated Fortal wrappers from the Mix prerequisite and keep functional component expansions out of this visual-only layer. --- docs/components/accordion.mdx | 2 +- docs/components/avatar.mdx | 2 +- docs/components/badge.mdx | 2 +- docs/components/button.mdx | 2 +- docs/components/callout.mdx | 2 +- docs/components/card.mdx | 2 +- docs/components/checkbox.mdx | 2 +- docs/components/dialog.mdx | 2 +- docs/components/divider.mdx | 2 +- docs/components/icon_button.mdx | 2 +- docs/components/menu.mdx | 10 +- docs/components/popover.mdx | 20 +- docs/components/progress.mdx | 2 +- docs/components/radio.mdx | 2 +- docs/components/select.mdx | 6 +- docs/components/slider.mdx | 4 +- docs/components/spinner.mdx | 2 +- docs/components/switch.mdx | 2 +- docs/components/tabs.mdx | 2 +- docs/components/textfield.mdx | 4 +- docs/components/toggle.mdx | 2 +- docs/components/tooltip.mdx | 2 +- docs/index.mdx | 12 +- docs/styler-api.mdx | 18 +- packages/demo/PREVIEW.md | 2 +- packages/demo/lib/components/menu.dart | 4 +- packages/demo/lib/components/popover.dart | 4 +- packages/example/lib/api/menu.0.dart | 6 +- packages/remix/lib/remix.dart | 23 +- .../accordion/accordion_widget.dart | 2 +- .../accordion/fortal_accordion_styles.dart | 90 +- .../lib/src/components/avatar/avatar.g.dart | 8 +- .../src/components/avatar/avatar_widget.dart | 107 +- .../avatar/fortal_avatar_styles.dart | 133 +- .../remix/lib/src/components/badge/badge.dart | 1 + .../lib/src/components/badge/badge.g.dart | 50 +- .../lib/src/components/badge/badge_spec.dart | 17 + .../lib/src/components/badge/badge_style.dart | 16 +- .../src/components/badge/badge_widget.dart | 49 +- .../components/badge/fortal_badge_styles.dart | 166 +- .../lib/src/components/button/button.dart | 3 +- .../lib/src/components/button/button.g.dart | 67 +- .../src/components/button/button_spec.dart | 33 +- .../src/components/button/button_widget.dart | 7 +- .../button/fortal_button_styles.dart | 526 +- .../lib/src/components/callout/callout.dart | 1 + .../lib/src/components/callout/callout.g.dart | 48 +- .../src/components/callout/callout_spec.dart | 17 + .../components/callout/callout_widget.dart | 11 +- .../callout/fortal_callout_styles.dart | 151 +- .../remix/lib/src/components/card/card.dart | 2 + .../remix/lib/src/components/card/card.g.dart | 71 +- .../lib/src/components/card/card_spec.dart | 20 +- .../lib/src/components/card/card_widget.dart | 6 +- .../components/card/fortal_card_styles.dart | 237 +- .../lib/src/components/checkbox/checkbox.dart | 1 + .../src/components/checkbox/checkbox.g.dart | 57 +- .../components/checkbox/checkbox_spec.dart | 18 + .../components/checkbox/checkbox_widget.dart | 3 +- .../checkbox/fortal_checkbox_styles.dart | 288 +- .../lib/src/components/dialog/dialog.dart | 1 + .../lib/src/components/dialog/dialog.g.dart | 41 +- .../src/components/dialog/dialog_spec.dart | 17 + .../src/components/dialog/dialog_widget.dart | 9 +- .../dialog/fortal_dialog_styles.dart | 59 +- .../lib/src/components/divider/divider.g.dart | 13 +- .../divider/fortal_divider_styles.dart | 50 +- .../fortal_icon_button_styles.dart | 489 +- .../components/icon_button/icon_button.dart | 2 + .../components/icon_button/icon_button.g.dart | 75 +- .../icon_button/icon_button_spec.dart | 17 + .../icon_button/icon_button_widget.dart | 213 +- .../components/menu/fortal_menu_styles.dart | 306 +- .../remix/lib/src/components/menu/menu.dart | 3 +- .../remix/lib/src/components/menu/menu.g.dart | 40 +- .../lib/src/components/menu/menu_spec.dart | 19 + .../lib/src/components/menu/menu_widget.dart | 4 +- .../popover/fortal_popover_styles.dart | 43 +- .../lib/src/components/popover/popover.dart | 1 + .../lib/src/components/popover/popover.g.dart | 62 +- .../src/components/popover/popover_spec.dart | 18 +- .../components/popover/popover_widget.dart | 6 +- .../progress/fortal_progress_styles.dart | 169 +- .../lib/src/components/progress/progress.dart | 1 + .../src/components/progress/progress.g.dart | 70 +- .../components/progress/progress_spec.dart | 28 + .../components/progress/progress_widget.dart | 10 +- .../components/radio/fortal_radio_styles.dart | 283 +- .../remix/lib/src/components/radio/radio.dart | 1 + .../lib/src/components/radio/radio.g.dart | 48 +- .../lib/src/components/radio/radio_spec.dart | 18 + .../src/components/radio/radio_widget.dart | 3 +- .../select/fortal_select_styles.dart | 422 +- .../lib/src/components/select/select.dart | 5 + .../lib/src/components/select/select.g.dart | 888 +++- .../src/components/select/select_spec.dart | 70 +- .../src/components/select/select_style.dart | 4 +- .../src/components/select/select_widget.dart | 61 +- .../slider/fortal_slider_styles.dart | 406 +- .../lib/src/components/slider/slider.dart | 2 + .../lib/src/components/slider/slider.g.dart | 227 +- .../src/components/slider/slider_spec.dart | 120 +- .../src/components/slider/slider_widget.dart | 106 +- .../spinner/fortal_spinner_styles.dart | 11 +- .../lib/src/components/spinner/spinner.dart | 1 - .../lib/src/components/spinner/spinner.g.dart | 66 + .../components/spinner/spinner_painter.dart | 175 +- .../src/components/spinner/spinner_spec.dart | 27 +- .../switch/fortal_switch_styles.dart | 344 +- .../lib/src/components/switch/switch.dart | 1 + .../lib/src/components/switch/switch.g.dart | 64 +- .../src/components/switch/switch_spec.dart | 28 + .../src/components/switch/switch_widget.dart | 8 +- .../components/tabs/fortal_tabs_styles.dart | 99 +- .../remix/lib/src/components/tabs/tabs.g.dart | 8 +- .../textfield/fortal_textfield_styles.dart | 305 +- .../src/components/textfield/textfield.dart | 1 + .../src/components/textfield/textfield.g.dart | 98 +- .../components/textfield/textfield_spec.dart | 18 + .../textfield/textfield_widget.dart | 10 +- .../toggle/fortal_toggle_styles.dart | 104 +- .../fortal_toggle_group_styles.dart | 101 +- .../tooltip/fortal_tooltip_styles.dart | 15 +- .../lib/src/components/tooltip/tooltip.g.dart | 28 +- .../src/components/tooltip/tooltip_spec.dart | 3 + .../lib/src/fortal/base_button_recipe.dart | 262 + packages/remix/lib/src/fortal/computed.dart | 408 +- packages/remix/lib/src/fortal/fortal.dart | 1 + .../remix/lib/src/fortal/fortal_theme.dart | 1724 +++++-- .../src/radix/colors/colors_generated.dart | 146 +- .../lib/src/rendering/remix_blend_mode.dart | 166 + .../lib/src/rendering/remix_box_effects.dart | 13 + .../rendering/remix_box_effects_adapter.dart | 575 +++ .../rendering/remix_box_effects_model.dart | 780 +++ .../rendering/remix_box_effects_painters.dart | 375 ++ .../rendering/remix_ordered_color_filter.dart | 218 + .../src/style/mixins/spinner_style_mixin.dart | 23 +- .../lib/src/utilities/remix_path_icon.dart | 129 + .../remix/lib/src/utilities/remix_style.dart | 59 +- packages/remix/pubspec.yaml | 4 +- packages/remix/radix_colors.generated.json | 2216 +++++++++ .../reference/radix_themes_3_3_0/README.md | 28 + .../radix_themes_3_3_0/chromium/README.md | 7 + .../chromium/computed-styles.json | 560 +++ .../chromium/families-light.png | Bin 0 -> 85163 bytes .../radix_themes_3_3_0/coverage_evidence.json | 1034 ++++ .../radix_themes_3_3_0/manifest.json | 4243 +++++++++++++++++ .../radix_themes_3_3_0/manifest.schema.json | 179 + .../remix/scripts/extract_radix_tokens.dart | 181 + .../remix/scripts/generate_radix_colors.dart | 2 +- .../avatar/avatar_fortal_parity_test.dart | 190 + .../badge/badge_fortal_parity_test.dart | 210 + .../components/badge/badge_spec_test.dart | 6 +- .../components/badge/badge_style_test.dart | 2 +- .../box_effect_layer_component_lerp_test.dart | 307 ++ .../components/button/button_spec_test.dart | 4 +- .../components/button/button_style_test.dart | 9 +- .../callout/callout_fortal_parity_test.dart | 149 + .../components/callout/callout_spec_test.dart | 6 +- .../callout/callout_style_test.dart | 2 +- .../card/card_fortal_parity_test.dart | 371 ++ .../test/components/card/card_spec_test.dart | 6 +- .../test/components/card/card_style_test.dart | 2 +- .../checkbox/checkbox_spec_test.dart | 6 +- .../checkbox/checkbox_style_test.dart | 2 +- .../fortal_checkbox_disabled_test.dart | 41 + .../components/dialog/dialog_spec_test.dart | 2 +- .../components/dialog/dialog_style_test.dart | 2 +- .../test/components/fortal_widget_test.dart | 2 +- .../icon_button/icon_button_spec_test.dart | 6 +- .../icon_button/icon_button_style_test.dart | 2 +- .../test/components/menu/menu_spec_test.dart | 2 +- .../test/components/menu/menu_style_test.dart | 4 +- .../components/menu/menu_widget_test.dart | 4 +- .../components/popover/popover_spec_test.dart | 2 +- .../popover/popover_widget_test.dart | 9 +- .../progress/progress_spec_test.dart | 2 +- .../components/radio/radio_spec_test.dart | 2 +- .../select/select_fortal_parity_test.dart | 425 ++ .../components/select/select_spec_test.dart | 6 +- .../components/select/select_widget_test.dart | 34 +- .../components/slider/slider_spec_test.dart | 2 +- .../components/spinner/spinner_spec_test.dart | 2 +- .../spinner/spinner_style_test.dart | 2 +- .../components/switch/switch_spec_test.dart | 2 +- .../components/switch/switch_style_test.dart | 2 +- .../textfield/textfield_spec_test.dart | 2 +- .../textfield/textfield_widget_test.dart | 55 +- .../components/tooltip/tooltip_spec_test.dart | 2 +- .../tooltip/tooltip_widget_test.dart | 6 +- .../fortal/fortal_control_matrix_test.dart | 314 ++ .../fortal/fortal_high_contrast_test.dart | 560 +++ .../fortal/fortal_theme_resolution_test.dart | 111 + .../remix/test/fortal/fortal_theme_test.dart | 138 + .../remix/test/fortal/fortal_tokens_test.dart | 412 ++ .../fortal/radix_3_3_color_fixture_test.dart | 148 + .../test/public_api_compatibility_test.dart | 66 + packages/remix/test/public_api_test.dart | 13 + .../test/rendering/remix_blend_mode_test.dart | 205 + .../remix_box_effects_pixel_test.dart | 242 + .../rendering/remix_box_effects_test.dart | 703 +++ .../remix_ordered_color_filter_test.dart | 260 + .../test/utilities/remix_style_test.dart | 53 + packages/remix/tool/fortal_parity/check.dart | 702 +++ .../tool/fortal_parity/chromium/fixture.html | 113 + .../tool/fortal_parity/chromium/generate.mjs | 166 + .../fortal_parity/chromium/package-lock.json | 1836 +++++++ .../tool/fortal_parity/chromium/package.json | 11 + packages/remix/tool/validate_docs.dart | 488 ++ pubspec.lock | 4 +- pubspec.yaml | 18 +- 211 files changed, 27414 insertions(+), 2672 deletions(-) create mode 100644 packages/remix/lib/src/fortal/base_button_recipe.dart create mode 100644 packages/remix/lib/src/rendering/remix_blend_mode.dart create mode 100644 packages/remix/lib/src/rendering/remix_box_effects.dart create mode 100644 packages/remix/lib/src/rendering/remix_box_effects_adapter.dart create mode 100644 packages/remix/lib/src/rendering/remix_box_effects_model.dart create mode 100644 packages/remix/lib/src/rendering/remix_box_effects_painters.dart create mode 100644 packages/remix/lib/src/rendering/remix_ordered_color_filter.dart create mode 100644 packages/remix/lib/src/utilities/remix_path_icon.dart create mode 100644 packages/remix/radix_colors.generated.json create mode 100644 packages/remix/reference/radix_themes_3_3_0/README.md create mode 100644 packages/remix/reference/radix_themes_3_3_0/chromium/README.md create mode 100644 packages/remix/reference/radix_themes_3_3_0/chromium/computed-styles.json create mode 100644 packages/remix/reference/radix_themes_3_3_0/chromium/families-light.png create mode 100644 packages/remix/reference/radix_themes_3_3_0/coverage_evidence.json create mode 100644 packages/remix/reference/radix_themes_3_3_0/manifest.json create mode 100644 packages/remix/reference/radix_themes_3_3_0/manifest.schema.json create mode 100644 packages/remix/scripts/extract_radix_tokens.dart create mode 100644 packages/remix/test/components/avatar/avatar_fortal_parity_test.dart create mode 100644 packages/remix/test/components/badge/badge_fortal_parity_test.dart create mode 100644 packages/remix/test/components/box_effect_layer_component_lerp_test.dart create mode 100644 packages/remix/test/components/callout/callout_fortal_parity_test.dart create mode 100644 packages/remix/test/components/card/card_fortal_parity_test.dart create mode 100644 packages/remix/test/components/checkbox/fortal_checkbox_disabled_test.dart create mode 100644 packages/remix/test/components/select/select_fortal_parity_test.dart create mode 100644 packages/remix/test/fortal/fortal_control_matrix_test.dart create mode 100644 packages/remix/test/fortal/fortal_high_contrast_test.dart create mode 100644 packages/remix/test/fortal/fortal_theme_resolution_test.dart create mode 100644 packages/remix/test/fortal/fortal_theme_test.dart create mode 100644 packages/remix/test/fortal/fortal_tokens_test.dart create mode 100644 packages/remix/test/fortal/radix_3_3_color_fixture_test.dart create mode 100644 packages/remix/test/public_api_compatibility_test.dart create mode 100644 packages/remix/test/public_api_test.dart create mode 100644 packages/remix/test/rendering/remix_blend_mode_test.dart create mode 100644 packages/remix/test/rendering/remix_box_effects_pixel_test.dart create mode 100644 packages/remix/test/rendering/remix_box_effects_test.dart create mode 100644 packages/remix/test/rendering/remix_ordered_color_filter_test.dart create mode 100644 packages/remix/test/utilities/remix_style_test.dart create mode 100644 packages/remix/tool/fortal_parity/check.dart create mode 100644 packages/remix/tool/fortal_parity/chromium/fixture.html create mode 100644 packages/remix/tool/fortal_parity/chromium/generate.mjs create mode 100644 packages/remix/tool/fortal_parity/chromium/package-lock.json create mode 100644 packages/remix/tool/fortal_parity/chromium/package.json create mode 100644 packages/remix/tool/validate_docs.dart diff --git a/docs/components/accordion.mdx b/docs/components/accordion.mdx index da8f2506..62a4aa9f 100644 --- a/docs/components/accordion.mdx +++ b/docs/components/accordion.mdx @@ -111,7 +111,7 @@ class _AccordionExampleState extends State { ## Fortal widgets -Remix includes a generated Fortal-themed widget for this component: +Remix includes a Fortal-themed widget for this component: ```dart diff --git a/docs/components/avatar.mdx b/docs/components/avatar.mdx index 26e7b5e8..bd326ebb 100644 --- a/docs/components/avatar.mdx +++ b/docs/components/avatar.mdx @@ -77,7 +77,7 @@ class AvatarExample extends StatelessWidget { ## Fortal widgets -Remix includes a generated Fortal-themed widget for this component: +Remix includes a Fortal-themed widget for this component: ```dart diff --git a/docs/components/badge.mdx b/docs/components/badge.mdx index ac80f1f4..6ffaf8cd 100644 --- a/docs/components/badge.mdx +++ b/docs/components/badge.mdx @@ -76,7 +76,7 @@ class BadgeExample extends StatelessWidget { ## Fortal widgets -Remix includes a generated Fortal-themed widget for this component: +Remix includes a Fortal-themed widget for this component: ```dart diff --git a/docs/components/button.mdx b/docs/components/button.mdx index 5f908f0c..200c924b 100644 --- a/docs/components/button.mdx +++ b/docs/components/button.mdx @@ -101,7 +101,7 @@ class ButtonExample extends StatelessWidget { ## Fortal widgets -Remix includes a generated Fortal-themed widget for this component: +Remix includes a Fortal-themed widget for this component: ```dart diff --git a/docs/components/callout.mdx b/docs/components/callout.mdx index 7c0030d5..b5b23a7e 100644 --- a/docs/components/callout.mdx +++ b/docs/components/callout.mdx @@ -59,7 +59,7 @@ class CalloutExample extends StatelessWidget { ## Fortal widgets -Remix includes a generated Fortal-themed widget for this component: +Remix includes a Fortal-themed widget for this component: ```dart diff --git a/docs/components/card.mdx b/docs/components/card.mdx index 2347821c..4aad2fef 100644 --- a/docs/components/card.mdx +++ b/docs/components/card.mdx @@ -48,7 +48,7 @@ class CardExample extends StatelessWidget { ## Fortal widgets -Remix includes a generated Fortal-themed widget for this component: +Remix includes a Fortal-themed widget for this component: ```dart diff --git a/docs/components/checkbox.mdx b/docs/components/checkbox.mdx index 780138a7..36996273 100644 --- a/docs/components/checkbox.mdx +++ b/docs/components/checkbox.mdx @@ -61,7 +61,7 @@ class _CheckboxExampleState extends State { ## Fortal widgets -Remix includes a generated Fortal-themed widget for this component: +Remix includes a Fortal-themed widget for this component: ```dart diff --git a/docs/components/dialog.mdx b/docs/components/dialog.mdx index 403ae514..56a08448 100644 --- a/docs/components/dialog.mdx +++ b/docs/components/dialog.mdx @@ -90,7 +90,7 @@ showRemixAlertDialog( ## Fortal widgets -Remix includes a generated Fortal-themed widget for this component: +Remix includes a Fortal-themed widget for this component: ```dart diff --git a/docs/components/divider.mdx b/docs/components/divider.mdx index ca4fb1a2..a04bf4e8 100644 --- a/docs/components/divider.mdx +++ b/docs/components/divider.mdx @@ -42,7 +42,7 @@ class DividerExample extends StatelessWidget { ## Fortal widgets -Remix includes a generated Fortal-themed widget for this component: +Remix includes a Fortal-themed widget for this component: ```dart diff --git a/docs/components/icon_button.mdx b/docs/components/icon_button.mdx index 139c4c21..37fbb094 100644 --- a/docs/components/icon_button.mdx +++ b/docs/components/icon_button.mdx @@ -74,7 +74,7 @@ class IconButtonExample extends StatelessWidget { ## Fortal widgets -Remix includes a generated Fortal-themed widget for this component: +Remix includes a Fortal-themed widget for this component: ```dart diff --git a/docs/components/menu.mdx b/docs/components/menu.mdx index f63bee70..c4e01dd1 100644 --- a/docs/components/menu.mdx +++ b/docs/components/menu.mdx @@ -61,9 +61,9 @@ class _MenuExampleState extends State { ), ], positioning: const OverlayPositionConfig( - offset: Offset(0, 8), - followerAnchor: Alignment.topCenter, - targetAnchor: Alignment.bottomCenter, + sideOffset: 8, + side: OverlaySide.bottom, + alignment: OverlayAlignment.center, ), style: menuStyle, onSelected: (value) { @@ -132,7 +132,7 @@ class _MenuExampleState extends State { ## Fortal widgets -Remix includes a generated Fortal-themed widget for this component: +Remix includes a Fortal-themed widget for this component: ```dart @@ -247,7 +247,7 @@ Optional. Open/close interceptors (for example, to drive animations). #### `onCloseRequested` → `RawMenuAnchorCloseRequestedCallback?` -Optional. +Optional. #### `consumeOutsideTaps` → `bool` diff --git a/docs/components/popover.mdx b/docs/components/popover.mdx index 885d4f0a..d6c23235 100644 --- a/docs/components/popover.mdx +++ b/docs/components/popover.mdx @@ -25,8 +25,8 @@ class AccountPopover extends StatelessWidget { return FortalPopover( semanticLabel: 'Show account details', positioning: const OverlayPositionConfig( - targetAnchor: Alignment.bottomCenter, - followerAnchor: Alignment.topCenter, + side: OverlaySide.bottom, + alignment: OverlayAlignment.center, ), popoverChild: const SizedBox( width: 240, @@ -85,15 +85,15 @@ callback to complete the state change. ## Positioning -`OverlayPositionConfig` aligns one point on the trigger with one point on the -overlay and can apply an additional offset. +`OverlayPositionConfig` places the overlay on a trigger side, aligns it along +that side, and can apply an additional offset. ```dart const OverlayPositionConfig( - targetAnchor: Alignment.topRight, - followerAnchor: Alignment.bottomRight, - offset: Offset(0, -8), -) + side: OverlaySide.top, + alignment: OverlayAlignment.end, + sideOffset: 8, +); ``` The overlay is clamped to the available screen bounds. @@ -109,10 +109,10 @@ RemixPopover( .paddingAll(16) .constraints(BoxConstraintsMix(maxWidth: 320)) .backgroundColor(Colors.white) - .borderRadiusAll(12), + .borderRadiusAll(const Radius.circular(12)), popoverChild: const Text('Custom popover'), child: const Text('Open'), -) +); ``` The `FortalPopover` preset adds Fortal spacing, border, radius, surface color, diff --git a/docs/components/progress.mdx b/docs/components/progress.mdx index 420ec576..03f1ed50 100644 --- a/docs/components/progress.mdx +++ b/docs/components/progress.mdx @@ -45,7 +45,7 @@ class ProgressExample extends StatelessWidget { ## Fortal widgets -Remix includes a generated Fortal-themed widget for this component: +Remix includes a Fortal-themed widget for this component: ```dart diff --git a/docs/components/radio.mdx b/docs/components/radio.mdx index 2e9fcfae..c8c17cee 100644 --- a/docs/components/radio.mdx +++ b/docs/components/radio.mdx @@ -112,7 +112,7 @@ class _RadioExampleState extends State { ## Fortal widgets -Remix includes a generated Fortal-themed widget for this component: +Remix includes a Fortal-themed widget for this component: ```dart diff --git a/docs/components/select.mdx b/docs/components/select.mdx index 15d34329..25359bd7 100644 --- a/docs/components/select.mdx +++ b/docs/components/select.mdx @@ -97,7 +97,7 @@ class _SelectExampleState extends State { ## Fortal widgets -Remix includes a generated Fortal-themed widget for this component: +Remix includes a Fortal-themed widget for this component: ```dart @@ -165,8 +165,8 @@ const RemixSelect({ required List> items, T? selectedValue, OverlayPositionConfig positioning = const OverlayPositionConfig( - targetAnchor: Alignment.bottomCenter, - followerAnchor: Alignment.topCenter, + side: OverlaySide.bottom, + alignment: OverlayAlignment.center, ), ValueChanged? onChanged, VoidCallback? onOpen, diff --git a/docs/components/slider.mdx b/docs/components/slider.mdx index 8a35d397..af9003a4 100644 --- a/docs/components/slider.mdx +++ b/docs/components/slider.mdx @@ -54,7 +54,7 @@ class _SliderExampleState extends State { BoxShadowMix() .color(Colors.black45) .blurRadius(4) - .offset(const Offset(0, 2)), + .offset(x: 0, y: 2), ), ) .thumbColor(Colors.black) @@ -74,7 +74,7 @@ class _SliderExampleState extends State { ## Fortal widgets -Remix includes a generated Fortal-themed widget for this component: +Remix includes a Fortal-themed widget for this component: ```dart diff --git a/docs/components/spinner.mdx b/docs/components/spinner.mdx index 2c7bb027..871a0667 100644 --- a/docs/components/spinner.mdx +++ b/docs/components/spinner.mdx @@ -58,7 +58,7 @@ class SpinnerExample extends StatelessWidget { ## Fortal widgets -Remix includes a generated Fortal-themed widget for this component: +Remix includes a Fortal-themed widget for this component: ```dart diff --git a/docs/components/switch.mdx b/docs/components/switch.mdx index 79e76828..41005a74 100644 --- a/docs/components/switch.mdx +++ b/docs/components/switch.mdx @@ -79,7 +79,7 @@ class _SwitchExampleState extends State { ## Fortal widgets -Remix includes a generated Fortal-themed widget for this component: +Remix includes a Fortal-themed widget for this component: ```dart diff --git a/docs/components/tabs.mdx b/docs/components/tabs.mdx index f7740ecf..0b16eefe 100644 --- a/docs/components/tabs.mdx +++ b/docs/components/tabs.mdx @@ -119,7 +119,7 @@ class _TabsExampleState extends State { ## Fortal widgets -Remix includes a generated Fortal-themed widget for this component: +Remix includes a Fortal-themed widget for this component: ```dart diff --git a/docs/components/textfield.mdx b/docs/components/textfield.mdx index b0e900ad..1c8940d2 100644 --- a/docs/components/textfield.mdx +++ b/docs/components/textfield.mdx @@ -67,7 +67,7 @@ class _TextfieldExampleState extends State { BoxShadowMix() .blurRadius(1) .color(Colors.black12) - .offset(const Offset(0, 1)), + .offset(x: 0, y: 1), ) .border( BoxBorderMix.all(BorderSideMix(color: Colors.grey.shade300)), @@ -89,7 +89,7 @@ class _TextfieldExampleState extends State { ## Fortal widgets -Remix includes a generated Fortal-themed widget for this component: +Remix includes a Fortal-themed widget for this component: ```dart diff --git a/docs/components/toggle.mdx b/docs/components/toggle.mdx index aea03bbb..d195f1f8 100644 --- a/docs/components/toggle.mdx +++ b/docs/components/toggle.mdx @@ -64,7 +64,7 @@ class _ToggleExampleState extends State { ## Fortal widgets -Remix includes a generated Fortal-themed widget for this component: +Remix includes a Fortal-themed widget for this component: ```dart diff --git a/docs/components/tooltip.mdx b/docs/components/tooltip.mdx index f68098d0..f943a1be 100644 --- a/docs/components/tooltip.mdx +++ b/docs/components/tooltip.mdx @@ -92,7 +92,7 @@ class _TriggerButton extends StatelessWidget { ## Fortal widgets -Remix includes a generated Fortal-themed widget for this component: +Remix includes a Fortal-themed widget for this component: ```dart diff --git a/docs/index.mdx b/docs/index.mdx index 7005605e..0cfc1184 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -169,7 +169,7 @@ While Remix gives you complete freedom to build any design system, it also inclu ### Quick Start with Fortal -To use Fortal widgets, wrap your app with `FortalScope` to provide the design tokens, then use the generated `Fortal*` widgets. Named constructors select a fixed variant; use the unnamed constructor with `variant:` when the choice is dynamic: +To use Fortal widgets, wrap your app with `FortalScope` to provide the design tokens, then use the `Fortal*` widgets. Named constructors select a fixed variant; use the unnamed constructor with `variant:` when the choice is dynamic: ```dart import 'package:flutter/material.dart'; @@ -178,10 +178,10 @@ import 'package:remix/remix.dart'; class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp( - home: Scaffold( - body: FortalScope( - child: Center( + return FortalScope( + child: MaterialApp( + home: Scaffold( + body: Center( child: FortalButton.solid( onPressed: () {}, label: 'Fortal Button', @@ -196,7 +196,7 @@ class MyApp extends StatelessWidget { ### Customizing Fortal Styles -Generated Fortal widgets call the matching `fortal*Styler` internally. Use those stylers directly when you need a custom Remix widget composition: +Fortal widgets call the matching `fortal*Styler` internally. Use those stylers directly when you need a custom Remix widget composition: ```dart final style = fortalButtonStyler(variant: FortalButtonVariant.solid) diff --git a/docs/styler-api.mdx b/docs/styler-api.mdx index 6e24245a..8d53b5e4 100644 --- a/docs/styler-api.mdx +++ b/docs/styler-api.mdx @@ -20,8 +20,8 @@ final style = ButtonStyler() The following expressions are equivalent: ```dart -RemixCardStyler.color(Colors.blue) -RemixCardStyler().color(Colors.blue) +RemixCardStyler.color(Colors.blue); +RemixCardStyler().color(Colors.blue); ``` ## Factory policy @@ -127,13 +127,7 @@ operation without ambiguous overloads. ## Generation boundary -Component stylers are generated from `@MixableSpec`. Fortal widget wrapper -classes are source-authored even though they were previously emitted by -`@MixWidget`. Both upstream generators write shared parts for the same library; -during a clean build, the widget generator cannot resolve a Styler that the -spec generator has not yet combined into that library. - -Only the wrapper shell was materialized—the `fortal*Styler` recipes and widget -behavior remain unchanged. This keeps clean builds deterministic and preserves -the former generated constructors and fields. The widget builder can be -re-enabled once upstream generation supports that dependency ordering. +Component stylers are generated from `@MixableSpec`, and Fortal wrapper classes +are generated from `@MixWidget`. The annotations identify the target Remix +constructor and which recipe parameters become wrapper fields. Generated files +remain deterministic build artifacts and should never be edited directly. diff --git a/packages/demo/PREVIEW.md b/packages/demo/PREVIEW.md index 2ed670a4..82c1189b 100644 --- a/packages/demo/PREVIEW.md +++ b/packages/demo/PREVIEW.md @@ -110,7 +110,7 @@ Widget createRemixPreview(Widget child) { ### Key Features - **Remix Tokens**: Full access to design system tokens -- **Theme Support**: Automatic light/dark theme handling +- **Theme Support**: Automatic light/dark theme handling - **Hot Reload**: Changes reflect immediately - **No Interaction**: Uses empty callbacks for preview safety - **Consistent Styling**: All previews use same wrapper diff --git a/packages/demo/lib/components/menu.dart b/packages/demo/lib/components/menu.dart index bd451c79..9cef36c8 100644 --- a/packages/demo/lib/components/menu.dart +++ b/packages/demo/lib/components/menu.dart @@ -31,8 +31,8 @@ Widget buildMenuUseCase(BuildContext context) { child: Center( child: FortalMenu( positioning: const OverlayPositionConfig( - followerAnchor: .topCenter, - targetAnchor: .bottomCenter, + side: .bottom, + alignment: .center, ), trigger: const RemixMenuTrigger( label: 'Menu', diff --git a/packages/demo/lib/components/popover.dart b/packages/demo/lib/components/popover.dart index c7ccd56b..e563c421 100644 --- a/packages/demo/lib/components/popover.dart +++ b/packages/demo/lib/components/popover.dart @@ -20,8 +20,8 @@ Widget buildPopoverUseCase(BuildContext context) { consumeOutsideTaps: consumeOutsideTaps, semanticLabel: 'Show collaboration details', positioning: const OverlayPositionConfig( - targetAnchor: .bottomCenter, - followerAnchor: .topCenter, + side: .bottom, + alignment: .center, ), popoverChild: SizedBox( width: 280, diff --git a/packages/example/lib/api/menu.0.dart b/packages/example/lib/api/menu.0.dart index a1a2d2f0..fc8f396c 100644 --- a/packages/example/lib/api/menu.0.dart +++ b/packages/example/lib/api/menu.0.dart @@ -63,9 +63,9 @@ class _MenuExampleState extends State { ), ], positioning: const OverlayPositionConfig( - offset: Offset(0, 8), - followerAnchor: .topCenter, - targetAnchor: .bottomCenter, + side: .bottom, + alignment: .center, + sideOffset: 8, ), style: menuStyle, onSelected: (value) { diff --git a/packages/remix/lib/remix.dart b/packages/remix/lib/remix.dart index 68bc1f22..95409cef 100644 --- a/packages/remix/lib/remix.dart +++ b/packages/remix/lib/remix.dart @@ -28,11 +28,32 @@ export 'src/components/tooltip/tooltip.dart'; /// EXTERNAL DEPENDENCIES export 'package:mix/mix.dart'; -export 'package:naked_ui/naked_ui.dart' show OverlayPositionConfig; +export 'package:naked_ui/naked_ui.dart' + show OverlayAlignment, OverlayPlacement, OverlayPositionConfig, OverlaySide; /// FORTAL export 'src/fortal/fortal.dart'; +/// RENDERING +export 'src/rendering/remix_blend_mode.dart' show RemixBlendMode; +export 'src/rendering/remix_ordered_color_filter.dart' + show + RemixCssColorFilterFunction, + RemixCssColorFilterOperation, + RemixOrderedColorFilterModifier, + RemixOrderedColorFilterModifierMix; +export 'src/rendering/remix_box_effects.dart' + show + RemixBoxShadowKind, + RemixBoxShadow, + RemixBoxShadowMix, + RemixBoxShadowListToken, + RemixLinearGradientMix, + RemixBoxEffectLayerSpec, + RemixBoxEffectLayerMix, + RemixBoxEffectsSpec, + RemixBoxEffectsMix; + /// STYLER CONVENIENCES export 'src/utilities/remix_style.dart' show RemixBoxStylerAnchors, RemixBoxStylerMixin, RemixBoxStylerConvenience; diff --git a/packages/remix/lib/src/components/accordion/accordion_widget.dart b/packages/remix/lib/src/components/accordion/accordion_widget.dart index 8e080cbb..9b16211e 100644 --- a/packages/remix/lib/src/components/accordion/accordion_widget.dart +++ b/packages/remix/lib/src/components/accordion/accordion_widget.dart @@ -225,7 +225,7 @@ class RemixAccordion extends StatelessWidget { focusNode: focusNode, onFocusChange: onFocusChange, onHoverChange: onHoverChange, - onPressChange: onPressChange, + onPressChange: (pressed) => onPressChange?.call(pressed), semanticLabel: semanticLabel ?? title, child: child, builder: builder ?? _buildDefaultTrigger, diff --git a/packages/remix/lib/src/components/accordion/fortal_accordion_styles.dart b/packages/remix/lib/src/components/accordion/fortal_accordion_styles.dart index 1cad22f6..31e51068 100644 --- a/packages/remix/lib/src/components/accordion/fortal_accordion_styles.dart +++ b/packages/remix/lib/src/components/accordion/fortal_accordion_styles.dart @@ -24,18 +24,41 @@ RemixAccordionStyler fortalAccordionStyler({ RemixAccordionStyler _fortalAccordionBaseStyler(FortalAccordionSize size) { return RemixAccordionStyler() - .trigger(FlexBoxStyler().direction(.horizontal)) - .title(TextStyler().fontWeight(.w500).color(FortalTokens.gray12())) - .trailingIcon(IconStyler().color(FortalTokens.gray11())) - .content(BoxStyler().width(.infinity)) + .trigger(.direction(.horizontal).clipBehavior(.antiAlias)) + .leadingIcon(.color(FortalTokens.gray11())) + .title( + .fontWeight( + FortalTokens.fontWeightMedium(), + ).color(FortalTokens.gray12()), + ) + .trailingIcon(.color(FortalTokens.gray11())) + .content(.width(.infinity)) .merge(_fortalAccordionSizeStyler(size)); } +RemixAccordionStyler _fortalAccordionFocusStyler() { + return RemixAccordionStyler().trigger( + FlexBoxStyler().borderAll( + color: FortalTokens.focusA8(), + width: FortalTokens.focusRingWidth(), + strokeAlign: BorderSide.strokeAlignInside, + ), + ); +} + +RemixAccordionStyler _fortalAccordionDisabledStyler() { + return RemixAccordionStyler() + .trigger(.color(FortalTokens.grayA3())) + .leadingIcon(.color(FortalTokens.gray8())) + .title(.color(FortalTokens.gray8())) + .trailingIcon(.color(FortalTokens.gray8())); +} + RemixAccordionStyler _fortalAccordionSurfaceStyler([ FortalAccordionSize size = .size2, ]) { return _fortalAccordionBaseStyler(size) - .trigger(FlexBoxStyler().color(FortalTokens.gray1())) + .trigger(.color(FortalTokens.gray1())) .content( BoxStyler() .borderTop( @@ -48,16 +71,20 @@ RemixAccordionStyler _fortalAccordionSurfaceStyler([ style: TextStyleMix().color(FortalTokens.gray12()), ), ), - ); + ) + .onHovered(.trigger(.color(FortalTokens.gray2()))) + .onPressed(.trigger(.color(FortalTokens.gray3()))) + .onFocused(_fortalAccordionFocusStyler()) + .onDisabled(_fortalAccordionDisabledStyler()); } RemixAccordionStyler _fortalAccordionSoftStyler([ FortalAccordionSize size = .size2, ]) { return _fortalAccordionBaseStyler(size) - .trigger(FlexBoxStyler().color(FortalTokens.accent2())) - .title(TextStyler().color(FortalTokens.accent12())) - .trailingIcon(IconStyler().color(FortalTokens.accent11())) + .trigger(.color(FortalTokens.accent2())) + .title(.color(FortalTokens.accent12())) + .trailingIcon(.color(FortalTokens.accent11())) .content( BoxStyler() .borderTop( @@ -70,7 +97,11 @@ RemixAccordionStyler _fortalAccordionSoftStyler([ style: TextStyleMix().color(FortalTokens.accent12()), ), ), - ); + ) + .onHovered(.trigger(.color(FortalTokens.accent3()))) + .onPressed(.trigger(.color(FortalTokens.accent4()))) + .onFocused(_fortalAccordionFocusStyler()) + .onDisabled(_fortalAccordionDisabledStyler()); } RemixAccordionStyler _fortalAccordionSizeStyler(FortalAccordionSize size) { @@ -78,26 +109,41 @@ RemixAccordionStyler _fortalAccordionSizeStyler(FortalAccordionSize size) { .size1 => RemixAccordionStyler( trigger: FlexBoxStyler() .paddingX(FortalTokens.space2()) - .paddingY(FortalTokens.space2()), - title: TextStyler().fontSize(14), - trailingIcon: IconStyler().size(16), - content: BoxStyler().paddingAll(FortalTokens.space2()), + .paddingY(FortalTokens.space2()) + .borderRadiusAll(FortalTokens.radius3()), + leadingIcon: .size(FortalTokens.space4()), + title: .style(FortalTokens.text2.mix()), + trailingIcon: .size(FortalTokens.space4()), + content: BoxStyler() + .paddingAll(FortalTokens.space2()) + .borderRadiusBottom(FortalTokens.radius3()) + .clipBehavior(.antiAlias), ), .size2 => RemixAccordionStyler( trigger: FlexBoxStyler() .paddingX(FortalTokens.space3()) - .paddingY(FortalTokens.space3()), - title: TextStyler().fontSize(15), - trailingIcon: IconStyler().size(20), - content: BoxStyler().paddingAll(FortalTokens.space3()), + .paddingY(FortalTokens.space3()) + .borderRadiusAll(FortalTokens.radius4()), + leadingIcon: .size(FortalTokens.spinnerSize3()), + title: .style(FortalTokens.accordionText2.mix()), + trailingIcon: .size(FortalTokens.spinnerSize3()), + content: BoxStyler() + .paddingAll(FortalTokens.space3()) + .borderRadiusBottom(FortalTokens.radius4()) + .clipBehavior(.antiAlias), ), .size3 => RemixAccordionStyler( trigger: FlexBoxStyler() .paddingX(FortalTokens.space4()) - .paddingY(FortalTokens.space4()), - title: TextStyler().fontSize(16), - trailingIcon: IconStyler().size(24), - content: BoxStyler().paddingAll(FortalTokens.space4()), + .paddingY(FortalTokens.space4()) + .borderRadiusAll(FortalTokens.radius5()), + leadingIcon: .size(FortalTokens.space5()), + title: .style(FortalTokens.text3.mix()), + trailingIcon: .size(FortalTokens.space5()), + content: BoxStyler() + .paddingAll(FortalTokens.space4()) + .borderRadiusBottom(FortalTokens.radius5()) + .clipBehavior(.antiAlias), ), }; } diff --git a/packages/remix/lib/src/components/avatar/avatar.g.dart b/packages/remix/lib/src/components/avatar/avatar.g.dart index ea2d3de4..98732f06 100644 --- a/packages/remix/lib/src/components/avatar/avatar.g.dart +++ b/packages/remix/lib/src/components/avatar/avatar.g.dart @@ -94,12 +94,12 @@ typedef _$RemixAvatarSpecMethods = _$RemixAvatarSpec; // ignore: unused_element // MixWidgetGenerator // ************************************************************************** -/// Fortal-themed preset for [RemixAvatar]. +/// Fortal recipe for [RemixAvatar]. class FortalAvatar extends StatelessWidget { const FortalAvatar({ super.key, this.variant = .soft, - this.size = .size2, + this.size = .size3, this.backgroundImage, this.foregroundImage, this.onBackgroundImageError, @@ -113,7 +113,7 @@ class FortalAvatar extends StatelessWidget { const FortalAvatar.soft({ super.key, - this.size = .size2, + this.size = .size3, this.backgroundImage, this.foregroundImage, this.onBackgroundImageError, @@ -127,7 +127,7 @@ class FortalAvatar extends StatelessWidget { const FortalAvatar.solid({ super.key, - this.size = .size2, + this.size = .size3, this.backgroundImage, this.foregroundImage, this.onBackgroundImageError, diff --git a/packages/remix/lib/src/components/avatar/avatar_widget.dart b/packages/remix/lib/src/components/avatar/avatar_widget.dart index 064892da..63f9098e 100644 --- a/packages/remix/lib/src/components/avatar/avatar_widget.dart +++ b/packages/remix/lib/src/components/avatar/avatar_widget.dart @@ -110,32 +110,95 @@ class RemixAvatar extends StatelessWidget { } } + Widget current = content ?? const SizedBox.shrink(); + if (backgroundImage case final image?) { + current = _RemixAvatarImageLayer( + image: image, + onError: onBackgroundImageError, + fallback: current, + ); + } + if (foregroundImage case final image?) { + current = Stack( + fit: StackFit.passthrough, + children: [ + current, + Positioned.fill( + child: _RemixAvatarImageLayer( + image: image, + onError: onForegroundImageError, + fallback: const SizedBox.expand(), + ), + ), + ], + ); + } + return Box( styleSpec: spec.container, - child: Container( - alignment: .center, - decoration: backgroundImage != null - ? BoxDecoration( - image: DecorationImage( - image: backgroundImage!, - onError: onBackgroundImageError, - fit: .cover, - ), - ) - : null, - foregroundDecoration: foregroundImage != null - ? BoxDecoration( - image: DecorationImage( - image: foregroundImage!, - onError: onForegroundImageError, - fit: .cover, - ), - ) - : null, - child: content, - ), + child: Container(alignment: .center, child: current), ); }, ); } } + +/// Replaces [fallback] only after an image frame has decoded successfully. +/// Errors keep the fallback visible and are reported once per provider. +class _RemixAvatarImageLayer extends StatefulWidget { + const _RemixAvatarImageLayer({ + required this.image, + required this.fallback, + this.onError, + }); + + final ImageProvider image; + final Widget fallback; + final ImageErrorListener? onError; + + @override + State<_RemixAvatarImageLayer> createState() => _RemixAvatarImageLayerState(); +} + +class _RemixAvatarImageLayerState extends State<_RemixAvatarImageLayer> { + bool _reportedError = false; + + @override + void didUpdateWidget(_RemixAvatarImageLayer oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.image != widget.image) _reportedError = false; + } + + @override + Widget build(BuildContext context) { + return Image( + image: widget.image, + fit: BoxFit.cover, + excludeFromSemantics: true, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (!wasSynchronouslyLoaded && frame == null) return widget.fallback; + return Stack( + fit: StackFit.passthrough, + children: [ + Opacity( + opacity: 0, + alwaysIncludeSemantics: true, + child: widget.fallback, + ), + Positioned.fill(child: child), + ], + ); + }, + errorBuilder: (context, error, stackTrace) { + if (!_reportedError) { + _reportedError = true; + final onError = widget.onError; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) onError?.call(error, stackTrace); + }); + } + return widget.fallback; + }, + ); + } +} diff --git a/packages/remix/lib/src/components/avatar/fortal_avatar_styles.dart b/packages/remix/lib/src/components/avatar/fortal_avatar_styles.dart index ae6b1501..592052d1 100644 --- a/packages/remix/lib/src/components/avatar/fortal_avatar_styles.dart +++ b/packages/remix/lib/src/components/avatar/fortal_avatar_styles.dart @@ -1,12 +1,22 @@ part of 'avatar.dart'; -/// Fortal avatar size presets. -enum FortalAvatarSize { size1, size2, size3, size4 } +/// Radix Themes Avatar size presets. +enum FortalAvatarSize { + size1, + size2, + size3, + size4, + size5, + size6, + size7, + size8, + size9, +} -/// Fortal avatar color variants. +/// Radix Themes Avatar variants. enum FortalAvatarVariant { soft, solid } -/// Fortal-themed preset for [RemixAvatar]. +/// Fortal recipe for [RemixAvatar]. @MixWidget( name: 'FortalAvatar', target: RemixAvatar.new, @@ -14,56 +24,89 @@ enum FortalAvatarVariant { soft, solid } ) RemixAvatarStyler fortalAvatarStyler({ FortalAvatarVariant variant = .soft, - FortalAvatarSize size = .size2, + FortalAvatarSize size = .size3, + bool highContrast = false, + int fallbackLength = 1, }) { + final base = _fortalAvatarBaseStyler(size, fallbackLength: fallbackLength); return switch (variant) { - .soft => _fortalAvatarSoftStyler(size), - .solid => _fortalAvatarSolidStyler(size), + .soft => + base + .backgroundColor(FortalTokens.accentA3()) + .foregroundColor( + highContrast ? FortalTokens.accent12() : FortalTokens.accentA11(), + ), + .solid => + base + .backgroundColor( + highContrast ? FortalTokens.accent12() : FortalTokens.accent9(), + ) + .foregroundColor( + highContrast + ? FortalTokens.accent1() + : FortalTokens.accentContrast(), + ), }; } -RemixAvatarStyler _fortalAvatarBaseStyler(FortalAvatarSize size) { +RemixAvatarStyler _fortalAvatarBaseStyler( + FortalAvatarSize size, { + required int fallbackLength, +}) { + final fallbackText = _fortalAvatarFallbackText(size, fallbackLength); return RemixAvatarStyler() .clipBehavior(.hardEdge) - .labelFontWeight(.w500) - .merge(_fortalAvatarSizeStyler(size)); + .label( + TextStyler( + style: fallbackText.mix(), + ).fontWeight(FortalTokens.fontWeightMedium()), + ) + .icon( + .size( + _fortalAvatarDimension(size) * 0.5, + ).color(FortalTokens.accentA11()), + ) + .square(_fortalAvatarDimension(size)) + .borderRadiusAll(_fortalAvatarRadius(size)); } -RemixAvatarStyler _fortalAvatarSoftStyler([FortalAvatarSize size = .size2]) { - return _fortalAvatarBaseStyler(size) - .backgroundColor(FortalTokens.accentA3()) - .foregroundColor(FortalTokens.accentA10()); -} +double _fortalAvatarDimension(FortalAvatarSize size) => switch (size) { + .size1 => FortalTokens.space5(), + .size2 => FortalTokens.space6(), + .size3 => FortalTokens.space7(), + .size4 => FortalTokens.space8(), + .size5 => FortalTokens.space9(), + .size6 => FortalTokens.avatarSize6(), + .size7 => FortalTokens.avatarSize7(), + .size8 => FortalTokens.avatarSize8(), + .size9 => FortalTokens.avatarSize9(), +}; -RemixAvatarStyler _fortalAvatarSolidStyler([FortalAvatarSize size = .size2]) { - return _fortalAvatarBaseStyler(size) - .backgroundColor(FortalTokens.accent9()) - .foregroundColor(FortalTokens.accentContrast()); -} +Radius _fortalAvatarRadius(FortalAvatarSize size) => switch (size) { + .size1 || .size2 => FortalTokens.radius2OrFull(), + .size3 || .size4 => FortalTokens.radius3OrFull(), + .size5 => FortalTokens.radius4OrFull(), + .size6 || .size7 => FortalTokens.radius5OrFull(), + .size8 || .size9 => FortalTokens.radius6OrFull(), +}; -RemixAvatarStyler _fortalAvatarSizeStyler(FortalAvatarSize size) { - return switch (size) { - .size1 => - RemixAvatarStyler() - .square(24.0) - .borderRadiusAll(FortalTokens.radius2()) - .labelStyle(FortalTokens.text1.mix()), - .size2 => - RemixAvatarStyler() - .square(32.0) - .borderRadiusAll(FortalTokens.radius3()) - .labelStyle(FortalTokens.text2.mix()), - .size3 => - RemixAvatarStyler() - .square(40.0) - .borderRadiusAll(FortalTokens.radius4()) - .labelStyle(FortalTokens.text3.mix()), - .size4 => - RemixAvatarStyler() - .square(64.0) - .borderRadiusAll(FortalTokens.radius5()) - .labelStyle(FortalTokens.text4.mix()), - }; -} +TextStyleToken _fortalAvatarFallbackText( + FortalAvatarSize size, + int fallbackLength, +) => switch ((size, fallbackLength == 2)) { + (.size1, false) => FortalTokens.avatarFallback1One, + (.size1, true) => FortalTokens.avatarFallback1Two, + (.size2, false) => FortalTokens.avatarFallback2One, + (.size2, true) => FortalTokens.avatarFallback2Two, + (.size3, false) => FortalTokens.avatarFallback3One, + (.size3, true) => FortalTokens.avatarFallback3Two, + (.size4, false) => FortalTokens.avatarFallback4One, + (.size4, true) => FortalTokens.avatarFallback4Two, + (.size5, _) => FortalTokens.avatarFallback5, + (.size6, _) => FortalTokens.avatarFallback6, + (.size7, _) => FortalTokens.avatarFallback7, + (.size8, _) => FortalTokens.avatarFallback8, + (.size9, _) => FortalTokens.avatarFallback9, +}; -/// Fortal-themed preset for [RemixAvatar]. +/// Fortal-themed Avatar with the Radix size, variant, and override contract. diff --git a/packages/remix/lib/src/components/badge/badge.dart b/packages/remix/lib/src/components/badge/badge.dart index abc4681e..e23ed324 100644 --- a/packages/remix/lib/src/components/badge/badge.dart +++ b/packages/remix/lib/src/components/badge/badge.dart @@ -8,6 +8,7 @@ import 'package:mix_annotations/mix_annotations.dart'; import '../../style/style.dart'; import '../../utilities/remix_style.dart'; import '../../fortal/fortal.dart'; +import '../../rendering/remix_box_effects.dart'; part 'badge_spec.dart'; part 'badge_style.dart'; diff --git a/packages/remix/lib/src/components/badge/badge.g.dart b/packages/remix/lib/src/components/badge/badge.g.dart index 5404e738..11b08826 100644 --- a/packages/remix/lib/src/components/badge/badge.g.dart +++ b/packages/remix/lib/src/components/badge/badge.g.dart @@ -9,6 +9,7 @@ part of 'badge.dart'; mixin _$RemixBadgeSpec implements Spec, Diagnosticable { StyleSpec get container; StyleSpec get label; + RemixBoxEffectsSpec? get containerEffects; @override Type get type => RemixBadgeSpec; @@ -17,10 +18,12 @@ mixin _$RemixBadgeSpec implements Spec, Diagnosticable { RemixBadgeSpec copyWith({ StyleSpec? container, StyleSpec? label, + RemixBoxEffectsSpec? containerEffects, }) { return RemixBadgeSpec( container: container ?? this.container, label: label ?? this.label, + containerEffects: containerEffects ?? this.containerEffects, ); } @@ -29,11 +32,16 @@ mixin _$RemixBadgeSpec implements Spec, Diagnosticable { return RemixBadgeSpec( container: container.lerp(other?.container, t), label: label.lerp(other?.label, t), + containerEffects: MixOps.lerpSnap( + containerEffects, + other?.containerEffects, + t, + ), ); } @override - List get props => [container, label]; + List get props => [container, label, containerEffects]; @override bool operator ==(Object other) { @@ -76,7 +84,8 @@ mixin _$RemixBadgeSpec implements Spec, Diagnosticable { void debugFillProperties(DiagnosticPropertiesBuilder properties) { properties ..add(DiagnosticsProperty('container', container)) - ..add(DiagnosticsProperty('label', label)); + ..add(DiagnosticsProperty('label', label)) + ..add(DiagnosticsProperty('containerEffects', containerEffects)); } } @@ -89,12 +98,12 @@ typedef _$RemixBadgeSpecMethods = _$RemixBadgeSpec; // ignore: unused_element // MixWidgetGenerator // ************************************************************************** -/// Fortal-themed preset for [RemixBadge]. +/// Fortal recipe for [RemixBadge]. class FortalBadge extends StatelessWidget { const FortalBadge({ super.key, - this.variant = .solid, - this.size = .size2, + this.variant = .soft, + this.size = .size1, this.label, this.child, this.labelBuilder, @@ -102,7 +111,7 @@ class FortalBadge extends StatelessWidget { const FortalBadge.solid({ super.key, - this.size = .size2, + this.size = .size1, this.label, this.child, this.labelBuilder, @@ -110,7 +119,7 @@ class FortalBadge extends StatelessWidget { const FortalBadge.soft({ super.key, - this.size = .size2, + this.size = .size1, this.label, this.child, this.labelBuilder, @@ -118,7 +127,7 @@ class FortalBadge extends StatelessWidget { const FortalBadge.surface({ super.key, - this.size = .size2, + this.size = .size1, this.label, this.child, this.labelBuilder, @@ -126,7 +135,7 @@ class FortalBadge extends StatelessWidget { const FortalBadge.outline({ super.key, - this.size = .size2, + this.size = .size1, this.label, this.child, this.labelBuilder, @@ -164,25 +173,30 @@ class RemixBadgeStyler extends MixStyler LabelStyleMixin { final Prop>? $container; final Prop>? $label; + final Prop? $containerEffects; const RemixBadgeStyler.create({ Prop>? container, Prop>? label, + Prop? containerEffects, super.variants, super.modifier, super.animation, }) : $container = container, - $label = label; + $label = label, + $containerEffects = containerEffects; RemixBadgeStyler({ BoxStyler? container, TextStyler? label, + RemixBoxEffectsMix? containerEffects, AnimationConfig? animation, WidgetModifierConfig? modifier, List>? variants, }) : this.create( container: Prop.maybeMix(container), label: Prop.maybeMix(label), + containerEffects: Prop.maybeMix(containerEffects), variants: variants, modifier: modifier, animation: animation, @@ -192,6 +206,8 @@ class RemixBadgeStyler extends MixStyler RemixBadgeStyler().container(value); factory RemixBadgeStyler.label(TextStyler value) => RemixBadgeStyler().label(value); + factory RemixBadgeStyler.containerEffects(RemixBoxEffectsMix value) => + RemixBadgeStyler().containerEffects(value); factory RemixBadgeStyler.alignment(AlignmentGeometry value) => RemixBadgeStyler().alignment(value); factory RemixBadgeStyler.padding(EdgeInsetsGeometryMix value) => @@ -675,6 +691,11 @@ class RemixBadgeStyler extends MixStyler return merge(RemixBadgeStyler(label: value)); } + /// Sets the containerEffects. + RemixBadgeStyler containerEffects(RemixBoxEffectsMix value) { + return merge(RemixBadgeStyler(containerEffects: value)); + } + /// Sets the animation configuration. @override RemixBadgeStyler animate(AnimationConfig value) { @@ -704,6 +725,10 @@ class RemixBadgeStyler extends MixStyler return RemixBadgeStyler.create( container: MixOps.merge($container, other?.$container), label: MixOps.merge($label, other?.$label), + containerEffects: MixOps.merge( + $containerEffects, + other?.$containerEffects, + ), variants: MixOps.mergeVariants($variants, other?.$variants), modifier: MixOps.mergeModifier($modifier, other?.$modifier), animation: MixOps.mergeAnimation($animation, other?.$animation), @@ -716,6 +741,7 @@ class RemixBadgeStyler extends MixStyler final spec = RemixBadgeSpec( container: MixOps.resolve(context, $container), label: MixOps.resolve(context, $label), + containerEffects: MixOps.resolve(context, $containerEffects), ); return StyleSpec( @@ -730,13 +756,15 @@ class RemixBadgeStyler extends MixStyler super.debugFillProperties(properties); properties ..add(DiagnosticsProperty('container', $container)) - ..add(DiagnosticsProperty('label', $label)); + ..add(DiagnosticsProperty('label', $label)) + ..add(DiagnosticsProperty('containerEffects', $containerEffects)); } @override List get props => [ $container, $label, + $containerEffects, $animation, $modifier, $variants, diff --git a/packages/remix/lib/src/components/badge/badge_spec.dart b/packages/remix/lib/src/components/badge/badge_spec.dart index aaf5290d..47158c7d 100644 --- a/packages/remix/lib/src/components/badge/badge_spec.dart +++ b/packages/remix/lib/src/components/badge/badge_spec.dart @@ -8,10 +8,27 @@ class RemixBadgeSpec with _$RemixBadgeSpec { final StyleSpec container; @override final StyleSpec label; + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? containerEffects; const RemixBadgeSpec({ StyleSpec? container, StyleSpec? label, + this.containerEffects, }) : container = container ?? const StyleSpec(spec: BoxSpec()), label = label ?? const StyleSpec(spec: TextSpec()); + + @override + RemixBadgeSpec lerp(RemixBadgeSpec? other, double t) { + final generated = super.lerp(other, t); + if (other == null) return generated; + return generated.copyWith( + containerEffects: RemixBoxEffectsSpec.lerpNullable( + containerEffects, + other.containerEffects, + t, + ), + ); + } } diff --git a/packages/remix/lib/src/components/badge/badge_style.dart b/packages/remix/lib/src/components/badge/badge_style.dart index 2f164659..5bb3d16c 100644 --- a/packages/remix/lib/src/components/badge/badge_style.dart +++ b/packages/remix/lib/src/components/badge/badge_style.dart @@ -22,13 +22,11 @@ extension RemixBadgeStylerRemixHelpers on RemixBadgeStyler { String? label, Widget? child, RemixBadgeLabelBuilder? labelBuilder, - }) { - return RemixBadge( - key: key, - label: label, - labelBuilder: labelBuilder, - style: this, - child: child, - ); - } + }) => RemixBadge( + key: key, + label: label, + child: child, + labelBuilder: labelBuilder, + style: this, + ); } diff --git a/packages/remix/lib/src/components/badge/badge_widget.dart b/packages/remix/lib/src/components/badge/badge_widget.dart index 96fb750e..ca095695 100644 --- a/packages/remix/lib/src/components/badge/badge_widget.dart +++ b/packages/remix/lib/src/components/badge/badge_widget.dart @@ -1,6 +1,6 @@ part of 'badge.dart'; -/// Builder for rendering badge label content with the resolved text spec. +/// Builds badge label content with its resolved text style. typedef RemixBadgeLabelBuilder = Widget Function(BuildContext context, TextSpec spec, String label); @@ -12,13 +12,11 @@ typedef RemixBadgeLabelBuilder = /// ## Example /// /// ```dart -/// RemixBadge( -/// label: 'New', -/// ) +/// RemixBadge(label: 'New') /// ``` class RemixBadge extends StatelessWidget { - /// Creates a badge widget. Provide [label] for a text badge or [child] for - /// fully custom content. When nothing is provided, an empty label is used. + /// Creates a text badge with [label] or an arbitrary-content badge with + /// [child]. Arbitrary content inherits the resolved text and icon themes. const RemixBadge({ super.key, this.label, @@ -30,15 +28,8 @@ class RemixBadge extends StatelessWidget { static final styleFrom = RemixBadgeStyler.new; - /// Optional text label rendered with the badge text style. final String? label; - - /// Optional fully custom badge content. When provided the badge style is - /// applied only to the container. final Widget? child; - - /// Optional builder that receives the resolved [TextSpec] so callers can - /// render text with custom widgets while preserving badge typography. final RemixBadgeLabelBuilder? labelBuilder; /// The style configuration for the badge. @@ -53,20 +44,26 @@ class RemixBadge extends StatelessWidget { style: style, styleSpec: styleSpec, builder: (context, spec) { - Widget? content = child; + final foreground = spec.label.spec.style?.color; final resolvedLabel = label ?? ''; - - if (content == null) { - content = labelBuilder == null - ? StyledText(resolvedLabel, styleSpec: spec.label) - : StyleSpecBuilder( - styleSpec: spec.label, - builder: (context, textSpec) => - labelBuilder!(context, textSpec, resolvedLabel), - ); - } - - return Box(styleSpec: spec.container, child: content); + final content = child == null + ? labelBuilder == null + ? StyledText(resolvedLabel, styleSpec: spec.label) + : StyleSpecBuilder( + styleSpec: spec.label, + builder: (context, textSpec) => + labelBuilder!(context, textSpec, resolvedLabel), + ) + : RemixDefaultContentStyle( + text: spec.label, + icon: StyleSpec(spec: IconSpec(color: foreground)), + child: child!, + ); + return RemixBoxWithEffects( + styleSpec: spec.container, + containerEffects: spec.containerEffects, + child: content, + ); }, ); } diff --git a/packages/remix/lib/src/components/badge/fortal_badge_styles.dart b/packages/remix/lib/src/components/badge/fortal_badge_styles.dart index 56369764..84c934ef 100644 --- a/packages/remix/lib/src/components/badge/fortal_badge_styles.dart +++ b/packages/remix/lib/src/components/badge/fortal_badge_styles.dart @@ -1,89 +1,125 @@ part of 'badge.dart'; -/// Fortal badge size presets. +/// Radix Themes Badge size presets. enum FortalBadgeSize { size1, size2, size3 } -/// Fortal badge color and emphasis variants. +/// Radix Themes Badge variants. enum FortalBadgeVariant { solid, soft, surface, outline } -/// Fortal-themed preset for [RemixBadge]. +/// Fortal recipe for [RemixBadge]. @MixWidget( name: 'FortalBadge', target: RemixBadge.new, factoryParameters: .only({'variant', 'size'}), ) RemixBadgeStyler fortalBadgeStyler({ - FortalBadgeVariant variant = .solid, - FortalBadgeSize size = .size2, + FortalBadgeVariant variant = .soft, + FortalBadgeSize size = .size1, + bool highContrast = false, }) { + final base = _fortalBadgeBaseStyler(size); return switch (variant) { - .solid => _fortalBadgeSolidStyler(size), - .soft => _fortalBadgeSoftStyler(size), - .surface => _fortalBadgeSurfaceStyler(size), - .outline => _fortalBadgeOutlineStyler(size), + .solid => + base + .color( + highContrast ? FortalTokens.accent12() : FortalTokens.accent9(), + ) + .foregroundColor( + highContrast + ? FortalTokens.accent1() + : FortalTokens.accentContrast(), + ), + .soft => + base + .color(FortalTokens.accentA3()) + .foregroundColor( + highContrast ? FortalTokens.accent12() : FortalTokens.accentA11(), + ), + .surface => + base + .color(FortalTokens.accentSurface()) + .containerEffects( + RemixBoxEffectsMix( + behindContent: _fortalBadgeInsetStroke(FortalTokens.accentA6()), + ), + ) + .foregroundColor( + highContrast ? FortalTokens.accent12() : FortalTokens.accentA11(), + ), + .outline => + base + .containerEffects( + RemixBoxEffectsMix( + behindContent: RemixBoxEffectLayerMix( + shadows: [ + RemixBoxShadowMix( + kind: RemixBoxShadowKind.inset, + color: highContrast + ? FortalTokens.accentA7() + : FortalTokens.accentA8(), + spreadRadius: 1, + ), + if (highContrast) + RemixBoxShadowMix( + kind: RemixBoxShadowKind.inset, + color: FortalTokens.grayA11(), + spreadRadius: 1, + ), + ], + ), + ), + ) + .foregroundColor( + highContrast ? FortalTokens.accent12() : FortalTokens.accentA11(), + ), }; } RemixBadgeStyler _fortalBadgeBaseStyler(FortalBadgeSize size) { - return RemixBadgeStyler().merge(_fortalBadgeSizeStyler(size)); + final radius = _fortalBadgeRadius(size); + return RemixBadgeStyler( + container: .padding(_fortalBadgePadding(size)), + label: .style( + _fortalBadgeText(size).mix(), + ).fontWeight(FortalTokens.fontWeightMedium()), + ).borderRadiusAll(radius); } -RemixBadgeStyler _fortalBadgeSolidStyler([FortalBadgeSize size = .size2]) { - return _fortalBadgeBaseStyler(size) - .backgroundColor(FortalTokens.accent9()) - .foregroundColor(FortalTokens.accentContrast()); -} +TextStyleToken _fortalBadgeText(FortalBadgeSize size) => switch (size) { + .size1 || .size2 => FortalTokens.text1, + .size3 => FortalTokens.text2, +}; -RemixBadgeStyler _fortalBadgeSoftStyler([FortalBadgeSize size = .size2]) { - return _fortalBadgeBaseStyler(size) - .backgroundColor(FortalTokens.accentA3()) - .foregroundColor(FortalTokens.accent12()); -} +EdgeInsetsGeometryMix _fortalBadgePadding(FortalBadgeSize size) => + switch (size) { + .size1 => EdgeInsetsGeometryMix.symmetric( + horizontal: FortalTokens.badgePaddingX1(), + vertical: FortalTokens.badgePaddingY1(), + ), + .size2 => EdgeInsetsGeometryMix.symmetric( + horizontal: FortalTokens.space2(), + vertical: FortalTokens.space1(), + ), + .size3 => EdgeInsetsGeometryMix.symmetric( + horizontal: FortalTokens.badgePaddingX3(), + vertical: FortalTokens.space1(), + ), + }; -RemixBadgeStyler _fortalBadgeSurfaceStyler([FortalBadgeSize size = .size2]) { - return _fortalBadgeBaseStyler(size) - .backgroundColor(FortalTokens.accentA2()) - .borderAll( - color: FortalTokens.accent6(), - width: FortalTokens.borderWidth1(), - ) - .foregroundColor(FortalTokens.accent12()); -} +Radius _fortalBadgeRadius(FortalBadgeSize size) => switch (size) { + .size1 => FortalTokens.radius1OrFull(), + .size2 || .size3 => FortalTokens.radius2OrFull(), +}; -RemixBadgeStyler _fortalBadgeOutlineStyler([FortalBadgeSize size = .size2]) { - return _fortalBadgeBaseStyler(size) - .backgroundColor(Colors.transparent) - .borderAll( - color: FortalTokens.accent7(), - width: FortalTokens.borderWidth1(), - ) - .foregroundColor(FortalTokens.accent12()); -} - -RemixBadgeStyler _fortalBadgeSizeStyler(FortalBadgeSize size) { - return switch (size) { - .size1 => RemixBadgeStyler( - container: BoxStyler() - .paddingX(6.0) - .paddingY(2.0) - .borderRadiusAll(FortalTokens.radius2()), - label: TextStyler().fontSize(11.0).height(16.0 / 11.0), - ), - .size2 => RemixBadgeStyler( - container: BoxStyler() - .paddingX(8.0) - .paddingY(3.0) - .borderRadiusAll(FortalTokens.radius3()), - label: TextStyler().fontSize(12.0).height(18.0 / 12.0), - ), - .size3 => RemixBadgeStyler( - container: BoxStyler() - .paddingX(10.0) - .paddingY(4.0) - .borderRadiusAll(FortalTokens.radius3()), - label: TextStyler().fontSize(13.0).height(20.0 / 13.0), - ), - }; -} +RemixBoxEffectLayerMix _fortalBadgeInsetStroke(Color color) => + RemixBoxEffectLayerMix( + shadows: [ + RemixBoxShadowMix( + kind: RemixBoxShadowKind.inset, + color: color, + spreadRadius: 1, + ), + ], + ); -/// Fortal-themed preset for [RemixBadge]. +/// Fortal-themed Badge with the Radix size, variant, and override contract. diff --git a/packages/remix/lib/src/components/button/button.dart b/packages/remix/lib/src/components/button/button.dart index eda3640b..251aab5a 100644 --- a/packages/remix/lib/src/components/button/button.dart +++ b/packages/remix/lib/src/components/button/button.dart @@ -2,12 +2,13 @@ library remix_button; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:mix/mix.dart'; import 'package:mix_annotations/mix_annotations.dart'; import 'package:naked_ui/naked_ui.dart'; import '../../fortal/fortal.dart'; +import '../../rendering/remix_ordered_color_filter.dart'; +import '../../rendering/remix_box_effects.dart'; import '../../style/style.dart'; import '../../utilities/remix_style.dart'; import '../spinner/spinner.dart'; diff --git a/packages/remix/lib/src/components/button/button.g.dart b/packages/remix/lib/src/components/button/button.g.dart index a6afadcc..dfbbc602 100644 --- a/packages/remix/lib/src/components/button/button.g.dart +++ b/packages/remix/lib/src/components/button/button.g.dart @@ -11,6 +11,7 @@ mixin _$ButtonSpec implements Spec, Diagnosticable { StyleSpec get label; StyleSpec get icon; StyleSpec get spinner; + RemixBoxEffectsSpec? get containerEffects; IconAlignment? get iconAlignment; @override @@ -22,6 +23,7 @@ mixin _$ButtonSpec implements Spec, Diagnosticable { StyleSpec? label, StyleSpec? icon, StyleSpec? spinner, + RemixBoxEffectsSpec? containerEffects, IconAlignment? iconAlignment, }) { return ButtonSpec( @@ -29,6 +31,7 @@ mixin _$ButtonSpec implements Spec, Diagnosticable { label: label ?? this.label, icon: icon ?? this.icon, spinner: spinner ?? this.spinner, + containerEffects: containerEffects ?? this.containerEffects, iconAlignment: iconAlignment ?? this.iconAlignment, ); } @@ -40,12 +43,24 @@ mixin _$ButtonSpec implements Spec, Diagnosticable { label: label.lerp(other?.label, t), icon: icon.lerp(other?.icon, t), spinner: spinner.lerp(other?.spinner, t), + containerEffects: MixOps.lerpSnap( + containerEffects, + other?.containerEffects, + t, + ), iconAlignment: MixOps.lerpSnap(iconAlignment, other?.iconAlignment, t), ); } @override - List get props => [container, label, icon, spinner, iconAlignment]; + List get props => [ + container, + label, + icon, + spinner, + containerEffects, + iconAlignment, + ]; @override bool operator ==(Object other) { @@ -91,6 +106,7 @@ mixin _$ButtonSpec implements Spec, Diagnosticable { ..add(DiagnosticsProperty('label', label)) ..add(DiagnosticsProperty('icon', icon)) ..add(DiagnosticsProperty('spinner', spinner)) + ..add(DiagnosticsProperty('containerEffects', containerEffects)) ..add(DiagnosticsProperty('iconAlignment', iconAlignment)); } } @@ -104,7 +120,7 @@ typedef _$ButtonSpecMethods = _$ButtonSpec; // ignore: unused_element // MixWidgetGenerator // ************************************************************************** -/// Fortal-themed button style and widget presets. +/// Fortal recipe for [RemixButton]. class FortalButton extends StatelessWidget { const FortalButton({ super.key, @@ -130,7 +146,29 @@ class FortalButton extends StatelessWidget { this.mouseCursor = SystemMouseCursors.click, }); - /// High-emphasis filled button. + const FortalButton.classic({ + super.key, + this.size = .size2, + required this.label, + this.leadingIcon, + this.trailingIcon, + this.textBuilder, + this.leadingIconBuilder, + this.trailingIconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalButtonVariant.classic; + const FortalButton.solid({ super.key, this.size = .size2, @@ -154,7 +192,6 @@ class FortalButton extends StatelessWidget { this.mouseCursor = SystemMouseCursors.click, }) : variant = FortalButtonVariant.solid; - /// Low-emphasis filled button. const FortalButton.soft({ super.key, this.size = .size2, @@ -178,7 +215,6 @@ class FortalButton extends StatelessWidget { this.mouseCursor = SystemMouseCursors.click, }) : variant = FortalButtonVariant.soft; - /// Subtle surface button with a border. const FortalButton.surface({ super.key, this.size = .size2, @@ -202,7 +238,6 @@ class FortalButton extends StatelessWidget { this.mouseCursor = SystemMouseCursors.click, }) : variant = FortalButtonVariant.surface; - /// Transparent button with an outline. const FortalButton.outline({ super.key, this.size = .size2, @@ -226,7 +261,6 @@ class FortalButton extends StatelessWidget { this.mouseCursor = SystemMouseCursors.click, }) : variant = FortalButtonVariant.outline; - /// Transparent button without a persistent border. const FortalButton.ghost({ super.key, this.size = .size2, @@ -331,6 +365,7 @@ class ButtonStyler extends MixStyler final Prop>? $label; final Prop>? $icon; final Prop>? $spinner; + final Prop? $containerEffects; final Prop? $iconAlignment; const ButtonStyler.create({ @@ -338,6 +373,7 @@ class ButtonStyler extends MixStyler Prop>? label, Prop>? icon, Prop>? spinner, + Prop? containerEffects, Prop? iconAlignment, super.variants, super.modifier, @@ -346,6 +382,7 @@ class ButtonStyler extends MixStyler $label = label, $icon = icon, $spinner = spinner, + $containerEffects = containerEffects, $iconAlignment = iconAlignment; ButtonStyler({ @@ -353,6 +390,7 @@ class ButtonStyler extends MixStyler TextStyler? label, IconStyler? icon, RemixSpinnerStyler? spinner, + RemixBoxEffectsMix? containerEffects, IconAlignment? iconAlignment, AnimationConfig? animation, WidgetModifierConfig? modifier, @@ -362,6 +400,7 @@ class ButtonStyler extends MixStyler label: Prop.maybeMix(label), icon: Prop.maybeMix(icon), spinner: Prop.maybeMix(spinner), + containerEffects: Prop.maybeMix(containerEffects), iconAlignment: Prop.maybe(iconAlignment), variants: variants, modifier: modifier, @@ -374,6 +413,8 @@ class ButtonStyler extends MixStyler factory ButtonStyler.icon(IconStyler value) => ButtonStyler().icon(value); factory ButtonStyler.spinner(RemixSpinnerStyler value) => ButtonStyler().spinner(value); + factory ButtonStyler.containerEffects(RemixBoxEffectsMix value) => + ButtonStyler().containerEffects(value); factory ButtonStyler.iconAlignment(IconAlignment value) => ButtonStyler().iconAlignment(value); factory ButtonStyler.color(Color value) => ButtonStyler().color(value); @@ -920,6 +961,11 @@ class ButtonStyler extends MixStyler return merge(ButtonStyler(spinner: value)); } + /// Sets the containerEffects. + ButtonStyler containerEffects(RemixBoxEffectsMix value) { + return merge(ButtonStyler(containerEffects: value)); + } + /// Sets the iconAlignment. ButtonStyler iconAlignment(IconAlignment value) { return merge(ButtonStyler(iconAlignment: value)); @@ -956,6 +1002,10 @@ class ButtonStyler extends MixStyler label: MixOps.merge($label, other?.$label), icon: MixOps.merge($icon, other?.$icon), spinner: MixOps.merge($spinner, other?.$spinner), + containerEffects: MixOps.merge( + $containerEffects, + other?.$containerEffects, + ), iconAlignment: MixOps.merge($iconAlignment, other?.$iconAlignment), variants: MixOps.mergeVariants($variants, other?.$variants), modifier: MixOps.mergeModifier($modifier, other?.$modifier), @@ -971,6 +1021,7 @@ class ButtonStyler extends MixStyler label: MixOps.resolve(context, $label), icon: MixOps.resolve(context, $icon), spinner: MixOps.resolve(context, $spinner), + containerEffects: MixOps.resolve(context, $containerEffects), iconAlignment: MixOps.resolve(context, $iconAlignment), ); @@ -989,6 +1040,7 @@ class ButtonStyler extends MixStyler ..add(DiagnosticsProperty('label', $label)) ..add(DiagnosticsProperty('icon', $icon)) ..add(DiagnosticsProperty('spinner', $spinner)) + ..add(DiagnosticsProperty('containerEffects', $containerEffects)) ..add(DiagnosticsProperty('iconAlignment', $iconAlignment)); } @@ -998,6 +1050,7 @@ class ButtonStyler extends MixStyler $label, $icon, $spinner, + $containerEffects, $iconAlignment, $animation, $modifier, diff --git a/packages/remix/lib/src/components/button/button_spec.dart b/packages/remix/lib/src/components/button/button_spec.dart index bd104323..d34695f7 100644 --- a/packages/remix/lib/src/components/button/button_spec.dart +++ b/packages/remix/lib/src/components/button/button_spec.dart @@ -36,9 +36,9 @@ part of 'button.dart'; /// /// // Widget receives the resolved spec /// RemixButton( -/// label: 'Click me', /// style: style, /// onPressed: () {}, +/// child: Text('Click me'), /// ) /// ``` /// @@ -93,13 +93,11 @@ class ButtonSpec with _$ButtonSpec { @override final StyleSpec spinner; - /// Optional alignment override for a button with exactly one icon. - /// - /// When set to [IconAlignment.start], the icon appears before the label. - /// When set to [IconAlignment.end], the icon appears after the label. - /// When omitted, [RemixButton.leadingIcon] and [RemixButton.trailingIcon] - /// keep their named positions. - /// Buttons with both icons always keep both explicit positions. + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? containerEffects; + + /// Placement used when exactly one legacy icon is present. @override final IconAlignment? iconAlignment; @@ -121,12 +119,25 @@ class ButtonSpec with _$ButtonSpec { StyleSpec? label, StyleSpec? icon, StyleSpec? spinner, - IconAlignment? iconAlignment, + this.containerEffects, + this.iconAlignment, }) : container = container ?? const StyleSpec(spec: FlexBoxSpec()), label = label ?? const StyleSpec(spec: TextSpec()), icon = icon ?? const StyleSpec(spec: IconSpec()), - spinner = spinner ?? const StyleSpec(spec: RemixSpinnerSpec()), - iconAlignment = iconAlignment; + spinner = spinner ?? const StyleSpec(spec: RemixSpinnerSpec()); + + @override + RemixButtonSpec lerp(RemixButtonSpec? other, double t) { + final generated = super.lerp(other, t); + if (other == null) return generated; + return generated.copyWith( + containerEffects: RemixBoxEffectsSpec.lerpNullable( + containerEffects, + other.containerEffects, + t, + ), + ); + } } /// Backward-compatible name for [ButtonSpec]. diff --git a/packages/remix/lib/src/components/button/button_widget.dart b/packages/remix/lib/src/components/button/button_widget.dart index 52f238e7..0c7a1961 100644 --- a/packages/remix/lib/src/components/button/button_widget.dart +++ b/packages/remix/lib/src/components/button/button_widget.dart @@ -251,7 +251,12 @@ class RemixButton extends StatelessWidget { .toList(); // Create content row with visibility control for loading state - final contentRow = RowBox(styleSpec: spec.container, children: rowChildren); + final contentRow = RemixFlexBoxWithEffects( + styleSpec: spec.container, + direction: Axis.horizontal, + containerEffects: spec.containerEffects, + children: rowChildren, + ); // Layer spinner above the content while keeping size stable. final layered = Stack( diff --git a/packages/remix/lib/src/components/button/fortal_button_styles.dart b/packages/remix/lib/src/components/button/fortal_button_styles.dart index a372a759..423bef78 100644 --- a/packages/remix/lib/src/components/button/fortal_button_styles.dart +++ b/packages/remix/lib/src/components/button/fortal_button_styles.dart @@ -1,39 +1,12 @@ part of 'button.dart'; -/// Fortal button size presets. -enum FortalButtonSize { - /// Compact button. - size1, +/// Radix Themes Button size presets. +enum FortalButtonSize { size1, size2, size3, size4 } - /// Default button. - size2, +/// Radix Themes Button variants. +enum FortalButtonVariant { classic, solid, soft, surface, outline, ghost } - /// Large button. - size3, - - /// Extra-large button. - size4, -} - -/// Fortal button color and emphasis variants. -enum FortalButtonVariant { - /// High-emphasis filled button. - solid, - - /// Low-emphasis filled button. - soft, - - /// Subtle surface button with a border. - surface, - - /// Transparent button with an outline. - outline, - - /// Transparent button without a persistent border. - ghost, -} - -/// Fortal-themed button style and widget presets. +/// Fortal recipe for [RemixButton]. @MixWidget( name: 'FortalButton', target: RemixButton.new, @@ -42,204 +15,349 @@ enum FortalButtonVariant { ButtonStyler fortalButtonStyler({ FortalButtonVariant variant = .solid, FortalButtonSize size = .size2, + bool highContrast = false, }) { - return switch (variant) { - .solid => _fortalButtonSolidStyler(size), - .soft => _fortalButtonSoftStyler(size), - .surface => _fortalButtonSurfaceStyler(size), - .outline => _fortalButtonOutlineStyler(size), - .ghost => _fortalButtonGhostStyler(size), - }; -} + final index = size.index + 1; + final base = _fortalButtonBaseStyler(variant, index); + final focus = fortalFocusOutline( + variant == .soft ? FortalTokens.accent8() : FortalTokens.focus8(), + offset: variant == .classic || variant == .solid ? 2 : -1, + ); + final disabledFocus = RemixBoxEffectsMix( + outline: BorderSideMix(style: BorderStyle.none), + ); -ButtonStyler _fortalButtonBaseStyler(FortalButtonSize size) { - return ButtonStyler() - .label(TextStyler().fontWeight(FortalTokens.fontWeightMedium())) - .spinner( - .strokeWidth( - FortalTokens.borderWidth2(), - ).duration(const Duration(milliseconds: 800)), - ) - .onFocused( - ButtonStyler().borderAll( - color: FortalTokens.focusA8(), - width: FortalTokens.focusRingWidth(), + return switch (variant) { + .classic => _fortalButtonClassic( + base, + size: index, + highContrast: highContrast, ), - ) - .merge(_fortalButtonSizeStyler(size)); + .solid => _fortalButtonSolid(base, highContrast: highContrast), + .soft => _fortalButtonSoft(base, highContrast: highContrast), + .surface => _fortalButtonSurface(base, highContrast: highContrast), + .outline => _fortalButtonOutline(base, highContrast: highContrast), + .ghost => _fortalButtonGhost(base, highContrast: highContrast), + } + .onFocused(.containerEffects(focus)) + .onDisabled(.containerEffects(disabledFocus)); } -ButtonStyler _fortalButtonSolidStyler([FortalButtonSize size = .size2]) { - return _fortalButtonBaseStyler(size) - .backgroundColor(FortalTokens.accent9()) - .foregroundColor(FortalTokens.accentContrast()) - .spinner(.indicatorColor(FortalTokens.accentContrast())) - .onHovered(ButtonStyler().backgroundColor(FortalTokens.accent10())) - .onPressed(ButtonStyler().backgroundColor(FortalTokens.accent10())) - .onDisabled( - ButtonStyler() - .backgroundColor(FortalTokens.grayA3()) - .foregroundColor(FortalTokens.gray8()) - .spinner( - .indicatorColor( - FortalTokens.gray8(), - ).strokeWidth(FortalTokens.borderWidth1()), - ), - ); -} +ButtonStyler _fortalButtonBaseStyler(FortalButtonVariant variant, int size) { + final metrics = fortalBaseButtonMetrics(size); + var style = ButtonStyler( + container: .direction(.horizontal) + .mainAxisAlignment(.center) + .mainAxisSize(.min) + .crossAxisAlignment(.center) + .spacing(metrics.gap), + label: .style(metrics.text.mix()).fontWeight( + variant == .ghost + ? FortalTokens.fontWeightRegular() + : FortalTokens.fontWeightMedium(), + ), + spinner: .size( + metrics.spinnerSize, + ).opacity(0.65).duration(const Duration(milliseconds: 800)), + ).borderRadiusAll(metrics.radius); -ButtonStyler _fortalButtonSoftStyler([FortalButtonSize size = .size2]) { - return _fortalButtonBaseStyler(size) - .backgroundColor(FortalTokens.accent3()) - .foregroundColor(FortalTokens.accent11()) - .spinner(.indicatorColor(FortalTokens.accent11())) - .onHovered(ButtonStyler().backgroundColor(FortalTokens.accent4())) - .onPressed(ButtonStyler().backgroundColor(FortalTokens.accent5())) - .onDisabled( - ButtonStyler() - .backgroundColor(FortalTokens.grayA3()) - .foregroundColor(FortalTokens.gray8()) - .spinner( - .indicatorColor( - FortalTokens.gray8(), - ).strokeWidth(FortalTokens.borderWidth1()), - ), - ); + if (variant == .ghost) { + final ghost = fortalBaseButtonGhostMetrics(size); + style = style + .spacing(ghost.gap) + .paddingOnly(horizontal: ghost.paddingX, vertical: ghost.paddingY) + .marginOnly(horizontal: ghost.marginX, vertical: ghost.marginY); + } else { + style = style + .height(metrics.height) + .paddingX(metrics.paddingX) + .icon(.opacity(0.9)); + } + return style; } -ButtonStyler _fortalButtonSurfaceStyler([FortalButtonSize size = .size2]) { - return _fortalButtonBaseStyler(size) - .backgroundColor(FortalTokens.accentA2()) - .borderAll( - color: FortalTokens.accent6(), - width: FortalTokens.borderWidth1(), +ButtonStyler _fortalButtonClassic( + ButtonStyler base, { + required int size, + required bool highContrast, +}) { + final foreground = highContrast + ? FortalTokens.gray1() + : FortalTokens.accentContrast(); + final activePadding = size == 1 ? 1.0 : 2.0; + return _fortalButtonForeground(base, foreground) + .color(highContrast ? FortalTokens.accent12() : FortalTokens.accent9()) + .containerEffects( + RemixBoxEffectsMix( + behindContent: fortalClassicBaseButtonSurface( + highContrast: highContrast, + ), + ), ) - .foregroundColor(FortalTokens.accent11()) - .spinner(.indicatorColor(FortalTokens.accent11())) .onHovered( - ButtonStyler().borderAll( - color: FortalTokens.accent8(), - width: FortalTokens.borderWidth1(), + .containerEffects( + RemixBoxEffectsMix( + behindContent: fortalClassicBaseButtonSurface( + highContrast: highContrast, + hovered: true, + ), + ), + ).wrap( + highContrast + ? fortalModeAwareFilter( + light: const [ + RemixCssColorFilterOperation.contrast(0.88), + RemixCssColorFilterOperation.saturate(1.1), + RemixCssColorFilterOperation.brightness(1.1), + ], + dark: const [ + RemixCssColorFilterOperation.contrast(0.88), + RemixCssColorFilterOperation.saturate(1.3), + RemixCssColorFilterOperation.brightness(1.14), + ], + ) + : fortalClearFilter(), ), ) - .onDisabled( - ButtonStyler() - .backgroundColor(FortalTokens.grayA2()) - .foregroundColor(FortalTokens.gray8()) - .borderAll( - color: FortalTokens.gray5(), - width: FortalTokens.borderWidth1(), + .onPressed( + .containerEffects( + RemixBoxEffectsMix( + behindContent: fortalClassicBaseButtonSurface( + highContrast: highContrast, + pressed: true, + ), + ), ) - .spinner( - .indicatorColor( - FortalTokens.gray8(), - ).strokeWidth(FortalTokens.borderWidth1()), + .paddingTop(activePadding) + .wrap( + highContrast + ? fortalModeAwareFilter( + light: const [ + RemixCssColorFilterOperation.contrast(0.82), + RemixCssColorFilterOperation.saturate(1.2), + RemixCssColorFilterOperation.brightness(1.16), + ], + dark: const [ + RemixCssColorFilterOperation.brightness(0.95), + RemixCssColorFilterOperation.saturate(1.2), + ], + ) + : fortalModeAwareFilter( + light: const [ + RemixCssColorFilterOperation.brightness(0.92), + RemixCssColorFilterOperation.saturate(1.1), + ], + dark: const [ + RemixCssColorFilterOperation.brightness(1.08), + ], + ), ), + ) + .onDisabled( + _fortalButtonForeground(ButtonStyler(), FortalTokens.grayA8()) + .color(FortalTokens.gray2()) + .containerEffects( + RemixBoxEffectsMix( + behindContent: fortalClassicBaseButtonSurface( + highContrast: false, + disabled: true, + ), + ), + ) + .spinner(.opacity(1)) + .wrap(fortalClearFilter()), ); } -ButtonStyler _fortalButtonOutlineStyler([FortalButtonSize size = .size2]) { - return _fortalButtonBaseStyler(size) - .backgroundColor(Colors.transparent) - .borderAll( - color: FortalTokens.accent7(), - width: FortalTokens.borderWidth1(), - ) - .foregroundColor(FortalTokens.accent11()) - .spinner(.indicatorColor(FortalTokens.accent11())) +ButtonStyler _fortalButtonSolid( + ButtonStyler base, { + required bool highContrast, +}) { + final foreground = highContrast + ? FortalTokens.gray1() + : FortalTokens.accentContrast(); + final idle = highContrast ? FortalTokens.accent12() : FortalTokens.accent9(); + return _fortalButtonForeground(base, foreground) + .color(idle) .onHovered( - ButtonStyler() - .backgroundColor(FortalTokens.accentA2()) - .borderAll( - color: FortalTokens.accent8(), - width: FortalTokens.borderWidth1(), - ), + .color( + highContrast ? FortalTokens.accent12() : FortalTokens.accent10(), + ).wrap( + highContrast + ? fortalModeAwareFilter( + light: const [ + RemixCssColorFilterOperation.contrast(0.88), + RemixCssColorFilterOperation.saturate(1.1), + RemixCssColorFilterOperation.brightness(1.1), + ], + dark: const [ + RemixCssColorFilterOperation.contrast(0.88), + RemixCssColorFilterOperation.saturate(1.3), + RemixCssColorFilterOperation.brightness(1.18), + ], + ) + : fortalClearFilter(), + ), + ) + .onPressed( + .color( + highContrast ? FortalTokens.accent12() : FortalTokens.accent10(), + ).wrap( + highContrast + ? fortalModeAwareFilter( + light: const [ + RemixCssColorFilterOperation.contrast(0.82), + RemixCssColorFilterOperation.saturate(1.2), + RemixCssColorFilterOperation.brightness(1.16), + ], + dark: const [ + RemixCssColorFilterOperation.brightness(0.95), + RemixCssColorFilterOperation.saturate(1.2), + ], + ) + : fortalModeAwareFilter( + light: const [ + RemixCssColorFilterOperation.brightness(0.92), + RemixCssColorFilterOperation.saturate(1.1), + ], + dark: const [RemixCssColorFilterOperation.brightness(1.08)], + ), + ), ) .onDisabled( - ButtonStyler() - .foregroundColor(FortalTokens.gray8()) - .borderAll(color: FortalTokens.gray5()) - .spinner( - .indicatorColor( - FortalTokens.gray8(), - ).strokeWidth(FortalTokens.borderWidth1()), - ), + _fortalButtonForeground(ButtonStyler(), FortalTokens.grayA8()) + .color(FortalTokens.grayA3()) + .spinner(.opacity(1)) + .wrap(fortalClearFilter()), ); } -ButtonStyler _fortalButtonGhostStyler([FortalButtonSize size = .size2]) { - return _fortalButtonBaseStyler(size) - .backgroundColor(Colors.transparent) - .foregroundColor(FortalTokens.accent11()) - .spinner(.indicatorColor(FortalTokens.accent11())) - .onHovered(ButtonStyler().backgroundColor(FortalTokens.accentA3())) - .onPressed(ButtonStyler().backgroundColor(FortalTokens.accentA4())) - .onDisabled( - ButtonStyler() - .foregroundColor(FortalTokens.gray8()) - .spinner( - .indicatorColor( - FortalTokens.gray8(), - ).strokeWidth(FortalTokens.borderWidth1()), +ButtonStyler _fortalButtonSoft( + ButtonStyler base, { + required bool highContrast, +}) => + _fortalButtonForeground( + base, + highContrast ? FortalTokens.accent12() : FortalTokens.accentA11(), + ) + .color(FortalTokens.accentA3()) + .onHovered(.color(FortalTokens.accentA4())) + .onPressed(.color(FortalTokens.accentA5())) + .onDisabled(_fortalButtonDisabledFill()); + +ButtonStyler _fortalButtonSurface( + ButtonStyler base, { + required bool highContrast, +}) => + _fortalButtonForeground( + base, + highContrast ? FortalTokens.accent12() : FortalTokens.accentA11(), + ) + .color(FortalTokens.accentSurface()) + .containerEffects( + RemixBoxEffectsMix( + behindContent: fortalInsetSurface( + strokes: [FortalTokens.accentA7()], + ), + ), + ) + .onHovered( + .color(FortalTokens.accentSurface()).containerEffects( + RemixBoxEffectsMix( + behindContent: fortalInsetSurface( + strokes: [FortalTokens.accentA8()], + ), + ), + ), + ) + .onPressed( + .color(FortalTokens.accentA3()).containerEffects( + RemixBoxEffectsMix( + behindContent: fortalInsetSurface( + strokes: [FortalTokens.accentA8()], + ), ), + ), + ) + .onDisabled( + _fortalButtonForeground(ButtonStyler(), FortalTokens.grayA8()) + .color(FortalTokens.grayA2()) + .containerEffects( + RemixBoxEffectsMix( + behindContent: fortalInsetSurface( + strokes: [FortalTokens.grayA6()], + ), + ), + ) + .spinner(.opacity(1)), + ); + +ButtonStyler _fortalButtonOutline( + ButtonStyler base, { + required bool highContrast, +}) { + final strokes = highContrast + ? [FortalTokens.accentA7(), FortalTokens.grayA11()] + : [FortalTokens.accentA8()]; + return _fortalButtonForeground( + base, + highContrast ? FortalTokens.accent12() : FortalTokens.accentA11(), + ) + .containerEffects( + RemixBoxEffectsMix(behindContent: fortalInsetSurface(strokes: strokes)), + ) + .onHovered( + .color(FortalTokens.accentA2()).containerEffects( + RemixBoxEffectsMix( + behindContent: fortalInsetSurface(strokes: strokes), + ), + ), + ) + .onPressed( + .color(FortalTokens.accentA3()).containerEffects( + RemixBoxEffectsMix( + behindContent: fortalInsetSurface(strokes: strokes), + ), + ), + ) + .onDisabled( + _fortalButtonForeground(ButtonStyler(), FortalTokens.grayA8()) + .color(Colors.transparent) + .containerEffects( + RemixBoxEffectsMix( + behindContent: fortalInsetSurface( + strokes: [FortalTokens.grayA7()], + ), + ), + ) + .spinner(.opacity(1)), ); } -ButtonStyler _fortalButtonSizeStyler(FortalButtonSize size) { - final style = ButtonStyler(); +ButtonStyler _fortalButtonGhost( + ButtonStyler base, { + required bool highContrast, +}) => + _fortalButtonForeground( + base, + highContrast ? FortalTokens.accent12() : FortalTokens.accentA11(), + ) + .color(Colors.transparent) + .onHovered(.color(FortalTokens.accentA3())) + .onPressed(.color(FortalTokens.accentA4())) + .onDisabled( + _fortalButtonForeground( + ButtonStyler(), + FortalTokens.grayA8(), + ).color(Colors.transparent).spinner(.opacity(1)), + ); - return switch (size) { - .size1 => - style - .paddingX(FortalTokens.space2()) - .paddingY(FortalTokens.space1()) - .spacing(FortalTokens.space1()) - .borderRadiusAll(FortalTokens.radius2()) - .label( - TextStyler() - .fontSize(12.0) - .height(16.0 / 12.0) - .letterSpacing(0.0025), - ) - .icon(.size(12.0)) - .spinner(.size(12.0)), - .size2 => - style - .paddingX(FortalTokens.space3()) - .paddingY(FortalTokens.space2()) - .spacing(FortalTokens.space2()) - .borderRadiusAll(FortalTokens.radius3()) - .label( - TextStyler().fontSize(14.0).height(20.0 / 14.0).letterSpacing(0.0), - ) - .icon(.size(16.0)) - .spinner(.size(16.0)), - .size3 => - style - .paddingX(FortalTokens.space4()) - .paddingY(FortalTokens.space3()) - .spacing(FortalTokens.space3()) - .borderRadiusAll(FortalTokens.radius4()) - .label( - TextStyler().fontSize(16.0).height(24.0 / 16.0).letterSpacing(0.0), - ) - .icon(.size(20.0)) - .spinner(.size(20.0)), - .size4 => - style - .paddingX(FortalTokens.space5()) - .paddingY(FortalTokens.space4()) - .spacing(FortalTokens.space4()) - .borderRadiusAll(FortalTokens.radius5()) - .label( - TextStyler() - .fontSize(18.0) - .height(26.0 / 18.0) - .letterSpacing(-0.0025), - ) - .icon(.size(24.0)) - .spinner(.size(24.0)), - }; -} +ButtonStyler _fortalButtonDisabledFill() => _fortalButtonForeground( + ButtonStyler(), + FortalTokens.grayA8(), +).color(FortalTokens.grayA3()).spinner(.opacity(1)); + +ButtonStyler _fortalButtonForeground(ButtonStyler style, Color color) => + style.label(.color(color)).icon(.color(color)).spinner(.color(color)); -/// Fortal-themed button style and widget presets. +/// Fortal-themed Button with the Radix size, variant, and override contract. diff --git a/packages/remix/lib/src/components/callout/callout.dart b/packages/remix/lib/src/components/callout/callout.dart index 11704af8..1d821703 100644 --- a/packages/remix/lib/src/components/callout/callout.dart +++ b/packages/remix/lib/src/components/callout/callout.dart @@ -8,6 +8,7 @@ import 'package:mix_annotations/mix_annotations.dart'; import '../../style/style.dart'; import '../../utilities/remix_style.dart'; import '../../fortal/fortal.dart'; +import '../../rendering/remix_box_effects.dart'; part 'callout_spec.dart'; part 'callout_style.dart'; diff --git a/packages/remix/lib/src/components/callout/callout.g.dart b/packages/remix/lib/src/components/callout/callout.g.dart index 9c23ed89..3d6a8225 100644 --- a/packages/remix/lib/src/components/callout/callout.g.dart +++ b/packages/remix/lib/src/components/callout/callout.g.dart @@ -10,6 +10,7 @@ mixin _$RemixCalloutSpec implements Spec, Diagnosticable { StyleSpec get container; StyleSpec get text; StyleSpec get icon; + RemixBoxEffectsSpec? get containerEffects; @override Type get type => RemixCalloutSpec; @@ -19,11 +20,13 @@ mixin _$RemixCalloutSpec implements Spec, Diagnosticable { StyleSpec? container, StyleSpec? text, StyleSpec? icon, + RemixBoxEffectsSpec? containerEffects, }) { return RemixCalloutSpec( container: container ?? this.container, text: text ?? this.text, icon: icon ?? this.icon, + containerEffects: containerEffects ?? this.containerEffects, ); } @@ -33,11 +36,16 @@ mixin _$RemixCalloutSpec implements Spec, Diagnosticable { container: container.lerp(other?.container, t), text: text.lerp(other?.text, t), icon: icon.lerp(other?.icon, t), + containerEffects: MixOps.lerpSnap( + containerEffects, + other?.containerEffects, + t, + ), ); } @override - List get props => [container, text, icon]; + List get props => [container, text, icon, containerEffects]; @override bool operator ==(Object other) { @@ -81,7 +89,8 @@ mixin _$RemixCalloutSpec implements Spec, Diagnosticable { properties ..add(DiagnosticsProperty('container', container)) ..add(DiagnosticsProperty('text', text)) - ..add(DiagnosticsProperty('icon', icon)); + ..add(DiagnosticsProperty('icon', icon)) + ..add(DiagnosticsProperty('containerEffects', containerEffects)); } } @@ -94,24 +103,24 @@ typedef _$RemixCalloutSpecMethods = _$RemixCalloutSpec; // ignore: unused_elemen // MixWidgetGenerator // ************************************************************************** -/// Fortal-themed preset for [RemixCallout]. +/// Fortal recipe for [RemixCallout]. class FortalCallout extends StatelessWidget { const FortalCallout({ super.key, - this.variant = .surface, + this.variant = .soft, this.size = .size2, this.text, this.icon, this.child, }); - const FortalCallout.outline({ + const FortalCallout.soft({ super.key, this.size = .size2, this.text, this.icon, this.child, - }) : variant = FortalCalloutVariant.outline; + }) : variant = FortalCalloutVariant.soft; const FortalCallout.surface({ super.key, @@ -121,13 +130,13 @@ class FortalCallout extends StatelessWidget { this.child, }) : variant = FortalCalloutVariant.surface; - const FortalCallout.soft({ + const FortalCallout.outline({ super.key, this.size = .size2, this.text, this.icon, this.child, - }) : variant = FortalCalloutVariant.soft; + }) : variant = FortalCalloutVariant.outline; final FortalCalloutVariant variant; @@ -162,22 +171,26 @@ class RemixCalloutStyler extends MixStyler final Prop>? $container; final Prop>? $text; final Prop>? $icon; + final Prop? $containerEffects; const RemixCalloutStyler.create({ Prop>? container, Prop>? text, Prop>? icon, + Prop? containerEffects, super.variants, super.modifier, super.animation, }) : $container = container, $text = text, - $icon = icon; + $icon = icon, + $containerEffects = containerEffects; RemixCalloutStyler({ FlexBoxStyler? container, TextStyler? text, IconStyler? icon, + RemixBoxEffectsMix? containerEffects, AnimationConfig? animation, WidgetModifierConfig? modifier, List>? variants, @@ -185,6 +198,7 @@ class RemixCalloutStyler extends MixStyler container: Prop.maybeMix(container), text: Prop.maybeMix(text), icon: Prop.maybeMix(icon), + containerEffects: Prop.maybeMix(containerEffects), variants: variants, modifier: modifier, animation: animation, @@ -196,6 +210,8 @@ class RemixCalloutStyler extends MixStyler RemixCalloutStyler().text(value); factory RemixCalloutStyler.icon(IconStyler value) => RemixCalloutStyler().icon(value); + factory RemixCalloutStyler.containerEffects(RemixBoxEffectsMix value) => + RemixCalloutStyler().containerEffects(value); factory RemixCalloutStyler.color(Color value) => RemixCalloutStyler().color(value); factory RemixCalloutStyler.gradient(GradientMix value) => @@ -742,6 +758,11 @@ class RemixCalloutStyler extends MixStyler return merge(RemixCalloutStyler(icon: value)); } + /// Sets the containerEffects. + RemixCalloutStyler containerEffects(RemixBoxEffectsMix value) { + return merge(RemixCalloutStyler(containerEffects: value)); + } + /// Sets the animation configuration. @override RemixCalloutStyler animate(AnimationConfig value) { @@ -772,6 +793,10 @@ class RemixCalloutStyler extends MixStyler container: MixOps.merge($container, other?.$container), text: MixOps.merge($text, other?.$text), icon: MixOps.merge($icon, other?.$icon), + containerEffects: MixOps.merge( + $containerEffects, + other?.$containerEffects, + ), variants: MixOps.mergeVariants($variants, other?.$variants), modifier: MixOps.mergeModifier($modifier, other?.$modifier), animation: MixOps.mergeAnimation($animation, other?.$animation), @@ -785,6 +810,7 @@ class RemixCalloutStyler extends MixStyler container: MixOps.resolve(context, $container), text: MixOps.resolve(context, $text), icon: MixOps.resolve(context, $icon), + containerEffects: MixOps.resolve(context, $containerEffects), ); return StyleSpec( @@ -800,7 +826,8 @@ class RemixCalloutStyler extends MixStyler properties ..add(DiagnosticsProperty('container', $container)) ..add(DiagnosticsProperty('text', $text)) - ..add(DiagnosticsProperty('icon', $icon)); + ..add(DiagnosticsProperty('icon', $icon)) + ..add(DiagnosticsProperty('containerEffects', $containerEffects)); } @override @@ -808,6 +835,7 @@ class RemixCalloutStyler extends MixStyler $container, $text, $icon, + $containerEffects, $animation, $modifier, $variants, diff --git a/packages/remix/lib/src/components/callout/callout_spec.dart b/packages/remix/lib/src/components/callout/callout_spec.dart index 47ec79c3..155d1521 100644 --- a/packages/remix/lib/src/components/callout/callout_spec.dart +++ b/packages/remix/lib/src/components/callout/callout_spec.dart @@ -10,12 +10,29 @@ class RemixCalloutSpec with _$RemixCalloutSpec { final StyleSpec text; @override final StyleSpec icon; + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? containerEffects; const RemixCalloutSpec({ StyleSpec? container, StyleSpec? text, StyleSpec? icon, + this.containerEffects, }) : container = container ?? const StyleSpec(spec: FlexBoxSpec()), text = text ?? const StyleSpec(spec: TextSpec()), icon = icon ?? const StyleSpec(spec: IconSpec()); + + @override + RemixCalloutSpec lerp(RemixCalloutSpec? other, double t) { + final generated = super.lerp(other, t); + if (other == null) return generated; + return generated.copyWith( + containerEffects: RemixBoxEffectsSpec.lerpNullable( + containerEffects, + other.containerEffects, + t, + ), + ); + } } diff --git a/packages/remix/lib/src/components/callout/callout_widget.dart b/packages/remix/lib/src/components/callout/callout_widget.dart index 63a0f4fe..d16525b6 100644 --- a/packages/remix/lib/src/components/callout/callout_widget.dart +++ b/packages/remix/lib/src/components/callout/callout_widget.dart @@ -52,8 +52,10 @@ class RemixCallout extends StatelessWidget { builder: (context, spec) { // For raw constructor, use provided child directly if (child != null) { - return RowBox( + return RemixFlexBoxWithEffects( styleSpec: spec.container, + direction: Axis.horizontal, + containerEffects: spec.containerEffects, children: [ // RowBox resolves to a Flex. A loose fit gives custom content a // bounded maximum width without forcing it to fill the callout. @@ -81,7 +83,12 @@ class RemixCallout extends StatelessWidget { ); } - return RowBox(styleSpec: spec.container, children: children); + return RemixFlexBoxWithEffects( + styleSpec: spec.container, + direction: Axis.horizontal, + containerEffects: spec.containerEffects, + children: children, + ); }, ); } diff --git a/packages/remix/lib/src/components/callout/fortal_callout_styles.dart b/packages/remix/lib/src/components/callout/fortal_callout_styles.dart index 5dc6b41f..95db1732 100644 --- a/packages/remix/lib/src/components/callout/fortal_callout_styles.dart +++ b/packages/remix/lib/src/components/callout/fortal_callout_styles.dart @@ -1,101 +1,98 @@ part of 'callout.dart'; -/// Fortal callout size presets. +/// Radix Themes Callout size presets. enum FortalCalloutSize { size1, size2, size3 } -/// Fortal callout color and emphasis variants. -enum FortalCalloutVariant { outline, surface, soft } +/// Radix Themes Callout variants. +enum FortalCalloutVariant { soft, surface, outline } -/// Fortal-themed preset for [RemixCallout]. +/// Fortal recipe for [RemixCallout]. @MixWidget( name: 'FortalCallout', target: RemixCallout.new, factoryParameters: .only({'variant', 'size'}), ) RemixCalloutStyler fortalCalloutStyler({ - FortalCalloutVariant variant = .surface, + FortalCalloutVariant variant = .soft, FortalCalloutSize size = .size2, + bool highContrast = false, }) { + final base = _fortalCalloutBaseStyler(size).foregroundColor( + highContrast ? FortalTokens.accent12() : FortalTokens.accentA11(), + ); return switch (variant) { - .soft => _fortalCalloutSoftStyler(size), - .outline => _fortalCalloutOutlineStyler(size), - .surface => _fortalCalloutSurfaceStyler(size), + .soft => base.color(FortalTokens.accentA3()), + .surface => + base + .color(FortalTokens.accentA2()) + .containerEffects( + RemixBoxEffectsMix( + behindContent: RemixBoxEffectLayerMix( + shadows: [ + RemixBoxShadowMix( + kind: RemixBoxShadowKind.inset, + color: FortalTokens.accentA6(), + spreadRadius: 1, + ), + ], + ), + ), + ), + .outline => base.containerEffects( + RemixBoxEffectsMix( + behindContent: RemixBoxEffectLayerMix( + shadows: [ + RemixBoxShadowMix( + kind: RemixBoxShadowKind.inset, + color: FortalTokens.accentA7(), + spreadRadius: 1, + ), + ], + ), + ), + ), }; } RemixCalloutStyler _fortalCalloutBaseStyler(FortalCalloutSize size) { - return RemixCalloutStyler() - .merge( - RemixCalloutStyler( - container: FlexBoxStyler( - direction: .horizontal, - crossAxisAlignment: .center, - ), - ), - ) - .merge(_fortalCalloutSizeStyler(size)); + final radius = _fortalCalloutRadius(size); + return RemixCalloutStyler( + container: .direction(.horizontal) + .mainAxisSize(.min) + .crossAxisAlignment(.start) + .spacing(_fortalCalloutGap(size)) + .padding(EdgeInsetsGeometryMix.all(_fortalCalloutPadding(size))), + text: .style(_fortalCalloutText(size).mix()), + icon: .size(_fortalCalloutIconSize(size)), + ).borderRadiusAll(radius); } -RemixCalloutStyler _fortalCalloutOutlineStyler([ - FortalCalloutSize size = .size2, -]) { - return _fortalCalloutBaseStyler(size) - .backgroundColor(Colors.transparent) - .borderAll( - color: FortalTokens.accent7(), - width: FortalTokens.borderWidth1(), - ) - .borderRadiusAll(FortalTokens.radius3()) - .foregroundColor(FortalTokens.accent11()); -} +double _fortalCalloutPadding(FortalCalloutSize size) => switch (size) { + .size1 => FortalTokens.space3(), + .size2 => FortalTokens.space4(), + .size3 => FortalTokens.space5(), +}; -RemixCalloutStyler _fortalCalloutSurfaceStyler([ - FortalCalloutSize size = .size2, -]) { - return _fortalCalloutBaseStyler(size) - .backgroundColor(FortalTokens.accentSurface()) - .borderAll( - color: FortalTokens.accent6(), - width: FortalTokens.borderWidth1(), - ) - .borderRadiusAll(FortalTokens.radius3()) - .foregroundColor(FortalTokens.accent11()); -} +double _fortalCalloutGap(FortalCalloutSize size) => switch (size) { + .size1 => FortalTokens.space2(), + .size2 => FortalTokens.space3(), + .size3 => FortalTokens.space4(), +}; -RemixCalloutStyler _fortalCalloutSoftStyler([FortalCalloutSize size = .size2]) { - return _fortalCalloutBaseStyler(size) - .backgroundColor(FortalTokens.accent3()) - .borderRadiusAll(FortalTokens.radius3()) - .foregroundColor(FortalTokens.accent11()); -} +TextStyleToken _fortalCalloutText(FortalCalloutSize size) => switch (size) { + .size1 || .size2 => FortalTokens.text2, + .size3 => FortalTokens.text3, +}; -RemixCalloutStyler _fortalCalloutSizeStyler(FortalCalloutSize size) { - return switch (size) { - .size1 => RemixCalloutStyler( - container: FlexBoxStyler() - .paddingY(8.0) - .paddingX(12.0) - .spacing(FortalTokens.space2()), - text: TextStyler(style: FortalTokens.text1.mix()), - icon: IconStyler(size: 16.0), - ), - .size2 => RemixCalloutStyler( - container: FlexBoxStyler() - .paddingY(12.0) - .paddingX(16.0) - .spacing(FortalTokens.space2()), - text: TextStyler(style: FortalTokens.text2.mix()), - icon: IconStyler(size: 20.0), - ), - .size3 => RemixCalloutStyler( - container: FlexBoxStyler() - .paddingY(16.0) - .paddingX(20.0) - .spacing(FortalTokens.space3()), - text: TextStyler(style: FortalTokens.text3.mix()), - icon: IconStyler(size: 24.0), - ), - }; -} +double _fortalCalloutIconSize(FortalCalloutSize size) => switch (size) { + .size1 || .size2 => FortalTokens.space4(), + .size3 => FortalTokens.spinnerSize3(), +}; + +Radius _fortalCalloutRadius(FortalCalloutSize size) => switch (size) { + .size1 => FortalTokens.radius3(), + .size2 => FortalTokens.radius4(), + .size3 => FortalTokens.radius5(), +}; -/// Fortal-themed preset for [RemixCallout]. +/// Fortal-themed Callout with the Radix size, variant, and override contract. diff --git a/packages/remix/lib/src/components/card/card.dart b/packages/remix/lib/src/components/card/card.dart index f62c1728..fcaf2424 100644 --- a/packages/remix/lib/src/components/card/card.dart +++ b/packages/remix/lib/src/components/card/card.dart @@ -6,7 +6,9 @@ import 'package:mix/mix.dart'; import 'package:mix_annotations/mix_annotations.dart'; import '../../utilities/remix_style.dart'; +import '../../utilities/selected_mixin.dart'; import '../../fortal/fortal.dart'; +import '../../rendering/remix_box_effects.dart'; part 'card_spec.dart'; part 'card_style.dart'; diff --git a/packages/remix/lib/src/components/card/card.g.dart b/packages/remix/lib/src/components/card/card.g.dart index cbf099b9..611b79ed 100644 --- a/packages/remix/lib/src/components/card/card.g.dart +++ b/packages/remix/lib/src/components/card/card.g.dart @@ -8,22 +8,36 @@ part of 'card.dart'; mixin _$RemixCardSpec implements Spec, Diagnosticable { StyleSpec get container; + RemixBoxEffectsSpec? get containerEffects; @override Type get type => RemixCardSpec; @override - RemixCardSpec copyWith({StyleSpec? container}) { - return RemixCardSpec(container: container ?? this.container); + RemixCardSpec copyWith({ + StyleSpec? container, + RemixBoxEffectsSpec? containerEffects, + }) { + return RemixCardSpec( + container: container ?? this.container, + containerEffects: containerEffects ?? this.containerEffects, + ); } @override RemixCardSpec lerp(RemixCardSpec? other, double t) { - return RemixCardSpec(container: container.lerp(other?.container, t)); + return RemixCardSpec( + container: container.lerp(other?.container, t), + containerEffects: MixOps.lerpSnap( + containerEffects, + other?.containerEffects, + t, + ), + ); } @override - List get props => [container]; + List get props => [container, containerEffects]; @override bool operator ==(Object other) { @@ -64,7 +78,9 @@ mixin _$RemixCardSpec implements Spec, Diagnosticable { @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { - properties.add(DiagnosticsProperty('container', container)); + properties + ..add(DiagnosticsProperty('container', container)) + ..add(DiagnosticsProperty('containerEffects', containerEffects)); } } @@ -77,22 +93,22 @@ typedef _$RemixCardSpecMethods = _$RemixCardSpec; // ignore: unused_element // MixWidgetGenerator // ************************************************************************** -/// Fortal-themed preset for [RemixCard]. +/// Fortal recipe for [RemixCard]. class FortalCard extends StatelessWidget { const FortalCard({ super.key, this.variant = .surface, - this.size = .size2, + this.size = .size1, this.child, }); - const FortalCard.surface({super.key, this.size = .size2, this.child}) + const FortalCard.surface({super.key, this.size = .size1, this.child}) : variant = FortalCardVariant.surface; - const FortalCard.classic({super.key, this.size = .size2, this.child}) + const FortalCard.classic({super.key, this.size = .size1, this.child}) : variant = FortalCardVariant.classic; - const FortalCard.ghost({super.key, this.size = .size2, this.child}) + const FortalCard.ghost({super.key, this.size = .size1, this.child}) : variant = FortalCardVariant.ghost; final FortalCardVariant variant; @@ -118,21 +134,26 @@ class FortalCard extends StatelessWidget { class RemixCardStyler extends MixStyler with RemixBoxStylerMixin { final Prop>? $container; + final Prop? $containerEffects; const RemixCardStyler.create({ Prop>? container, + Prop? containerEffects, super.variants, super.modifier, super.animation, - }) : $container = container; + }) : $container = container, + $containerEffects = containerEffects; RemixCardStyler({ BoxStyler? container, + RemixBoxEffectsMix? containerEffects, AnimationConfig? animation, WidgetModifierConfig? modifier, List>? variants, }) : this.create( container: Prop.maybeMix(container), + containerEffects: Prop.maybeMix(containerEffects), variants: variants, modifier: modifier, animation: animation, @@ -140,6 +161,8 @@ class RemixCardStyler extends MixStyler factory RemixCardStyler.container(BoxStyler value) => RemixCardStyler().container(value); + factory RemixCardStyler.containerEffects(RemixBoxEffectsMix value) => + RemixCardStyler().containerEffects(value); factory RemixCardStyler.alignment(AlignmentGeometry value) => RemixCardStyler().alignment(value); factory RemixCardStyler.padding(EdgeInsetsGeometryMix value) => @@ -615,6 +638,11 @@ class RemixCardStyler extends MixStyler return merge(RemixCardStyler(container: value)); } + /// Sets the containerEffects. + RemixCardStyler containerEffects(RemixBoxEffectsMix value) { + return merge(RemixCardStyler(containerEffects: value)); + } + /// Sets the animation configuration. @override RemixCardStyler animate(AnimationConfig value) { @@ -643,6 +671,10 @@ class RemixCardStyler extends MixStyler RemixCardStyler merge(RemixCardStyler? other) { return RemixCardStyler.create( container: MixOps.merge($container, other?.$container), + containerEffects: MixOps.merge( + $containerEffects, + other?.$containerEffects, + ), variants: MixOps.mergeVariants($variants, other?.$variants), modifier: MixOps.mergeModifier($modifier, other?.$modifier), animation: MixOps.mergeAnimation($animation, other?.$animation), @@ -652,7 +684,10 @@ class RemixCardStyler extends MixStyler /// Resolves to [StyleSpec] using [context]. @override StyleSpec resolve(BuildContext context) { - final spec = RemixCardSpec(container: MixOps.resolve(context, $container)); + final spec = RemixCardSpec( + container: MixOps.resolve(context, $container), + containerEffects: MixOps.resolve(context, $containerEffects), + ); return StyleSpec( spec: spec, @@ -664,9 +699,17 @@ class RemixCardStyler extends MixStyler @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('container', $container)); + properties + ..add(DiagnosticsProperty('container', $container)) + ..add(DiagnosticsProperty('containerEffects', $containerEffects)); } @override - List get props => [$container, $animation, $modifier, $variants]; + List get props => [ + $container, + $containerEffects, + $animation, + $modifier, + $variants, + ]; } diff --git a/packages/remix/lib/src/components/card/card_spec.dart b/packages/remix/lib/src/components/card/card_spec.dart index f9567e84..ad64f54d 100644 --- a/packages/remix/lib/src/components/card/card_spec.dart +++ b/packages/remix/lib/src/components/card/card_spec.dart @@ -7,6 +7,24 @@ class RemixCardSpec with _$RemixCardSpec { @MixableField(forwardStyler: true) final StyleSpec container; - const RemixCardSpec({StyleSpec? container}) + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? containerEffects; + + const RemixCardSpec({StyleSpec? container, this.containerEffects}) : container = container ?? const StyleSpec(spec: BoxSpec()); + + @override + RemixCardSpec lerp(RemixCardSpec? other, double t) { + final generated = super.lerp(other, t); + if (other == null) return generated; + return RemixCardSpec( + container: generated.container, + containerEffects: RemixBoxEffectsSpec.lerpNullable( + containerEffects, + other.containerEffects, + t, + ), + ); + } } diff --git a/packages/remix/lib/src/components/card/card_widget.dart b/packages/remix/lib/src/components/card/card_widget.dart index d54d6d7c..fe925c26 100644 --- a/packages/remix/lib/src/components/card/card_widget.dart +++ b/packages/remix/lib/src/components/card/card_widget.dart @@ -45,7 +45,11 @@ class RemixCard extends StatelessWidget { style: style, styleSpec: styleSpec, builder: (context, spec) { - return Box(styleSpec: spec.container, child: child); + return RemixBoxWithEffects( + styleSpec: spec.container, + containerEffects: spec.containerEffects, + child: child, + ); }, ); } diff --git a/packages/remix/lib/src/components/card/fortal_card_styles.dart b/packages/remix/lib/src/components/card/fortal_card_styles.dart index 9ebc3fbf..a1c6af5e 100644 --- a/packages/remix/lib/src/components/card/fortal_card_styles.dart +++ b/packages/remix/lib/src/components/card/fortal_card_styles.dart @@ -1,12 +1,12 @@ part of 'card.dart'; -/// Fortal card size presets. -enum FortalCardSize { size1, size2, size3 } +/// Radix Themes Card size presets. +enum FortalCardSize { size1, size2, size3, size4, size5 } -/// Fortal card surface variants. +/// Radix Themes Card variants. enum FortalCardVariant { surface, classic, ghost } -/// Fortal-themed preset for [RemixCard]. +/// Fortal recipe for [RemixCard]. @MixWidget( name: 'FortalCard', target: RemixCard.new, @@ -14,56 +14,211 @@ enum FortalCardVariant { surface, classic, ghost } ) RemixCardStyler fortalCardStyler({ FortalCardVariant variant = .surface, - FortalCardSize size = .size2, + FortalCardSize size = .size1, }) { + final metrics = _fortalCardMetrics(size); + final base = RemixCardStyler() + .paddingAll(metrics.padding) + .borderRadiusAll(metrics.radius) + .clipBehavior(Clip.antiAlias) + .onFocused( + .containerEffects( + RemixBoxEffectsMix( + outline: BorderSideMix( + color: FortalTokens.focus8(), + width: 2, + strokeAlign: BorderSide.strokeAlignInside, + ), + outlineOffset: -1, + ), + ), + ); + return switch (variant) { - .surface => _fortalCardSurfaceStyler(size), - .classic => _fortalCardClassicStyler(size), - .ghost => _fortalCardGhostStyler(size), + .surface => _fortalCardSurface(base), + .classic => _fortalCardClassic(base), + .ghost => _fortalCardGhost(base, metrics.ghostMargin), }; } -RemixCardStyler _fortalCardBaseStyler(FortalCardSize size) { - return RemixCardStyler().merge(_fortalCardSizeStyler(size)); -} +({double padding, double ghostMargin, Radius radius}) _fortalCardMetrics( + FortalCardSize size, +) => switch (size) { + .size1 => ( + padding: FortalTokens.space3(), + ghostMargin: FortalTokens.cardGhostMargin1(), + radius: FortalTokens.radius4(), + ), + .size2 => ( + padding: FortalTokens.space4(), + ghostMargin: FortalTokens.cardGhostMargin2(), + radius: FortalTokens.radius4(), + ), + .size3 => ( + padding: FortalTokens.space5(), + ghostMargin: FortalTokens.cardGhostMargin3(), + radius: FortalTokens.radius5(), + ), + .size4 => ( + padding: FortalTokens.space6(), + ghostMargin: FortalTokens.cardGhostMargin4(), + radius: FortalTokens.radius5(), + ), + .size5 => ( + padding: FortalTokens.space8(), + ghostMargin: FortalTokens.cardGhostMargin5(), + radius: FortalTokens.radius6(), + ), +}; + +RemixCardStyler _fortalCardSurface(RemixCardStyler base) { + base = base.containerEffects( + RemixBoxEffectsMix(backdropBlur: FortalTokens.panelBlur()), + ); + final open = RemixCardStyler() + .containerEffects(RemixBoxEffectsMix(behindContent: _fortalCardPanel())) + .containerEffects( + RemixBoxEffectsMix( + overContent: _fortalCardSurfaceStroke(FortalTokens.grayStroke7()), + ), + ); + final activeFocus = RemixCardStyler() + .containerEffects( + RemixBoxEffectsMix(behindContent: _fortalCardActiveFocus()), + ) + .onSelected(open); + final pressed = RemixCardStyler() + .containerEffects( + RemixBoxEffectsMix( + overContent: _fortalCardSurfaceStroke(FortalTokens.grayStroke6()), + ), + ) + .onFocused(activeFocus) + .onSelected(open); -RemixCardStyler _fortalCardSurfaceStyler([FortalCardSize size = .size2]) { - return _fortalCardBaseStyler(size) - .borderAll( - color: FortalTokens.gray6(), - width: FortalTokens.borderWidth1(), + return base + .containerEffects(RemixBoxEffectsMix(behindContent: _fortalCardPanel())) + .containerEffects( + RemixBoxEffectsMix( + overContent: _fortalCardSurfaceStroke(FortalTokens.grayStroke5()), + ), ) - .borderRadiusAll(FortalTokens.radius5()); + .onHovered(open) + .onPressed(pressed) + .onSelected(open.onPressed(open)); } -RemixCardStyler _fortalCardClassicStyler([FortalCardSize size = .size2]) { - return _fortalCardBaseStyler(size) - .backgroundColor(FortalTokens.graySurface()) - .borderAll( - color: FortalTokens.gray6(), - width: FortalTokens.borderWidth1(), +RemixCardStyler _fortalCardClassic(RemixCardStyler base) { + base = base.containerEffects( + RemixBoxEffectsMix(backdropBlur: FortalTokens.panelBlur()), + ); + final open = RemixCardStyler() + .animate(AnimationConfig.ease(const Duration(milliseconds: 40))) + .containerEffects( + RemixBoxEffectsMix( + behindContent: _fortalCardPanel( + shadowToken: FortalTokens.cardClassicHoverOuterShadows, + ), + ), ) - .shadow( - BoxShadowMix() - .color(FortalTokens.grayA3()) - .offset(x: 0, y: 2) - .blurRadius(3) - .spreadRadius(0), + .containerEffects( + RemixBoxEffectsMix( + overContent: RemixBoxEffectLayerMix( + shadowToken: FortalTokens.cardClassicHoverInnerShadows, + ), + ), + ); + final pressed = RemixCardStyler() + .animate(AnimationConfig.ease(const Duration(milliseconds: 40))) + .containerEffects( + RemixBoxEffectsMix( + behindContent: RemixBoxEffectLayerMix( + shadowToken: FortalTokens.cardClassicActiveOuterShadows, + ), + ), ) - .borderRadiusAll(FortalTokens.radius5()) - .merge(RemixCardStyler(container: BoxStyler())); -} + .containerEffects( + RemixBoxEffectsMix( + overContent: RemixBoxEffectLayerMix( + shadowToken: FortalTokens.cardClassicActiveInnerShadows, + ), + ), + ) + .onFocused( + .containerEffects( + RemixBoxEffectsMix(behindContent: _fortalCardActiveFocus()), + ).onSelected(open), + ) + .onSelected(open); -RemixCardStyler _fortalCardGhostStyler([FortalCardSize size = .size2]) { - return _fortalCardBaseStyler(size).backgroundColor(Colors.transparent); + return base + .animate(AnimationConfig.ease(const Duration(milliseconds: 120))) + .containerEffects( + RemixBoxEffectsMix( + behindContent: _fortalCardPanel( + shadowToken: FortalTokens.cardClassicOuterShadows, + ), + ), + ) + .containerEffects( + RemixBoxEffectsMix( + overContent: RemixBoxEffectLayerMix( + shadowToken: FortalTokens.cardClassicInnerShadows, + ), + ), + ) + .onHovered(open) + .onPressed(pressed) + .onSelected(open.onPressed(open)); } -RemixCardStyler _fortalCardSizeStyler(FortalCardSize size) { - return switch (size) { - .size1 => RemixCardStyler().paddingAll(FortalTokens.space4()), - .size2 => RemixCardStyler().paddingAll(FortalTokens.space5()), - .size3 => RemixCardStyler().paddingAll(FortalTokens.space6()), - }; +RemixCardStyler _fortalCardGhost(RemixCardStyler base, double ghostMargin) { + final focused = RemixCardStyler().color(FortalTokens.accentA2()); + final open = RemixCardStyler() + .color(FortalTokens.grayA3()) + .onFocused(focused); + final pressed = RemixCardStyler() + .color(FortalTokens.grayA4()) + .onFocused(focused) + .onSelected(open); + + return base + .marginAll(ghostMargin) + .color(Colors.transparent) + .onHovered(open) + .onPressed(pressed) + .onSelected(open.onPressed(open)); } -/// Fortal-themed preset for [RemixCard]. +RemixBoxEffectLayerMix _fortalCardPanel({ + RemixBoxShadowListToken? shadowToken, +}) => RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix( + colors: [FortalTokens.colorPanel(), FortalTokens.colorPanel()], + ), + ], + gradientInsets: const [1], + shadowToken: shadowToken, +); + +RemixBoxEffectLayerMix _fortalCardActiveFocus() => RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix( + colors: [FortalTokens.accentA2(), FortalTokens.accentA2()], + ), + RemixLinearGradientMix( + colors: [FortalTokens.colorPanel(), FortalTokens.colorPanel()], + ), + ], + gradientInsets: const [1, 1], +); + +RemixBoxEffectLayerMix _fortalCardSurfaceStroke(Color color) => + RemixBoxEffectLayerMix( + shadows: [ + RemixBoxShadowMix(color: color, spreadRadius: 1, shapeInset: 1), + ], + ); + +/// Fortal-themed Card with the Radix size and variant contract. diff --git a/packages/remix/lib/src/components/checkbox/checkbox.dart b/packages/remix/lib/src/components/checkbox/checkbox.dart index a59a9582..0b458799 100644 --- a/packages/remix/lib/src/components/checkbox/checkbox.dart +++ b/packages/remix/lib/src/components/checkbox/checkbox.dart @@ -8,6 +8,7 @@ import 'package:mix_annotations/mix_annotations.dart'; import 'package:naked_ui/naked_ui.dart'; import '../../fortal/fortal.dart'; +import '../../rendering/remix_box_effects.dart'; import '../../utilities/remix_style.dart'; import '../../utilities/selected_mixin.dart'; diff --git a/packages/remix/lib/src/components/checkbox/checkbox.g.dart b/packages/remix/lib/src/components/checkbox/checkbox.g.dart index 74376956..b058b3d2 100644 --- a/packages/remix/lib/src/components/checkbox/checkbox.g.dart +++ b/packages/remix/lib/src/components/checkbox/checkbox.g.dart @@ -9,6 +9,7 @@ part of 'checkbox.dart'; mixin _$RemixCheckboxSpec implements Spec, Diagnosticable { StyleSpec get container; StyleSpec get indicator; + RemixBoxEffectsSpec? get containerEffects; @override Type get type => RemixCheckboxSpec; @@ -17,10 +18,12 @@ mixin _$RemixCheckboxSpec implements Spec, Diagnosticable { RemixCheckboxSpec copyWith({ StyleSpec? container, StyleSpec? indicator, + RemixBoxEffectsSpec? containerEffects, }) { return RemixCheckboxSpec( container: container ?? this.container, indicator: indicator ?? this.indicator, + containerEffects: containerEffects ?? this.containerEffects, ); } @@ -29,11 +32,16 @@ mixin _$RemixCheckboxSpec implements Spec, Diagnosticable { return RemixCheckboxSpec( container: container.lerp(other?.container, t), indicator: indicator.lerp(other?.indicator, t), + containerEffects: MixOps.lerpSnap( + containerEffects, + other?.containerEffects, + t, + ), ); } @override - List get props => [container, indicator]; + List get props => [container, indicator, containerEffects]; @override bool operator ==(Object other) { @@ -76,7 +84,8 @@ mixin _$RemixCheckboxSpec implements Spec, Diagnosticable { void debugFillProperties(DiagnosticPropertiesBuilder properties) { properties ..add(DiagnosticsProperty('container', container)) - ..add(DiagnosticsProperty('indicator', indicator)); + ..add(DiagnosticsProperty('indicator', indicator)) + ..add(DiagnosticsProperty('containerEffects', containerEffects)); } } @@ -89,7 +98,7 @@ typedef _$RemixCheckboxSpecMethods = _$RemixCheckboxSpec; // ignore: unused_elem // MixWidgetGenerator // ************************************************************************** -/// Fortal-themed preset for [RemixCheckbox]. +/// Fortal recipe for [RemixCheckbox]. class FortalCheckbox extends StatelessWidget { const FortalCheckbox({ super.key, @@ -109,7 +118,23 @@ class FortalCheckbox extends StatelessWidget { this.mouseCursor = SystemMouseCursors.click, }); - /// Surface treatment with neutral border. + const FortalCheckbox.classic({ + super.key, + this.size = .size2, + required this.selected, + this.onChanged, + this.enabled = true, + this.tristate = false, + this.checkedIcon = Icons.check_rounded, + this.uncheckedIcon, + this.indeterminateIcon = Icons.horizontal_rule, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.semanticLabel, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalCheckboxVariant.classic; + const FortalCheckbox.surface({ super.key, this.size = .size2, @@ -127,7 +152,6 @@ class FortalCheckbox extends StatelessWidget { this.mouseCursor = SystemMouseCursors.click, }) : variant = FortalCheckboxVariant.surface; - /// Soft accent treatment. const FortalCheckbox.soft({ super.key, this.size = .size2, @@ -203,25 +227,30 @@ class RemixCheckboxStyler with RemixBoxStylerMixin { final Prop>? $container; final Prop>? $indicator; + final Prop? $containerEffects; const RemixCheckboxStyler.create({ Prop>? container, Prop>? indicator, + Prop? containerEffects, super.variants, super.modifier, super.animation, }) : $container = container, - $indicator = indicator; + $indicator = indicator, + $containerEffects = containerEffects; RemixCheckboxStyler({ BoxStyler? container, IconStyler? indicator, + RemixBoxEffectsMix? containerEffects, AnimationConfig? animation, WidgetModifierConfig? modifier, List>? variants, }) : this.create( container: Prop.maybeMix(container), indicator: Prop.maybeMix(indicator), + containerEffects: Prop.maybeMix(containerEffects), variants: variants, modifier: modifier, animation: animation, @@ -231,6 +260,8 @@ class RemixCheckboxStyler RemixCheckboxStyler().container(value); factory RemixCheckboxStyler.indicator(IconStyler value) => RemixCheckboxStyler().indicator(value); + factory RemixCheckboxStyler.containerEffects(RemixBoxEffectsMix value) => + RemixCheckboxStyler().containerEffects(value); factory RemixCheckboxStyler.alignment(AlignmentGeometry value) => RemixCheckboxStyler().alignment(value); factory RemixCheckboxStyler.padding(EdgeInsetsGeometryMix value) => @@ -716,6 +747,11 @@ class RemixCheckboxStyler return merge(RemixCheckboxStyler(indicator: value)); } + /// Sets the containerEffects. + RemixCheckboxStyler containerEffects(RemixBoxEffectsMix value) { + return merge(RemixCheckboxStyler(containerEffects: value)); + } + /// Sets the animation configuration. @override RemixCheckboxStyler animate(AnimationConfig value) { @@ -745,6 +781,10 @@ class RemixCheckboxStyler return RemixCheckboxStyler.create( container: MixOps.merge($container, other?.$container), indicator: MixOps.merge($indicator, other?.$indicator), + containerEffects: MixOps.merge( + $containerEffects, + other?.$containerEffects, + ), variants: MixOps.mergeVariants($variants, other?.$variants), modifier: MixOps.mergeModifier($modifier, other?.$modifier), animation: MixOps.mergeAnimation($animation, other?.$animation), @@ -757,6 +797,7 @@ class RemixCheckboxStyler final spec = RemixCheckboxSpec( container: MixOps.resolve(context, $container), indicator: MixOps.resolve(context, $indicator), + containerEffects: MixOps.resolve(context, $containerEffects), ); return StyleSpec( @@ -771,13 +812,15 @@ class RemixCheckboxStyler super.debugFillProperties(properties); properties ..add(DiagnosticsProperty('container', $container)) - ..add(DiagnosticsProperty('indicator', $indicator)); + ..add(DiagnosticsProperty('indicator', $indicator)) + ..add(DiagnosticsProperty('containerEffects', $containerEffects)); } @override List get props => [ $container, $indicator, + $containerEffects, $animation, $modifier, $variants, diff --git a/packages/remix/lib/src/components/checkbox/checkbox_spec.dart b/packages/remix/lib/src/components/checkbox/checkbox_spec.dart index 2c6691d9..62d58d83 100644 --- a/packages/remix/lib/src/components/checkbox/checkbox_spec.dart +++ b/packages/remix/lib/src/components/checkbox/checkbox_spec.dart @@ -72,6 +72,10 @@ class RemixCheckboxSpec with _$RemixCheckboxSpec { @override final StyleSpec indicator; + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? containerEffects; + /// Creates a RemixCheckboxSpec with optional styling specifications. /// /// If any [StyleSpec] is not provided, a default specification @@ -92,6 +96,20 @@ class RemixCheckboxSpec with _$RemixCheckboxSpec { const RemixCheckboxSpec({ StyleSpec? container, StyleSpec? indicator, + this.containerEffects, }) : container = container ?? const StyleSpec(spec: BoxSpec()), indicator = indicator ?? const StyleSpec(spec: IconSpec()); + + @override + RemixCheckboxSpec lerp(RemixCheckboxSpec? other, double t) { + final generated = super.lerp(other, t); + if (other == null) return generated; + return generated.copyWith( + containerEffects: RemixBoxEffectsSpec.lerpNullable( + containerEffects, + other.containerEffects, + t, + ), + ); + } } diff --git a/packages/remix/lib/src/components/checkbox/checkbox_widget.dart b/packages/remix/lib/src/components/checkbox/checkbox_widget.dart index 9aaa72a8..b415fe76 100644 --- a/packages/remix/lib/src/components/checkbox/checkbox_widget.dart +++ b/packages/remix/lib/src/components/checkbox/checkbox_widget.dart @@ -119,8 +119,9 @@ class RemixCheckbox extends StatelessWidget { ? checkedIcon : uncheckedIcon; - return Box( + return RemixBoxWithEffects( styleSpec: spec.container, + containerEffects: spec.containerEffects, child: iconData != null ? StyledIcon(icon: iconData, styleSpec: spec.indicator) : null, diff --git a/packages/remix/lib/src/components/checkbox/fortal_checkbox_styles.dart b/packages/remix/lib/src/components/checkbox/fortal_checkbox_styles.dart index 7542fb53..0ca39a0d 100644 --- a/packages/remix/lib/src/components/checkbox/fortal_checkbox_styles.dart +++ b/packages/remix/lib/src/components/checkbox/fortal_checkbox_styles.dart @@ -1,27 +1,12 @@ part of 'checkbox.dart'; -/// Fortal checkbox size presets. -enum FortalCheckboxSize { - /// Compact checkbox. - size1, +/// Radix Themes Checkbox size presets. +enum FortalCheckboxSize { size1, size2, size3 } - /// Default checkbox. - size2, +/// Radix Themes Checkbox variants. +enum FortalCheckboxVariant { classic, surface, soft } - /// Large checkbox. - size3, -} - -/// Fortal checkbox color variants. -enum FortalCheckboxVariant { - /// Surface treatment with neutral border. - surface, - - /// Soft accent treatment. - soft, -} - -/// Fortal-themed preset for [RemixCheckbox]. +/// Fortal recipe for [RemixCheckbox]. @MixWidget( name: 'FortalCheckbox', target: RemixCheckbox.new, @@ -30,91 +15,222 @@ enum FortalCheckboxVariant { RemixCheckboxStyler fortalCheckboxStyler({ FortalCheckboxVariant variant = .surface, FortalCheckboxSize size = .size2, + bool highContrast = false, }) { + final metrics = _fortalCheckboxMetrics(size); + final base = + RemixCheckboxStyler( + container: .size( + metrics.size, + metrics.size, + ).alignment(.center).borderRadiusAll(metrics.radius), + indicator: .size(metrics.indicatorSize), + containerEffects: RemixBoxEffectsMix( + behindContent: _fortalCheckboxLayer(), + overContent: _fortalCheckboxLayer(), + ), + ).onFocused( + .containerEffects( + RemixBoxEffectsMix( + outline: BorderSideMix( + color: FortalTokens.focus8(), + width: 2, + strokeAlign: BorderSide.strokeAlignInside, + ), + outlineOffset: 2, + ), + ), + ); + return switch (variant) { - .surface => _fortalCheckboxSurfaceStyler(size), - .soft => _fortalCheckboxSoftStyler(size), + .classic => _fortalCheckboxClassic(base, highContrast), + .surface => _fortalCheckboxSurface(base, highContrast), + .soft => _fortalCheckboxSoft(base, highContrast), }; } -RemixCheckboxStyler _fortalCheckboxBaseStyler(FortalCheckboxSize size) { - return RemixCheckboxStyler() - .onFocused( - RemixCheckboxStyler().borderAll( - color: FortalTokens.focusA8(), - width: FortalTokens.focusRingWidth(), +({double size, double indicatorSize, Radius radius}) _fortalCheckboxMetrics( + FortalCheckboxSize size, +) => switch (size) { + .size1 => ( + size: FortalTokens.checkboxSize1(), + indicatorSize: FortalTokens.checkboxIndicatorSize1(), + radius: FortalTokens.checkboxRadius1(), + ), + .size2 => ( + size: FortalTokens.space4(), + indicatorSize: FortalTokens.checkboxIndicatorSize2(), + radius: FortalTokens.radius1(), + ), + .size3 => ( + size: FortalTokens.checkboxSize3(), + indicatorSize: FortalTokens.checkboxIndicatorSize3(), + radius: FortalTokens.checkboxRadius3(), + ), +}; + +RemixCheckboxStyler _fortalCheckboxSurface( + RemixCheckboxStyler base, + bool highContrast, +) { + final selected = RemixCheckboxStyler() + .color( + highContrast ? FortalTokens.accent12() : FortalTokens.accentIndicator(), + ) + .containerEffects( + RemixBoxEffectsMix(behindContent: _fortalCheckboxLayer()), + ) + .containerEffects( + RemixBoxEffectsMix( + overContent: _fortalCheckboxLayer(shadows: const []), ), ) - .merge(_fortalCheckboxSizeStyler(size)); -} + .indicatorColor( + highContrast ? FortalTokens.accent1() : FortalTokens.accentContrast(), + ); -RemixCheckboxStyler _fortalCheckboxSurfaceStyler([ - FortalCheckboxSize size = .size2, -]) { - return _fortalCheckboxBaseStyler(size) - .borderAll( - color: FortalTokens.gray6(), - width: FortalTokens.borderWidth1(), + return base + .color(FortalTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix(behindContent: _fortalCheckboxLayer()), ) - .borderRadiusAll(FortalTokens.radius2()) - .indicatorColor(FortalTokens.accent9()) - .onSelected( - RemixCheckboxStyler() - .fillColor(FortalTokens.accent9()) - .borderAll( - color: FortalTokens.accent9(), - width: FortalTokens.borderWidth1(), - ) - .indicatorColor(FortalTokens.accentContrast()), + .containerEffects( + RemixBoxEffectsMix( + overContent: _fortalCheckboxInsetRing(FortalTokens.grayA7()), + ), ) + .onSelected(selected) + .onIndeterminate(selected) .onDisabled( - RemixCheckboxStyler() - .fillColor(FortalTokens.grayA3()) - .borderAll( - color: FortalTokens.gray7(), - width: FortalTokens.borderWidth1(), + .color(FortalTokens.grayA3()) + .containerEffects( + RemixBoxEffectsMix(behindContent: _fortalCheckboxLayer()), ) - .indicatorColor(FortalTokens.gray8()), + .containerEffects( + RemixBoxEffectsMix( + overContent: _fortalCheckboxInsetRing(FortalTokens.grayA6()), + ), + ) + .indicatorColor(FortalTokens.grayA8()), ); } -RemixCheckboxStyler _fortalCheckboxSoftStyler([ - FortalCheckboxSize size = .size2, -]) { - return _fortalCheckboxBaseStyler(size) - .fillColor(FortalTokens.accentA4()) - .borderRadiusAll(FortalTokens.radius2()) - .onSelected( - RemixCheckboxStyler().indicatorColor(FortalTokens.accentA11()), +RemixCheckboxStyler _fortalCheckboxClassic( + RemixCheckboxStyler base, + bool highContrast, +) { + final selected = RemixCheckboxStyler() + .color( + highContrast ? FortalTokens.accent12() : FortalTokens.accentIndicator(), ) + .containerEffects( + RemixBoxEffectsMix( + behindContent: _fortalCheckboxLayer( + gradients: [ + RemixLinearGradientMix( + colors: [ + FortalTokens.whiteA3(), + Colors.transparent, + FortalTokens.blackA1(), + ], + ), + ], + shadows: [ + RemixBoxShadowMix( + kind: .inset, + color: FortalTokens.whiteA4(), + offset: const Offset(0, 0.5), + blurRadius: 0.5, + ), + RemixBoxShadowMix( + kind: .inset, + color: FortalTokens.blackA4(), + offset: const Offset(0, -0.5), + blurRadius: 0.5, + ), + ], + ), + ), + ) + .containerEffects( + RemixBoxEffectsMix( + overContent: _fortalCheckboxLayer(shadows: const []), + ), + ) + .indicatorColor( + highContrast ? FortalTokens.accent1() : FortalTokens.accentContrast(), + ); + + return base + .color(FortalTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix( + behindContent: _fortalCheckboxLayer( + shadowToken: FortalTokens.shadow1Layers, + ), + ), + ) + .containerEffects( + RemixBoxEffectsMix( + overContent: _fortalCheckboxInsetRing(FortalTokens.grayA3()), + ), + ) + .onSelected(selected) + .onIndeterminate(selected) .onDisabled( - RemixCheckboxStyler() - .fillColor(FortalTokens.grayA3()) - .indicatorColor(FortalTokens.gray8()), + .color(FortalTokens.grayA3()) + .containerEffects( + RemixBoxEffectsMix( + behindContent: _fortalCheckboxLayer( + gradients: const [], + shadowToken: FortalTokens.shadow1Layers, + ), + ), + ) + .containerEffects( + RemixBoxEffectsMix( + overContent: _fortalCheckboxLayer(shadows: const []), + ), + ) + .indicatorColor(FortalTokens.grayA8()), ); } -RemixCheckboxStyler _fortalCheckboxSizeStyler(FortalCheckboxSize size) { - return switch (size) { - .size1 => RemixCheckboxStyler( - container: BoxStyler() - .width(FortalTokens.space4()) - .height(FortalTokens.space4()), - indicator: IconStyler().size(FortalTokens.space3()), - ), - .size2 => RemixCheckboxStyler( - container: BoxStyler() - .width(FortalTokens.space5()) - .height(FortalTokens.space5()), - indicator: IconStyler().size(FortalTokens.space4()), - ), - .size3 => RemixCheckboxStyler( - container: BoxStyler() - .width(FortalTokens.space6()) - .height(FortalTokens.space6()), - indicator: IconStyler().size(FortalTokens.space5()), - ), - }; +RemixCheckboxStyler _fortalCheckboxSoft( + RemixCheckboxStyler base, + bool highContrast, +) { + final selected = RemixCheckboxStyler().indicatorColor( + highContrast ? FortalTokens.accent12() : FortalTokens.accentA11(), + ); + + return base + .color(FortalTokens.accentA5()) + .containerEffects( + RemixBoxEffectsMix(behindContent: _fortalCheckboxLayer()), + ) + .onSelected(selected) + .onIndeterminate(selected) + .onDisabled( + .color(FortalTokens.grayA3()) + .containerEffects( + RemixBoxEffectsMix(behindContent: _fortalCheckboxLayer()), + ) + .indicatorColor(FortalTokens.grayA8()), + ); } -/// Fortal-themed preset for [RemixCheckbox]. +RemixBoxEffectLayerMix _fortalCheckboxInsetRing(Color color) => + _fortalCheckboxLayer( + shadows: [RemixBoxShadowMix(kind: .inset, color: color, spreadRadius: 1)], + ); + +RemixBoxEffectLayerMix _fortalCheckboxLayer({ + List? gradients, + List? shadows, + RemixBoxShadowListToken? shadowToken, +}) => RemixBoxEffectLayerMix( + gradients: gradients, + shadows: shadows, + shadowToken: shadowToken, +); diff --git a/packages/remix/lib/src/components/dialog/dialog.dart b/packages/remix/lib/src/components/dialog/dialog.dart index bf017c58..447908a5 100644 --- a/packages/remix/lib/src/components/dialog/dialog.dart +++ b/packages/remix/lib/src/components/dialog/dialog.dart @@ -8,6 +8,7 @@ import 'package:mix_annotations/mix_annotations.dart'; import 'package:naked_ui/naked_ui.dart'; import '../../fortal/fortal.dart'; +import '../../rendering/remix_box_effects.dart'; import '../../utilities/remix_style.dart'; part 'dialog_spec.dart'; diff --git a/packages/remix/lib/src/components/dialog/dialog.g.dart b/packages/remix/lib/src/components/dialog/dialog.g.dart index e39ddabd..18a34c5c 100644 --- a/packages/remix/lib/src/components/dialog/dialog.g.dart +++ b/packages/remix/lib/src/components/dialog/dialog.g.dart @@ -8,6 +8,7 @@ part of 'dialog.dart'; mixin _$RemixDialogSpec implements Spec, Diagnosticable { StyleSpec get container; + RemixBoxEffectsSpec? get containerEffects; StyleSpec get title; StyleSpec get description; StyleSpec get actions; @@ -18,12 +19,14 @@ mixin _$RemixDialogSpec implements Spec, Diagnosticable { @override RemixDialogSpec copyWith({ StyleSpec? container, + RemixBoxEffectsSpec? containerEffects, StyleSpec? title, StyleSpec? description, StyleSpec? actions, }) { return RemixDialogSpec( container: container ?? this.container, + containerEffects: containerEffects ?? this.containerEffects, title: title ?? this.title, description: description ?? this.description, actions: actions ?? this.actions, @@ -34,6 +37,11 @@ mixin _$RemixDialogSpec implements Spec, Diagnosticable { RemixDialogSpec lerp(RemixDialogSpec? other, double t) { return RemixDialogSpec( container: container.lerp(other?.container, t), + containerEffects: MixOps.lerpSnap( + containerEffects, + other?.containerEffects, + t, + ), title: title.lerp(other?.title, t), description: description.lerp(other?.description, t), actions: actions.lerp(other?.actions, t), @@ -41,7 +49,13 @@ mixin _$RemixDialogSpec implements Spec, Diagnosticable { } @override - List get props => [container, title, description, actions]; + List get props => [ + container, + containerEffects, + title, + description, + actions, + ]; @override bool operator ==(Object other) { @@ -84,6 +98,7 @@ mixin _$RemixDialogSpec implements Spec, Diagnosticable { void debugFillProperties(DiagnosticPropertiesBuilder properties) { properties ..add(DiagnosticsProperty('container', container)) + ..add(DiagnosticsProperty('containerEffects', containerEffects)) ..add(DiagnosticsProperty('title', title)) ..add(DiagnosticsProperty('description', description)) ..add(DiagnosticsProperty('actions', actions)); @@ -103,6 +118,7 @@ typedef _$RemixDialogSpecMethods = _$RemixDialogSpec; // ignore: unused_element class FortalDialog extends StatelessWidget { const FortalDialog({ super.key, + this.size = FortalDialogSize.size3, this.child, this.title, this.description, @@ -111,6 +127,8 @@ class FortalDialog extends StatelessWidget { this.semanticLabel, }); + final FortalDialogSize size; + final Widget? child; final String? title; @@ -127,7 +145,7 @@ class FortalDialog extends StatelessWidget { Widget build(BuildContext context) { return RemixDialog( key: this.key, - style: fortalDialogStyler(), + style: fortalDialogStyler(size: this.size), child: this.child, title: this.title, description: this.description, @@ -145,12 +163,14 @@ class FortalDialog extends StatelessWidget { class RemixDialogStyler extends MixStyler with RemixBoxStylerMixin { final Prop>? $container; + final Prop? $containerEffects; final Prop>? $title; final Prop>? $description; final Prop>? $actions; const RemixDialogStyler.create({ Prop>? container, + Prop? containerEffects, Prop>? title, Prop>? description, Prop>? actions, @@ -158,12 +178,14 @@ class RemixDialogStyler extends MixStyler super.modifier, super.animation, }) : $container = container, + $containerEffects = containerEffects, $title = title, $description = description, $actions = actions; RemixDialogStyler({ BoxStyler? container, + RemixBoxEffectsMix? containerEffects, TextStyler? title, TextStyler? description, FlexBoxStyler? actions, @@ -172,6 +194,7 @@ class RemixDialogStyler extends MixStyler List>? variants, }) : this.create( container: Prop.maybeMix(container), + containerEffects: Prop.maybeMix(containerEffects), title: Prop.maybeMix(title), description: Prop.maybeMix(description), actions: Prop.maybeMix(actions), @@ -182,6 +205,8 @@ class RemixDialogStyler extends MixStyler factory RemixDialogStyler.container(BoxStyler value) => RemixDialogStyler().container(value); + factory RemixDialogStyler.containerEffects(RemixBoxEffectsMix value) => + RemixDialogStyler().containerEffects(value); factory RemixDialogStyler.title(TextStyler value) => RemixDialogStyler().title(value); factory RemixDialogStyler.description(TextStyler value) => @@ -665,6 +690,11 @@ class RemixDialogStyler extends MixStyler return merge(RemixDialogStyler(container: value)); } + /// Sets the containerEffects. + RemixDialogStyler containerEffects(RemixBoxEffectsMix value) { + return merge(RemixDialogStyler(containerEffects: value)); + } + /// Sets the title. RemixDialogStyler title(TextStyler value) { return merge(RemixDialogStyler(title: value)); @@ -708,6 +738,10 @@ class RemixDialogStyler extends MixStyler RemixDialogStyler merge(RemixDialogStyler? other) { return RemixDialogStyler.create( container: MixOps.merge($container, other?.$container), + containerEffects: MixOps.merge( + $containerEffects, + other?.$containerEffects, + ), title: MixOps.merge($title, other?.$title), description: MixOps.merge($description, other?.$description), actions: MixOps.merge($actions, other?.$actions), @@ -722,6 +756,7 @@ class RemixDialogStyler extends MixStyler StyleSpec resolve(BuildContext context) { final spec = RemixDialogSpec( container: MixOps.resolve(context, $container), + containerEffects: MixOps.resolve(context, $containerEffects), title: MixOps.resolve(context, $title), description: MixOps.resolve(context, $description), actions: MixOps.resolve(context, $actions), @@ -739,6 +774,7 @@ class RemixDialogStyler extends MixStyler super.debugFillProperties(properties); properties ..add(DiagnosticsProperty('container', $container)) + ..add(DiagnosticsProperty('containerEffects', $containerEffects)) ..add(DiagnosticsProperty('title', $title)) ..add(DiagnosticsProperty('description', $description)) ..add(DiagnosticsProperty('actions', $actions)); @@ -747,6 +783,7 @@ class RemixDialogStyler extends MixStyler @override List get props => [ $container, + $containerEffects, $title, $description, $actions, diff --git a/packages/remix/lib/src/components/dialog/dialog_spec.dart b/packages/remix/lib/src/components/dialog/dialog_spec.dart index cf098be0..8d842541 100644 --- a/packages/remix/lib/src/components/dialog/dialog_spec.dart +++ b/packages/remix/lib/src/components/dialog/dialog_spec.dart @@ -7,6 +7,9 @@ class RemixDialogSpec with _$RemixDialogSpec { @MixableField(forwardStyler: true) final StyleSpec container; @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? containerEffects; + @override final StyleSpec title; @override final StyleSpec description; @@ -15,6 +18,7 @@ class RemixDialogSpec with _$RemixDialogSpec { const RemixDialogSpec({ StyleSpec? container, + this.containerEffects, StyleSpec? title, StyleSpec? description, StyleSpec? actions, @@ -22,4 +26,17 @@ class RemixDialogSpec with _$RemixDialogSpec { title = title ?? const StyleSpec(spec: TextSpec()), description = description ?? const StyleSpec(spec: TextSpec()), actions = actions ?? const StyleSpec(spec: FlexBoxSpec()); + + @override + RemixDialogSpec lerp(RemixDialogSpec? other, double t) { + final generated = super.lerp(other, t); + if (other == null) return generated; + return generated.copyWith( + containerEffects: RemixBoxEffectsSpec.lerpNullable( + containerEffects, + other.containerEffects, + t, + ), + ); + } } diff --git a/packages/remix/lib/src/components/dialog/dialog_widget.dart b/packages/remix/lib/src/components/dialog/dialog_widget.dart index 38d0f758..92876c32 100644 --- a/packages/remix/lib/src/components/dialog/dialog_widget.dart +++ b/packages/remix/lib/src/components/dialog/dialog_widget.dart @@ -183,12 +183,17 @@ class RemixDialog extends StatelessWidget { // Skip the default column so a fully custom body keeps its layout. if (isLoneChild) { - return Box(styleSpec: spec.container, child: child!); + return RemixBoxWithEffects( + styleSpec: spec.container, + containerEffects: spec.containerEffects, + child: child!, + ); } // title → description → child → actions; never discard provided content. - return Box( + return RemixBoxWithEffects( styleSpec: spec.container, + containerEffects: spec.containerEffects, child: Column( mainAxisAlignment: .start, mainAxisSize: .min, diff --git a/packages/remix/lib/src/components/dialog/fortal_dialog_styles.dart b/packages/remix/lib/src/components/dialog/fortal_dialog_styles.dart index 70f3b9d0..418ac14a 100644 --- a/packages/remix/lib/src/components/dialog/fortal_dialog_styles.dart +++ b/packages/remix/lib/src/components/dialog/fortal_dialog_styles.dart @@ -1,23 +1,45 @@ part of 'dialog.dart'; +/// Fortal dialog size presets matching Radix Themes 3.3.0. +enum FortalDialogSize { size1, size2, size3, size4 } + +/// Vertical placement of a Fortal dialog within its viewport. +enum FortalDialogAlign { start, center } + /// Fortal-themed preset for [RemixDialog]. @MixWidget( name: 'FortalDialog', target: RemixDialog.new, - factoryParameters: .only({}), + factoryParameters: .only({'size'}), ) -RemixDialogStyler fortalDialogStyler() { +RemixDialogStyler fortalDialogStyler({ + FortalDialogSize size = FortalDialogSize.size3, +}) { + final radius = switch (size) { + FortalDialogSize.size1 || FortalDialogSize.size2 => FortalTokens.radius4(), + FortalDialogSize.size3 || FortalDialogSize.size4 => FortalTokens.radius5(), + }; + final padding = switch (size) { + FortalDialogSize.size1 => FortalTokens.space3(), + FortalDialogSize.size2 => FortalTokens.space4(), + FortalDialogSize.size3 => FortalTokens.space5(), + FortalDialogSize.size4 => FortalTokens.space6(), + }; + return RemixDialogStyler() .title( - TextStyler() - .fontSize(18) - .fontWeight(.w600) + .style(FortalTokens.text5.mix()) + .fontWeight(FortalTokens.fontWeightBold()) .color(FortalTokens.gray12()) .wrap( - .padding(EdgeInsetsMix.fromLTRB(0, 0, 0, FortalTokens.space4())), + .padding(EdgeInsetsMix.fromLTRB(0, 0, 0, FortalTokens.space3())), ), ) - .description(TextStyler().fontSize(14).color(FortalTokens.gray11())) + .description( + TextStyler( + style: FortalTokens.text3.mix(), + ).color(FortalTokens.gray12()), + ) .actions( FlexBoxStyler() .mainAxisAlignment(.end) @@ -25,23 +47,14 @@ RemixDialogStyler fortalDialogStyler() { .spacing(FortalTokens.space3()) .marginTop(FortalTokens.space5()), ) - .padding(.all(FortalTokens.space5())) - .constraints(BoxConstraintsMix(maxWidth: 450)) - .border( - .all( - BorderSideMix() - .color(FortalTokens.gray6()) - .width(FortalTokens.borderWidth1()), - ), + .padding(.all(padding)) + .borderRadius(.all(radius)) + .color(FortalTokens.colorPanel()) + .decoration( + BoxDecorationMix.create(boxShadow: FortalTokens.shadow6.mix()), ) - .borderRadius(.all(FortalTokens.radius3())) - .backgroundColor(FortalTokens.gray1()) - .shadow( - BoxShadowMix() - .color(FortalTokens.blackA3()) - .offset(x: 0, y: 8) - .blurRadius(16) - .spreadRadius(0), + .containerEffects( + RemixBoxEffectsMix(backdropBlur: FortalTokens.panelBlur()), ); } diff --git a/packages/remix/lib/src/components/divider/divider.g.dart b/packages/remix/lib/src/components/divider/divider.g.dart index 4effaa30..90e64fd1 100644 --- a/packages/remix/lib/src/components/divider/divider.g.dart +++ b/packages/remix/lib/src/components/divider/divider.g.dart @@ -79,15 +79,24 @@ typedef _$RemixDividerSpecMethods = _$RemixDividerSpec; // ignore: unused_elemen /// Fortal-themed preset for [RemixDivider]. class FortalDivider extends StatelessWidget { - const FortalDivider({super.key, this.size = .size1}); + const FortalDivider({ + super.key, + this.size = .size1, + this.orientation = Axis.horizontal, + }); final FortalDividerSize size; + final Axis orientation; + @override Widget build(BuildContext context) { return RemixDivider( key: this.key, - style: fortalDividerStyler(size: this.size), + style: fortalDividerStyler( + size: this.size, + orientation: this.orientation, + ), ); } } diff --git a/packages/remix/lib/src/components/divider/fortal_divider_styles.dart b/packages/remix/lib/src/components/divider/fortal_divider_styles.dart index bf79abfe..d2dc98ed 100644 --- a/packages/remix/lib/src/components/divider/fortal_divider_styles.dart +++ b/packages/remix/lib/src/components/divider/fortal_divider_styles.dart @@ -1,35 +1,47 @@ part of 'divider.dart'; -/// Fortal divider thickness presets. -enum FortalDividerSize { - /// Hairline divider. - size1, - - /// Medium divider. - size2, - - /// Thick divider. - size3, -} +/// Fortal divider length presets: 16, 32, 64, or the available axis extent. +enum FortalDividerSize { size1, size2, size3, size4 } /// Fortal-themed preset for [RemixDivider]. @MixWidget( name: 'FortalDivider', target: RemixDivider.new, - factoryParameters: .only({'size'}), + factoryParameters: .only({'size', 'orientation'}), ) -RemixDividerStyler fortalDividerStyler({FortalDividerSize size = .size1}) { +RemixDividerStyler fortalDividerStyler({ + FortalDividerSize size = .size1, + Axis orientation = Axis.horizontal, +}) { return RemixDividerStyler() .color(FortalTokens.gray6()) - .merge(_fortalDividerSizeStyler(size)); + .merge(_fortalDividerSizeStyler(size, orientation)); } -RemixDividerStyler _fortalDividerSizeStyler(FortalDividerSize size) { - return switch (size) { - .size1 => RemixDividerStyler().thickness(1.0), - .size2 => RemixDividerStyler().thickness(2.0), - .size3 => RemixDividerStyler().thickness(3.0), +RemixDividerStyler _fortalDividerSizeStyler( + FortalDividerSize size, + Axis orientation, +) { + final length = switch (size) { + .size1 => FortalTokens.space4(), + .size2 => FortalTokens.space6(), + .size3 => FortalTokens.space9(), + .size4 => null, }; + if (orientation == Axis.horizontal) { + final style = RemixDividerStyler().height(FortalTokens.borderWidth1()); + return length == null + ? style.wrap( + WidgetModifierConfig.fractionallySizedBox(widthFactor: 1).align(), + ) + : style.width(length); + } + final style = RemixDividerStyler().width(FortalTokens.borderWidth1()); + return length == null + ? style.wrap( + WidgetModifierConfig.fractionallySizedBox(heightFactor: 1).align(), + ) + : style.height(length); } /// Fortal-themed preset for [RemixDivider]. diff --git a/packages/remix/lib/src/components/icon_button/fortal_icon_button_styles.dart b/packages/remix/lib/src/components/icon_button/fortal_icon_button_styles.dart index f4eb6a17..1847d90c 100644 --- a/packages/remix/lib/src/components/icon_button/fortal_icon_button_styles.dart +++ b/packages/remix/lib/src/components/icon_button/fortal_icon_button_styles.dart @@ -1,12 +1,12 @@ part of 'icon_button.dart'; -/// Fortal icon button size presets. +/// Radix Themes IconButton size presets. enum FortalIconButtonSize { size1, size2, size3, size4 } -/// Fortal icon button color and emphasis variants. -enum FortalIconButtonVariant { solid, soft, surface, outline, ghost } +/// Radix Themes IconButton variants. +enum FortalIconButtonVariant { classic, solid, soft, surface, outline, ghost } -/// Fortal-themed preset for [RemixIconButton]. +/// Fortal recipe for [RemixIconButton]. @MixWidget( name: 'FortalIconButton', target: RemixIconButton.new, @@ -15,202 +15,359 @@ enum FortalIconButtonVariant { solid, soft, surface, outline, ghost } RemixIconButtonStyler fortalIconButtonStyler({ FortalIconButtonVariant variant = .solid, FortalIconButtonSize size = .size2, + bool highContrast = false, }) { + final index = size.index + 1; + final base = _fortalIconButtonBaseStyler(variant, index); + final focus = fortalFocusOutline( + variant == .soft ? FortalTokens.accent8() : FortalTokens.focus8(), + offset: variant == .classic || variant == .solid ? 2 : -1, + ); + final disabledFocus = RemixBoxEffectsMix( + outline: BorderSideMix(style: BorderStyle.none), + ); + return switch (variant) { - .solid => _fortalIconButtonSolidStyler(size), - .soft => _fortalIconButtonSoftStyler(size), - .surface => _fortalIconButtonSurfaceStyler(size), - .outline => _fortalIconButtonOutlineStyler(size), - .ghost => _fortalIconButtonGhostStyler(size), - }; + .classic => _fortalIconButtonClassic( + base, + size: index, + highContrast: highContrast, + ), + .solid => _fortalIconButtonSolid(base, highContrast: highContrast), + .soft => _fortalIconButtonSoft(base, highContrast: highContrast), + .surface => _fortalIconButtonSurface(base, highContrast: highContrast), + .outline => _fortalIconButtonOutline(base, highContrast: highContrast), + .ghost => _fortalIconButtonGhost(base, highContrast: highContrast), + } + .onFocused(.containerEffects(focus)) + .onDisabled(.containerEffects(disabledFocus)); } -RemixIconButtonStyler _fortalIconButtonBaseStyler(FortalIconButtonSize size) { - return RemixIconButtonStyler() - .spinner( - RemixSpinnerStyler( - strokeWidth: FortalTokens.borderWidth2(), - duration: const Duration(milliseconds: 800), - ), - ) - .onFocused( - RemixIconButtonStyler().borderAll( - color: FortalTokens.focusA8(), - width: FortalTokens.focusRingWidth(), - ), - ) - .merge(_fortalIconButtonSizeStyler(size)); +RemixIconButtonStyler _fortalIconButtonBaseStyler( + FortalIconButtonVariant variant, + int size, +) { + final metrics = fortalBaseButtonMetrics(size); + var style = RemixIconButtonStyler( + container: .alignment(Alignment.center), + icon: .size(_fortalIconButtonIconSize(size)), + spinner: .size( + metrics.spinnerSize, + ).opacity(0.65).duration(const Duration(milliseconds: 800)), + ).borderRadiusAll(metrics.radius); + + if (variant == .ghost) { + final ghost = fortalIconButtonGhostMetrics(size); + style = style.paddingAll(ghost.padding).marginAll(ghost.margin); + } else { + style = style.width(metrics.height).height(metrics.height); + } + return style; } -RemixIconButtonStyler _fortalIconButtonSolidStyler([ - FortalIconButtonSize size = .size2, -]) { - return _fortalIconButtonBaseStyler(size) - .backgroundColor(FortalTokens.accent9()) - .foregroundColor(FortalTokens.accentContrast()) - .spinner( - RemixSpinnerStyler().indicatorColor(FortalTokens.accentContrast()), +double _fortalIconButtonIconSize(int size) => switch (size) { + 1 => FortalTokens.space3(), + 2 => FortalTokens.space4(), + 3 => FortalTokens.spinnerSize3(), + 4 => FortalTokens.space5(), + _ => throw ArgumentError.value(size, 'size', 'Expected a size from 1 to 4.'), +}; + +RemixIconButtonStyler _fortalIconButtonClassic( + RemixIconButtonStyler base, { + required int size, + required bool highContrast, +}) { + final foreground = highContrast + ? FortalTokens.gray1() + : FortalTokens.accentContrast(); + return _fortalIconButtonForeground(base, foreground) + .color(highContrast ? FortalTokens.accent12() : FortalTokens.accent9()) + .containerEffects( + RemixBoxEffectsMix( + behindContent: fortalClassicBaseButtonSurface( + highContrast: highContrast, + ), + ), ) .onHovered( - RemixIconButtonStyler().backgroundColor(FortalTokens.accent10()), + .containerEffects( + RemixBoxEffectsMix( + behindContent: fortalClassicBaseButtonSurface( + highContrast: highContrast, + hovered: true, + ), + ), + ).wrap( + highContrast + ? fortalModeAwareFilter( + light: const [ + RemixCssColorFilterOperation.contrast(0.88), + RemixCssColorFilterOperation.saturate(1.1), + RemixCssColorFilterOperation.brightness(1.1), + ], + dark: const [ + RemixCssColorFilterOperation.contrast(0.88), + RemixCssColorFilterOperation.saturate(1.3), + RemixCssColorFilterOperation.brightness(1.14), + ], + ) + : fortalClearFilter(), + ), ) .onPressed( - RemixIconButtonStyler().backgroundColor(FortalTokens.accent10()), + .containerEffects( + RemixBoxEffectsMix( + behindContent: fortalClassicBaseButtonSurface( + highContrast: highContrast, + pressed: true, + ), + ), + ) + .paddingTop(size == 1 ? 1 : 2) + .wrap( + highContrast + ? fortalModeAwareFilter( + light: const [ + RemixCssColorFilterOperation.contrast(0.82), + RemixCssColorFilterOperation.saturate(1.2), + RemixCssColorFilterOperation.brightness(1.16), + ], + dark: const [ + RemixCssColorFilterOperation.brightness(0.95), + RemixCssColorFilterOperation.saturate(1.2), + ], + ) + : fortalModeAwareFilter( + light: const [ + RemixCssColorFilterOperation.brightness(0.92), + RemixCssColorFilterOperation.saturate(1.1), + ], + dark: const [ + RemixCssColorFilterOperation.brightness(1.08), + ], + ), + ), ) .onDisabled( - RemixIconButtonStyler() - .backgroundColor(FortalTokens.grayA3()) - .foregroundColor(FortalTokens.gray8()) - .spinner( - RemixSpinnerStyler() - .indicatorColor(FortalTokens.gray8()) - .strokeWidth(FortalTokens.borderWidth1()), - ), + _fortalIconButtonForeground( + RemixIconButtonStyler(), + FortalTokens.grayA8(), + ) + .color(FortalTokens.gray2()) + .containerEffects( + RemixBoxEffectsMix( + behindContent: fortalClassicBaseButtonSurface( + highContrast: false, + disabled: true, + ), + ), + ) + .spinner(.opacity(1)) + .wrap(fortalClearFilter()), ); } -RemixIconButtonStyler _fortalIconButtonSoftStyler([ - FortalIconButtonSize size = .size2, -]) { - return _fortalIconButtonBaseStyler(size) - .backgroundColor(FortalTokens.accent3()) - .foregroundColor(FortalTokens.accent11()) - .spinner(RemixSpinnerStyler().indicatorColor(FortalTokens.accent11())) +RemixIconButtonStyler _fortalIconButtonSolid( + RemixIconButtonStyler base, { + required bool highContrast, +}) { + final foreground = highContrast + ? FortalTokens.gray1() + : FortalTokens.accentContrast(); + return _fortalIconButtonForeground(base, foreground) + .color(highContrast ? FortalTokens.accent12() : FortalTokens.accent9()) .onHovered( - RemixIconButtonStyler().backgroundColor(FortalTokens.accent4()), + .color( + highContrast ? FortalTokens.accent12() : FortalTokens.accent10(), + ).wrap( + highContrast + ? fortalModeAwareFilter( + light: const [ + RemixCssColorFilterOperation.contrast(0.88), + RemixCssColorFilterOperation.saturate(1.1), + RemixCssColorFilterOperation.brightness(1.1), + ], + dark: const [ + RemixCssColorFilterOperation.contrast(0.88), + RemixCssColorFilterOperation.saturate(1.3), + RemixCssColorFilterOperation.brightness(1.18), + ], + ) + : fortalClearFilter(), + ), ) .onPressed( - RemixIconButtonStyler().backgroundColor(FortalTokens.accent5()), - ) - .onDisabled( - RemixIconButtonStyler() - .backgroundColor(FortalTokens.grayA3()) - .foregroundColor(FortalTokens.gray8()) - .spinner( - RemixSpinnerStyler() - .indicatorColor(FortalTokens.gray8()) - .strokeWidth(FortalTokens.borderWidth1()), - ), - ); -} - -RemixIconButtonStyler _fortalIconButtonSurfaceStyler([ - FortalIconButtonSize size = .size2, -]) { - return _fortalIconButtonBaseStyler(size) - .backgroundColor(FortalTokens.accentA2()) - .borderAll( - color: FortalTokens.accent6(), - width: FortalTokens.borderWidth1(), - ) - .foregroundColor(FortalTokens.accent11()) - .spinner(RemixSpinnerStyler().indicatorColor(FortalTokens.accent11())) - .onHovered( - RemixIconButtonStyler().borderAll( - color: FortalTokens.accent8(), - width: FortalTokens.borderWidth1(), + .color( + highContrast ? FortalTokens.accent12() : FortalTokens.accent10(), + ).wrap( + highContrast + ? fortalModeAwareFilter( + light: const [ + RemixCssColorFilterOperation.contrast(0.82), + RemixCssColorFilterOperation.saturate(1.2), + RemixCssColorFilterOperation.brightness(1.16), + ], + dark: const [ + RemixCssColorFilterOperation.brightness(0.95), + RemixCssColorFilterOperation.saturate(1.2), + ], + ) + : fortalModeAwareFilter( + light: const [ + RemixCssColorFilterOperation.brightness(0.92), + RemixCssColorFilterOperation.saturate(1.1), + ], + dark: const [RemixCssColorFilterOperation.brightness(1.08)], + ), ), ) .onDisabled( - RemixIconButtonStyler() - .backgroundColor(FortalTokens.grayA2()) - .foregroundColor(FortalTokens.gray8()) - .borderAll( - color: FortalTokens.gray5(), - width: FortalTokens.borderWidth1(), + _fortalIconButtonForeground( + RemixIconButtonStyler(), + FortalTokens.grayA8(), ) - .spinner( - RemixSpinnerStyler() - .indicatorColor(FortalTokens.gray8()) - .strokeWidth(FortalTokens.borderWidth1()), - ), + .color(FortalTokens.grayA3()) + .spinner(.opacity(1)) + .wrap(fortalClearFilter()), ); } -RemixIconButtonStyler _fortalIconButtonOutlineStyler([ - FortalIconButtonSize size = .size2, -]) { - return _fortalIconButtonBaseStyler(size) - .backgroundColor(Colors.transparent) - .borderAll( - color: FortalTokens.accent7(), - width: FortalTokens.borderWidth1(), - ) - .foregroundColor(FortalTokens.accent11()) - .spinner(RemixSpinnerStyler().indicatorColor(FortalTokens.accent11())) - .onHovered( - RemixIconButtonStyler() - .backgroundColor(FortalTokens.accentA2()) - .borderAll( - color: FortalTokens.accent8(), - width: FortalTokens.borderWidth1(), +RemixIconButtonStyler _fortalIconButtonSoft( + RemixIconButtonStyler base, { + required bool highContrast, +}) => + _fortalIconButtonForeground( + base, + highContrast ? FortalTokens.accent12() : FortalTokens.accentA11(), + ) + .color(FortalTokens.accentA3()) + .onHovered(.color(FortalTokens.accentA4())) + .onPressed(.color(FortalTokens.accentA5())) + .onDisabled(_fortalIconButtonDisabledFill()); + +RemixIconButtonStyler _fortalIconButtonSurface( + RemixIconButtonStyler base, { + required bool highContrast, +}) => + _fortalIconButtonForeground( + base, + highContrast ? FortalTokens.accent12() : FortalTokens.accentA11(), + ) + .color(FortalTokens.accentSurface()) + .containerEffects( + RemixBoxEffectsMix( + behindContent: fortalInsetSurface( + strokes: [FortalTokens.accentA7()], ), - ) - .onDisabled( - RemixIconButtonStyler() - .foregroundColor(FortalTokens.gray8()) - .borderAll(color: FortalTokens.gray5()) - .spinner( - RemixSpinnerStyler() - .indicatorColor(FortalTokens.gray8()) - .strokeWidth(FortalTokens.borderWidth1()), + ), + ) + .onHovered( + .color(FortalTokens.accentSurface()).containerEffects( + RemixBoxEffectsMix( + behindContent: fortalInsetSurface( + strokes: [FortalTokens.accentA8()], + ), ), - ); -} + ), + ) + .onPressed( + .color(FortalTokens.accentA3()).containerEffects( + RemixBoxEffectsMix( + behindContent: fortalInsetSurface( + strokes: [FortalTokens.accentA8()], + ), + ), + ), + ) + .onDisabled( + _fortalIconButtonForeground( + RemixIconButtonStyler(), + FortalTokens.grayA8(), + ) + .color(FortalTokens.grayA2()) + .containerEffects( + RemixBoxEffectsMix( + behindContent: fortalInsetSurface( + strokes: [FortalTokens.grayA6()], + ), + ), + ) + .spinner(.opacity(1)), + ); -RemixIconButtonStyler _fortalIconButtonGhostStyler([ - FortalIconButtonSize size = .size2, -]) { - return _fortalIconButtonBaseStyler(size) - .backgroundColor(Colors.transparent) - .foregroundColor(FortalTokens.accent11()) - .spinner(RemixSpinnerStyler().indicatorColor(FortalTokens.accent11())) +RemixIconButtonStyler _fortalIconButtonOutline( + RemixIconButtonStyler base, { + required bool highContrast, +}) { + final strokes = highContrast + ? [FortalTokens.accentA7(), FortalTokens.grayA11()] + : [FortalTokens.accentA8()]; + return _fortalIconButtonForeground( + base, + highContrast ? FortalTokens.accent12() : FortalTokens.accentA11(), + ) + .containerEffects( + RemixBoxEffectsMix(behindContent: fortalInsetSurface(strokes: strokes)), + ) .onHovered( - RemixIconButtonStyler().backgroundColor(FortalTokens.accentA3()), + .color(FortalTokens.accentA2()).containerEffects( + RemixBoxEffectsMix( + behindContent: fortalInsetSurface(strokes: strokes), + ), + ), ) .onPressed( - RemixIconButtonStyler().backgroundColor(FortalTokens.accentA4()), + .color(FortalTokens.accentA3()).containerEffects( + RemixBoxEffectsMix( + behindContent: fortalInsetSurface(strokes: strokes), + ), + ), ) .onDisabled( - RemixIconButtonStyler() - .foregroundColor(FortalTokens.gray8()) - .spinner(RemixSpinnerStyler().indicatorColor(FortalTokens.gray8())), + _fortalIconButtonForeground( + RemixIconButtonStyler(), + FortalTokens.grayA8(), + ) + .color(Colors.transparent) + .containerEffects( + RemixBoxEffectsMix( + behindContent: fortalInsetSurface( + strokes: [FortalTokens.grayA7()], + ), + ), + ) + .spinner(.opacity(1)), ); } -RemixIconButtonStyler _fortalIconButtonSizeStyler(FortalIconButtonSize size) { - final style = RemixIconButtonStyler(); - - return switch (size) { - .size1 => - style - .width(24.0) - .height(24.0) - .borderRadiusAll(FortalTokens.radius2()) - .iconSize(12.0) - .spinner(RemixSpinnerStyler(size: 12.0)), - .size2 => - style - .width(32.0) - .height(32.0) - .borderRadiusAll(FortalTokens.radius3()) - .iconSize(16.0) - .spinner(RemixSpinnerStyler(size: 16.0)), - .size3 => - style - .width(40.0) - .height(40.0) - .borderRadiusAll(FortalTokens.radius4()) - .iconSize(20.0) - .spinner(RemixSpinnerStyler(size: 20.0)), - .size4 => - style - .width(48.0) - .height(48.0) - .borderRadiusAll(FortalTokens.radius5()) - .iconSize(24.0) - .spinner(RemixSpinnerStyler(size: 24.0)), - }; -} +RemixIconButtonStyler _fortalIconButtonGhost( + RemixIconButtonStyler base, { + required bool highContrast, +}) => + _fortalIconButtonForeground( + base, + highContrast ? FortalTokens.accent12() : FortalTokens.accentA11(), + ) + .color(Colors.transparent) + .onHovered(.color(FortalTokens.accentA3())) + .onPressed(.color(FortalTokens.accentA4())) + .onDisabled( + _fortalIconButtonForeground( + RemixIconButtonStyler(), + FortalTokens.grayA8(), + ).color(Colors.transparent).spinner(.opacity(1)), + ); + +RemixIconButtonStyler _fortalIconButtonDisabledFill() => + _fortalIconButtonForeground( + RemixIconButtonStyler(), + FortalTokens.grayA8(), + ).color(FortalTokens.grayA3()).spinner(.opacity(1)); + +RemixIconButtonStyler _fortalIconButtonForeground( + RemixIconButtonStyler style, + Color color, +) => style.icon(.color(color)).spinner(.color(color)); -/// Fortal-themed preset for [RemixIconButton]. +/// Fortal-themed IconButton with the Radix size, variant, and override contract. diff --git a/packages/remix/lib/src/components/icon_button/icon_button.dart b/packages/remix/lib/src/components/icon_button/icon_button.dart index da5f4ab6..0df534c6 100644 --- a/packages/remix/lib/src/components/icon_button/icon_button.dart +++ b/packages/remix/lib/src/components/icon_button/icon_button.dart @@ -8,6 +8,8 @@ import 'package:naked_ui/naked_ui.dart'; import '../../style/style.dart'; import '../../utilities/remix_style.dart'; +import '../../rendering/remix_ordered_color_filter.dart'; +import '../../rendering/remix_box_effects.dart'; import '../spinner/spinner.dart'; import '../../fortal/fortal.dart'; diff --git a/packages/remix/lib/src/components/icon_button/icon_button.g.dart b/packages/remix/lib/src/components/icon_button/icon_button.g.dart index 9684b07e..37d01a1c 100644 --- a/packages/remix/lib/src/components/icon_button/icon_button.g.dart +++ b/packages/remix/lib/src/components/icon_button/icon_button.g.dart @@ -11,6 +11,7 @@ mixin _$RemixIconButtonSpec StyleSpec get container; StyleSpec get icon; StyleSpec get spinner; + RemixBoxEffectsSpec? get containerEffects; @override Type get type => RemixIconButtonSpec; @@ -20,11 +21,13 @@ mixin _$RemixIconButtonSpec StyleSpec? container, StyleSpec? icon, StyleSpec? spinner, + RemixBoxEffectsSpec? containerEffects, }) { return RemixIconButtonSpec( container: container ?? this.container, icon: icon ?? this.icon, spinner: spinner ?? this.spinner, + containerEffects: containerEffects ?? this.containerEffects, ); } @@ -34,11 +37,16 @@ mixin _$RemixIconButtonSpec container: container.lerp(other?.container, t), icon: icon.lerp(other?.icon, t), spinner: spinner.lerp(other?.spinner, t), + containerEffects: MixOps.lerpSnap( + containerEffects, + other?.containerEffects, + t, + ), ); } @override - List get props => [container, icon, spinner]; + List get props => [container, icon, spinner, containerEffects]; @override bool operator ==(Object other) { @@ -82,7 +90,8 @@ mixin _$RemixIconButtonSpec properties ..add(DiagnosticsProperty('container', container)) ..add(DiagnosticsProperty('icon', icon)) - ..add(DiagnosticsProperty('spinner', spinner)); + ..add(DiagnosticsProperty('spinner', spinner)) + ..add(DiagnosticsProperty('containerEffects', containerEffects)); } } @@ -95,7 +104,7 @@ typedef _$RemixIconButtonSpecMethods = _$RemixIconButtonSpec; // ignore: unused_ // MixWidgetGenerator // ************************************************************************** -/// Fortal-themed preset for [RemixIconButton]. +/// Fortal recipe for [RemixIconButton]. class FortalIconButton extends StatelessWidget { const FortalIconButton({ super.key, @@ -103,6 +112,7 @@ class FortalIconButton extends StatelessWidget { this.size = .size2, required this.icon, this.iconBuilder, + this.semanticLabel, this.loadingBuilder, this.loading = false, this.enabled = true, @@ -111,17 +121,17 @@ class FortalIconButton extends StatelessWidget { this.onLongPress, this.focusNode, this.autofocus = false, - this.semanticLabel, this.semanticHint, this.excludeSemantics = false, this.mouseCursor = SystemMouseCursors.click, }); - const FortalIconButton.solid({ + const FortalIconButton.classic({ super.key, this.size = .size2, required this.icon, this.iconBuilder, + this.semanticLabel, this.loadingBuilder, this.loading = false, this.enabled = true, @@ -130,7 +140,25 @@ class FortalIconButton extends StatelessWidget { this.onLongPress, this.focusNode, this.autofocus = false, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalIconButtonVariant.classic; + + const FortalIconButton.solid({ + super.key, + this.size = .size2, + required this.icon, + this.iconBuilder, this.semanticLabel, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.enableFeedback = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, this.semanticHint, this.excludeSemantics = false, this.mouseCursor = SystemMouseCursors.click, @@ -141,6 +169,7 @@ class FortalIconButton extends StatelessWidget { this.size = .size2, required this.icon, this.iconBuilder, + this.semanticLabel, this.loadingBuilder, this.loading = false, this.enabled = true, @@ -149,7 +178,6 @@ class FortalIconButton extends StatelessWidget { this.onLongPress, this.focusNode, this.autofocus = false, - this.semanticLabel, this.semanticHint, this.excludeSemantics = false, this.mouseCursor = SystemMouseCursors.click, @@ -160,6 +188,7 @@ class FortalIconButton extends StatelessWidget { this.size = .size2, required this.icon, this.iconBuilder, + this.semanticLabel, this.loadingBuilder, this.loading = false, this.enabled = true, @@ -168,7 +197,6 @@ class FortalIconButton extends StatelessWidget { this.onLongPress, this.focusNode, this.autofocus = false, - this.semanticLabel, this.semanticHint, this.excludeSemantics = false, this.mouseCursor = SystemMouseCursors.click, @@ -179,6 +207,7 @@ class FortalIconButton extends StatelessWidget { this.size = .size2, required this.icon, this.iconBuilder, + this.semanticLabel, this.loadingBuilder, this.loading = false, this.enabled = true, @@ -187,7 +216,6 @@ class FortalIconButton extends StatelessWidget { this.onLongPress, this.focusNode, this.autofocus = false, - this.semanticLabel, this.semanticHint, this.excludeSemantics = false, this.mouseCursor = SystemMouseCursors.click, @@ -198,6 +226,7 @@ class FortalIconButton extends StatelessWidget { this.size = .size2, required this.icon, this.iconBuilder, + this.semanticLabel, this.loadingBuilder, this.loading = false, this.enabled = true, @@ -206,7 +235,6 @@ class FortalIconButton extends StatelessWidget { this.onLongPress, this.focusNode, this.autofocus = false, - this.semanticLabel, this.semanticHint, this.excludeSemantics = false, this.mouseCursor = SystemMouseCursors.click, @@ -220,6 +248,8 @@ class FortalIconButton extends StatelessWidget { final RemixIconButtonIconBuilder? iconBuilder; + final String? semanticLabel; + final RemixIconButtonLoadingBuilder? loadingBuilder; final bool loading; @@ -236,8 +266,6 @@ class FortalIconButton extends StatelessWidget { final bool autofocus; - final String? semanticLabel; - final String? semanticHint; final bool excludeSemantics; @@ -251,6 +279,7 @@ class FortalIconButton extends StatelessWidget { style: fortalIconButtonStyler(variant: this.variant, size: this.size), icon: this.icon, iconBuilder: this.iconBuilder, + semanticLabel: this.semanticLabel, loadingBuilder: this.loadingBuilder, loading: this.loading, enabled: this.enabled, @@ -259,7 +288,6 @@ class FortalIconButton extends StatelessWidget { onLongPress: this.onLongPress, focusNode: this.focusNode, autofocus: this.autofocus, - semanticLabel: this.semanticLabel, semanticHint: this.semanticHint, excludeSemantics: this.excludeSemantics, mouseCursor: this.mouseCursor, @@ -279,22 +307,26 @@ class RemixIconButtonStyler final Prop>? $container; final Prop>? $icon; final Prop>? $spinner; + final Prop? $containerEffects; const RemixIconButtonStyler.create({ Prop>? container, Prop>? icon, Prop>? spinner, + Prop? containerEffects, super.variants, super.modifier, super.animation, }) : $container = container, $icon = icon, - $spinner = spinner; + $spinner = spinner, + $containerEffects = containerEffects; RemixIconButtonStyler({ BoxStyler? container, IconStyler? icon, RemixSpinnerStyler? spinner, + RemixBoxEffectsMix? containerEffects, AnimationConfig? animation, WidgetModifierConfig? modifier, List>? variants, @@ -302,6 +334,7 @@ class RemixIconButtonStyler container: Prop.maybeMix(container), icon: Prop.maybeMix(icon), spinner: Prop.maybeMix(spinner), + containerEffects: Prop.maybeMix(containerEffects), variants: variants, modifier: modifier, animation: animation, @@ -313,6 +346,8 @@ class RemixIconButtonStyler RemixIconButtonStyler().icon(value); factory RemixIconButtonStyler.spinner(RemixSpinnerStyler value) => RemixIconButtonStyler().spinner(value); + factory RemixIconButtonStyler.containerEffects(RemixBoxEffectsMix value) => + RemixIconButtonStyler().containerEffects(value); factory RemixIconButtonStyler.alignment(AlignmentGeometry value) => RemixIconButtonStyler().alignment(value); factory RemixIconButtonStyler.padding(EdgeInsetsGeometryMix value) => @@ -810,6 +845,11 @@ class RemixIconButtonStyler return merge(RemixIconButtonStyler(spinner: value)); } + /// Sets the containerEffects. + RemixIconButtonStyler containerEffects(RemixBoxEffectsMix value) { + return merge(RemixIconButtonStyler(containerEffects: value)); + } + /// Sets the animation configuration. @override RemixIconButtonStyler animate(AnimationConfig value) { @@ -842,6 +882,10 @@ class RemixIconButtonStyler container: MixOps.merge($container, other?.$container), icon: MixOps.merge($icon, other?.$icon), spinner: MixOps.merge($spinner, other?.$spinner), + containerEffects: MixOps.merge( + $containerEffects, + other?.$containerEffects, + ), variants: MixOps.mergeVariants($variants, other?.$variants), modifier: MixOps.mergeModifier($modifier, other?.$modifier), animation: MixOps.mergeAnimation($animation, other?.$animation), @@ -855,6 +899,7 @@ class RemixIconButtonStyler container: MixOps.resolve(context, $container), icon: MixOps.resolve(context, $icon), spinner: MixOps.resolve(context, $spinner), + containerEffects: MixOps.resolve(context, $containerEffects), ); return StyleSpec( @@ -870,7 +915,8 @@ class RemixIconButtonStyler properties ..add(DiagnosticsProperty('container', $container)) ..add(DiagnosticsProperty('icon', $icon)) - ..add(DiagnosticsProperty('spinner', $spinner)); + ..add(DiagnosticsProperty('spinner', $spinner)) + ..add(DiagnosticsProperty('containerEffects', $containerEffects)); } @override @@ -878,6 +924,7 @@ class RemixIconButtonStyler $container, $icon, $spinner, + $containerEffects, $animation, $modifier, $variants, diff --git a/packages/remix/lib/src/components/icon_button/icon_button_spec.dart b/packages/remix/lib/src/components/icon_button/icon_button_spec.dart index 467c248f..a7c110de 100644 --- a/packages/remix/lib/src/components/icon_button/icon_button_spec.dart +++ b/packages/remix/lib/src/components/icon_button/icon_button_spec.dart @@ -10,12 +10,29 @@ class RemixIconButtonSpec with _$RemixIconButtonSpec { final StyleSpec icon; @override final StyleSpec spinner; + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? containerEffects; const RemixIconButtonSpec({ StyleSpec? container, StyleSpec? icon, StyleSpec? spinner, + this.containerEffects, }) : container = container ?? const StyleSpec(spec: BoxSpec()), icon = icon ?? const StyleSpec(spec: IconSpec()), spinner = spinner ?? const StyleSpec(spec: RemixSpinnerSpec()); + + @override + RemixIconButtonSpec lerp(RemixIconButtonSpec? other, double t) { + final generated = super.lerp(other, t); + if (other == null) return generated; + return generated.copyWith( + containerEffects: RemixBoxEffectsSpec.lerpNullable( + containerEffects, + other.containerEffects, + t, + ), + ); + } } diff --git a/packages/remix/lib/src/components/icon_button/icon_button_widget.dart b/packages/remix/lib/src/components/icon_button/icon_button_widget.dart index 217fe2bc..779c6738 100644 --- a/packages/remix/lib/src/components/icon_button/icon_button_widget.dart +++ b/packages/remix/lib/src/components/icon_button/icon_button_widget.dart @@ -1,48 +1,22 @@ part of 'icon_button.dart'; -/// Builder function for customizing icon button icon rendering. +/// Builds icon data with the resolved icon style. typedef RemixIconButtonIconBuilder = Widget Function(BuildContext context, IconSpec spec, IconData? icon); -/// Builder function for customizing icon button loading state rendering. +/// Builder for the loading indicator rendered by [RemixIconButton]. typedef RemixIconButtonLoadingBuilder = Widget Function(BuildContext context, RemixSpinnerSpec spec); -/// A customizable icon button component optimized for icon-only interactions. -/// The button is square by default and centers the icon properly. +/// A square button that renders typed [IconData]. /// -/// ## Examples -/// -/// ```dart -/// // Basic icon button -/// RemixIconButton( -/// icon: Icons.add, -/// onPressed: () => debugPrint('Add pressed!'), -/// ) -/// -/// // Custom styled icon button -/// RemixIconButton( -/// icon: Icons.delete, -/// style: RemixIconButtonStyler().backgroundColor(Colors.red), -/// onPressed: () => debugPrint('Delete pressed!'), -/// ) -/// -/// // Loading icon button -/// RemixIconButton( -/// icon: Icons.save, -/// loading: true, -/// onPressed: () => debugPrint('Save pressed!'), -/// ) -/// ``` +/// Use [iconBuilder] when custom icon composition is needed. class RemixIconButton extends StatelessWidget { - /// Creates a Remix icon button. - /// - /// The [icon] parameter is required and specifies which icon to display. - /// Use builders to customize rendering of specific parts. const RemixIconButton({ super.key, required this.icon, this.iconBuilder, + this.semanticLabel, this.loadingBuilder, this.loading = false, this.enabled = true, @@ -51,81 +25,45 @@ class RemixIconButton extends StatelessWidget { this.onLongPress, this.focusNode, this.autofocus = false, - this.semanticLabel, this.semanticHint, this.excludeSemantics = false, this.mouseCursor = SystemMouseCursors.click, this.style = const RemixIconButtonStyler.create(), this.styleSpec, - }); - - final RemixIconButtonStyler style; - - final RemixIconButtonSpec? styleSpec; + }) : assert(semanticLabel == null || semanticLabel != ''); static final styleFrom = RemixIconButtonStyler.new; - /// Whether the button is in a loading state. - /// - /// When true, the button will display a spinner and become non-interactive. + /// The icon rendered by the button. + final IconData icon; + final RemixIconButtonIconBuilder? iconBuilder; + final String? semanticLabel; + final RemixIconButtonLoadingBuilder? loadingBuilder; final bool loading; - - /// Whether this icon button is enabled. - /// - /// When false, press callbacks are suppressed even if [onPressed] is provided. final bool enabled; - - /// Callback function called when the button is pressed. - /// - /// If null, the button will be considered disabled. + final bool enableFeedback; final VoidCallback? onPressed; - - /// Callback function called when the button is long pressed. final VoidCallback? onLongPress; - - /// Optional focus node to control the button's focus behavior. final FocusNode? focusNode; - - /// Whether to provide feedback when the button is pressed. - /// - /// Defaults to true. - final bool enableFeedback; - - /// Whether the button should automatically request focus when it is created. final bool autofocus; - - /// The icon to display in the button. - final IconData icon; - - /// Builder for customizing the icon rendering. - final RemixIconButtonIconBuilder? iconBuilder; - - /// Builder for customizing the loading state rendering. - final RemixIconButtonLoadingBuilder? loadingBuilder; - - /// The semantic label for the button. - /// - /// Used by screen readers to describe the button. - final String? semanticLabel; - - /// The semantic hint for the button. - /// - /// Provides additional context about what will happen when the button is activated. final String? semanticHint; - - /// Whether to exclude child semantics. - /// - /// When true, the semantics of child widgets will be excluded. - /// Defaults to false. final bool excludeSemantics; - - /// Cursor when hovering over the button. - /// - /// Defaults to [SystemMouseCursors.click] when enabled. final MouseCursor mouseCursor; + final RemixIconButtonStyler style; + final RemixIconButtonSpec? styleSpec; bool get _isEnabled => enabled && !loading && onPressed != null; + Widget _buildIcon(BuildContext context, StyleSpec styleSpec) { + if (iconBuilder case final builder?) { + return StyleSpecBuilder( + styleSpec: styleSpec, + builder: (context, spec) => builder(context, spec, icon), + ); + } + return StyledIcon(icon: icon, styleSpec: styleSpec); + } + @override Widget build(BuildContext context) { return NakedButton( @@ -136,66 +74,51 @@ class RemixIconButton extends StatelessWidget { enableFeedback: enableFeedback, focusNode: focusNode, autofocus: autofocus, - semanticLabel: semanticLabel, - builder: (context, states, child) { - return RemixStyleSpecBuilder( - style: style, - styleSpec: styleSpec, - controller: NakedButtonState.controllerOf(context), - builder: (context, spec) { - Widget? iconWidget; - - if (iconBuilder != null) { - iconWidget = StyleSpecBuilder( - styleSpec: spec.icon, - builder: (context, iconSpec) => - iconBuilder!(context, iconSpec, icon), - ); - } else { - iconWidget = StyledIcon(icon: icon, styleSpec: spec.icon); - } - - // Build spinner (used when loading) - final spinner = Center( - child: loadingBuilder == null - ? StyleSpecBuilder( - styleSpec: spec.spinner, - builder: (context, spinnerSpec) => - RemixSpinner(styleSpec: spinnerSpec), - ) - : StyleSpecBuilder( - styleSpec: spec.spinner, - builder: loadingBuilder!, - ), - ); - - // Create content with visibility control for loading state - final content = Visibility( - visible: !loading, - maintainState: true, - maintainAnimation: true, - maintainSize: true, - child: iconWidget, - ); - - // Layer spinner above the content while keeping size stable. - final layered = Stack( - alignment: .center, - children: [content, if (loading) spinner], - ); - - return MergeSemantics( - child: Semantics( - excludeSemantics: excludeSemantics, - liveRegion: loading, - label: semanticLabel ?? 'Icon Button', - hint: semanticHint, - child: Box(styleSpec: spec.container, child: layered), + semanticLabel: semanticLabel ?? 'Icon Button', + excludeSemantics: excludeSemantics, + builder: (context, _, _) => RemixStyleSpecBuilder( + style: style, + styleSpec: styleSpec, + controller: NakedButtonState.controllerOf(context), + builder: (context, spec) { + final content = Visibility( + visible: !loading, + maintainState: true, + maintainAnimation: true, + maintainSize: true, + maintainSemantics: true, + child: _buildIcon(context, spec.icon), + ); + final button = RemixBoxWithEffects( + key: const ValueKey('remix-icon-button-surface'), + styleSpec: spec.container, + containerEffects: spec.containerEffects, + child: content, + ); + final spinner = StyleSpecBuilder( + styleSpec: spec.spinner, + builder: (context, spinnerSpec) => loadingBuilder == null + ? RemixSpinner(styleSpec: spinnerSpec) + : loadingBuilder!(context, spinnerSpec), + ); + + return MergeSemantics( + child: Semantics( + excludeSemantics: excludeSemantics, + liveRegion: loading, + label: semanticLabel ?? 'Icon Button', + hint: semanticHint, + child: Stack( + alignment: Alignment.center, + children: [ + button, + if (loading) Positioned.fill(child: Center(child: spinner)), + ], ), - ); - }, - ); - }, + ), + ); + }, + ), ); } } diff --git a/packages/remix/lib/src/components/menu/fortal_menu_styles.dart b/packages/remix/lib/src/components/menu/fortal_menu_styles.dart index f091af10..bbbbb9a0 100644 --- a/packages/remix/lib/src/components/menu/fortal_menu_styles.dart +++ b/packages/remix/lib/src/components/menu/fortal_menu_styles.dart @@ -1,24 +1,12 @@ part of 'menu.dart'; -/// Size scale for Fortal menu triggers and items. -enum FortalMenuSize { - /// Compact menu controls. - size1, +/// Radix Themes menu content sizes. +enum FortalMenuSize { size1, size2 } - /// Default menu controls. - size2, -} - -/// Visual variants for Fortal menu controls. -enum FortalMenuVariant { - /// High-emphasis menu trigger and item hover treatment. - solid, +/// Radix Themes menu content variants. +enum FortalMenuVariant { solid, soft } - /// Subtle accent-backed menu trigger and item hover treatment. - soft, -} - -/// Fortal-themed preset for [RemixMenu]. +/// Fortal recipe for the established data-driven menu. @MixWidget( name: 'FortalMenu', target: RemixMenu.new, @@ -27,173 +15,155 @@ enum FortalMenuVariant { RemixMenuStyler fortalMenuStyler({ FortalMenuVariant variant = .solid, FortalMenuSize size = .size2, + bool highContrast = false, }) { - return switch (variant) { - .solid => _fortalMenuSolidStyler(size), - .soft => _fortalMenuSoftStyler(size), - }; -} - -RemixMenuStyler _fortalMenuBaseStyler(FortalMenuSize size) { + final metrics = _fortalMenuMetrics(size); return RemixMenuStyler() - .trigger( - RemixMenuTriggerStyler() - .borderRadius(BorderRadiusMix.all(FortalTokens.radius2())) - .label( - TextStyler( - style: TextStyleMix( - color: FortalTokens.gray12(), - fontSize: 14, - fontWeight: .w500, - ), - ), - ) - .icon(IconStyler(color: FortalTokens.gray11(), size: 16)), - ) .overlay( - FlexBoxStyler( - decoration: BoxDecorationMix( - border: BorderMix.all( - BorderSideMix( - color: FortalTokens.gray7(), - width: FortalTokens.borderWidth1(), - ), - ), - borderRadius: BorderRadiusMix.all(FortalTokens.radius3()), - color: FortalTokens.gray1(), - ), - padding: EdgeInsetsMix.all(FortalTokens.space1()), - ).marginTop(8), - ) - .divider( - RemixDividerStyler() - .margin(EdgeInsetsMix.symmetric(vertical: FortalTokens.space1())) - .height(FortalTokens.borderWidth1()) - .color(FortalTokens.gray6()), - ) - .merge(_fortalMenuSizeStyler(size)); -} - -RemixMenuStyler _fortalMenuSolidStyler([FortalMenuSize size = .size2]) { - return _fortalMenuBaseStyler(size) - .trigger( - RemixMenuTriggerStyler() - .icon(IconStyler(color: FortalTokens.accentContrast(), size: 16)) - .spacing(8) - .color(FortalTokens.accent9()) - .label(TextStyler().color(FortalTokens.accentContrast())), - ) - .item(_fortalMenuItemSolidStyler(size)); -} - -RemixMenuStyler _fortalMenuSoftStyler([FortalMenuSize size = .size2]) { - return _fortalMenuBaseStyler(size) - .trigger( - RemixMenuTriggerStyler() - .decoration(BoxDecorationMix(color: FortalTokens.accent3())) - .label( - TextStyler( - style: TextStyleMix( - color: FortalTokens.accent11(), - fontSize: 14, - fontWeight: .w500, - ), - ), + FlexBoxStyler() + .paddingAll(metrics.contentPadding) + .borderRadiusAll(metrics.contentRadius) + .color(FortalTokens.colorPanel()) + .decoration( + BoxDecorationMix.create(boxShadow: FortalTokens.shadow5.mix()), ) - .icon(IconStyler(color: FortalTokens.accent11(), size: 16)), + .clipBehavior(Clip.antiAlias), ) - .item(_fortalMenuItemSoftStyler(size)); -} - -RemixMenuStyler _fortalMenuSizeStyler(FortalMenuSize size) { - return switch (size) { - .size1 => RemixMenuStyler().trigger( - RemixMenuTriggerStyler().padding( - EdgeInsetsMix.symmetric( - vertical: FortalTokens.space1(), - horizontal: FortalTokens.space2(), - ), - ), - ), - .size2 => RemixMenuStyler().trigger( - RemixMenuTriggerStyler().padding( - EdgeInsetsMix.symmetric( - vertical: FortalTokens.space2(), - horizontal: FortalTokens.space3(), - ), - ), - ), - }; + .containerEffects( + RemixBoxEffectsMix(backdropBlur: FortalTokens.panelBlur()), + ) + .item(_fortalMenuItemStyler(variant, metrics, highContrast: highContrast)) + .divider(_fortalMenuDividerStyler(metrics)); } -/// Creates a Fortal-themed [RemixMenuItemStyler]. +/// Fortal item recipe for per-item style overrides. RemixMenuItemStyler fortalMenuItemStyler({ FortalMenuVariant variant = .solid, FortalMenuSize size = .size2, + bool highContrast = false, +}) => _fortalMenuItemStyler( + variant, + _fortalMenuMetrics(size), + highContrast: highContrast, +); + +RemixMenuItemStyler _fortalMenuItemStyler( + FortalMenuVariant variant, + _FortalMenuMetrics metrics, { + required bool highContrast, }) { - return switch (variant) { - .solid => _fortalMenuItemSolidStyler(size), - .soft => _fortalMenuItemSoftStyler(size), + final base = RemixMenuItemStyler() + .direction(.horizontal) + .mainAxisSize(.max) + .crossAxisAlignment(.center) + .spacing(FortalTokens.space2()) + .height(metrics.itemHeight) + .paddingX(metrics.leadingInset) + .borderRadiusAll(metrics.itemRadius) + .label(.style(metrics.text.mix()).color(FortalTokens.gray12())) + .leadingIcon(.color(FortalTokens.gray12()).size(metrics.indicatorSize)) + .trailingIcon(.color(FortalTokens.gray12()).size(metrics.indicatorSize)); + + final highlighted = switch (variant) { + .solid => + RemixMenuItemStyler() + .color( + highContrast ? FortalTokens.accent12() : FortalTokens.accent9(), + ) + .label( + .color( + highContrast + ? FortalTokens.accent1() + : FortalTokens.accentContrast(), + ), + ) + .leadingIcon( + .color( + highContrast + ? FortalTokens.accent1() + : FortalTokens.accentContrast(), + ), + ) + .trailingIcon( + .color( + highContrast + ? FortalTokens.accent1() + : FortalTokens.accentContrast(), + ), + ), + .soft => RemixMenuItemStyler().color(FortalTokens.accentA4()), }; -} + final submenuOpen = RemixMenuItemStyler().color( + variant == .solid ? FortalTokens.grayA3() : FortalTokens.accentA3(), + ); + final disabled = RemixMenuItemStyler() + .color(Colors.transparent) + .label(.color(FortalTokens.grayA8())) + .leadingIcon(.color(FortalTokens.grayA8())) + .trailingIcon(.color(FortalTokens.grayA8())); -RemixMenuItemStyler _fortalMenuItemBaseStyler(FortalMenuSize size) { - return RemixMenuItemStyler() - .borderRadius(BorderRadiusMix.all(FortalTokens.radius2())) - .label( - TextStyler( - style: TextStyleMix(color: FortalTokens.gray12(), fontSize: 14), - ), - ) - .leadingIcon(IconStyler(color: FortalTokens.gray11(), size: 16)) - .trailingIcon(IconStyler(color: FortalTokens.gray11(), size: 16)) - .merge(_fortalMenuItemSizeStyler(size)); + return base + .onSelected(submenuOpen) + .onHovered(highlighted) + .onFocused(highlighted) + .onPressed(highlighted) + .onDisabled(disabled); } -RemixMenuItemStyler _fortalMenuItemSolidStyler([FortalMenuSize size = .size2]) { - return _fortalMenuItemBaseStyler(size) - .color(FortalTokens.graySurface()) - .onHovered( - RemixMenuItemStyler() - .color(FortalTokens.accent9()) - .label(TextStyler().color(FortalTokens.accentContrast())), - ); -} +RemixDividerStyler _fortalMenuDividerStyler(_FortalMenuMetrics metrics) => + RemixDividerStyler() + .height(1) + .marginOnly( + left: metrics.leadingInset, + right: metrics.trailingInset, + top: FortalTokens.space2(), + bottom: FortalTokens.space2(), + ) + .color(FortalTokens.grayA6()); -RemixMenuItemStyler _fortalMenuItemSoftStyler([FortalMenuSize size = .size2]) { - return _fortalMenuItemBaseStyler(size) - .decoration(BoxDecorationMix(color: Colors.transparent)) - .onHovered( - RemixMenuItemStyler() - .decoration(BoxDecorationMix(color: FortalTokens.accentA3())) - .label( - TextStyler( - style: TextStyleMix( - color: FortalTokens.accent11(), - fontSize: 14, - ), - ), - ) - .leadingIcon(IconStyler(color: FortalTokens.accent11(), size: 16)) - .trailingIcon(IconStyler(color: FortalTokens.accent11(), size: 16)), - ); -} +class _FortalMenuMetrics { + const _FortalMenuMetrics({ + required this.contentPadding, + required this.contentRadius, + required this.itemHeight, + required this.itemRadius, + required this.leadingInset, + required this.trailingInset, + required this.indicatorSize, + required this.text, + }); -RemixMenuItemStyler _fortalMenuItemSizeStyler(FortalMenuSize size) { - return switch (size) { - .size1 => RemixMenuItemStyler().padding( - EdgeInsetsMix.symmetric( - vertical: FortalTokens.space1(), - horizontal: FortalTokens.space1(), - ), - ), - .size2 => RemixMenuItemStyler().padding( - EdgeInsetsMix.symmetric( - vertical: FortalTokens.space2(), - horizontal: FortalTokens.space2(), - ), - ), - }; + final double contentPadding; + final Radius contentRadius; + final double itemHeight; + final Radius itemRadius; + final double leadingInset; + final double trailingInset; + final double indicatorSize; + final TextStyleToken text; } -/// Fortal-themed preset for [RemixMenu]. +_FortalMenuMetrics _fortalMenuMetrics(FortalMenuSize size) => switch (size) { + .size1 => _FortalMenuMetrics( + contentPadding: FortalTokens.space1(), + contentRadius: FortalTokens.radius3(), + itemHeight: FortalTokens.space5(), + itemRadius: FortalTokens.radius1(), + leadingInset: FortalTokens.space2(), + trailingInset: FortalTokens.space2(), + indicatorSize: FortalTokens.selectIndicatorSize1(), + text: FortalTokens.text1, + ), + .size2 => _FortalMenuMetrics( + contentPadding: FortalTokens.space2(), + contentRadius: FortalTokens.radius4(), + itemHeight: FortalTokens.space6(), + itemRadius: FortalTokens.radius2(), + leadingInset: FortalTokens.space3(), + trailingInset: FortalTokens.space3(), + indicatorSize: FortalTokens.selectIndicatorSize2(), + text: FortalTokens.text2, + ), +}; + +/// Fortal menu content with Radix-owned size, variant, and contrast behavior. diff --git a/packages/remix/lib/src/components/menu/menu.dart b/packages/remix/lib/src/components/menu/menu.dart index 94aae4e6..3e36d0ef 100644 --- a/packages/remix/lib/src/components/menu/menu.dart +++ b/packages/remix/lib/src/components/menu/menu.dart @@ -2,14 +2,15 @@ library remix_menu; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:mix/mix.dart'; import 'package:mix_annotations/mix_annotations.dart'; import 'package:naked_ui/naked_ui.dart'; import '../../fortal/fortal.dart'; +import '../../rendering/remix_box_effects.dart'; import '../../style/style.dart'; import '../../utilities/remix_style.dart'; +import '../../utilities/selected_mixin.dart'; import '../divider/divider.dart'; part 'menu_spec.dart'; diff --git a/packages/remix/lib/src/components/menu/menu.g.dart b/packages/remix/lib/src/components/menu/menu.g.dart index 30d46e66..07accf23 100644 --- a/packages/remix/lib/src/components/menu/menu.g.dart +++ b/packages/remix/lib/src/components/menu/menu.g.dart @@ -94,6 +94,7 @@ typedef _$RemixMenuTriggerSpecMethods = _$RemixMenuTriggerSpec; // ignore: unuse mixin _$RemixMenuSpec implements Spec, Diagnosticable { StyleSpec get trigger; StyleSpec get overlay; + RemixBoxEffectsSpec? get containerEffects; StyleSpec get item; StyleSpec get divider; @@ -104,12 +105,14 @@ mixin _$RemixMenuSpec implements Spec, Diagnosticable { RemixMenuSpec copyWith({ StyleSpec? trigger, StyleSpec? overlay, + RemixBoxEffectsSpec? containerEffects, StyleSpec? item, StyleSpec? divider, }) { return RemixMenuSpec( trigger: trigger ?? this.trigger, overlay: overlay ?? this.overlay, + containerEffects: containerEffects ?? this.containerEffects, item: item ?? this.item, divider: divider ?? this.divider, ); @@ -120,13 +123,24 @@ mixin _$RemixMenuSpec implements Spec, Diagnosticable { return RemixMenuSpec( trigger: trigger.lerp(other?.trigger, t), overlay: overlay.lerp(other?.overlay, t), + containerEffects: MixOps.lerpSnap( + containerEffects, + other?.containerEffects, + t, + ), item: item.lerp(other?.item, t), divider: divider.lerp(other?.divider, t), ); } @override - List get props => [trigger, overlay, item, divider]; + List get props => [ + trigger, + overlay, + containerEffects, + item, + divider, + ]; @override bool operator ==(Object other) { @@ -170,6 +184,7 @@ mixin _$RemixMenuSpec implements Spec, Diagnosticable { properties ..add(DiagnosticsProperty('trigger', trigger)) ..add(DiagnosticsProperty('overlay', overlay)) + ..add(DiagnosticsProperty('containerEffects', containerEffects)) ..add(DiagnosticsProperty('item', item)) ..add(DiagnosticsProperty('divider', divider)); } @@ -273,7 +288,7 @@ typedef _$RemixMenuItemSpecMethods = _$RemixMenuItemSpec; // ignore: unused_elem // MixWidgetGenerator // ************************************************************************** -/// Fortal-themed preset for [RemixMenu]. +/// Fortal recipe for the established data-driven menu. class FortalMenu extends StatelessWidget { const FortalMenu({ super.key, @@ -295,7 +310,6 @@ class FortalMenu extends StatelessWidget { this.positioning = const OverlayPositionConfig(), }); - /// High-emphasis menu trigger and item hover treatment. const FortalMenu.solid({ super.key, this.size = .size2, @@ -315,7 +329,6 @@ class FortalMenu extends StatelessWidget { this.positioning = const OverlayPositionConfig(), }) : variant = FortalMenuVariant.solid; - /// Subtle accent-backed menu trigger and item hover treatment. const FortalMenu.soft({ super.key, this.size = .size2, @@ -1070,12 +1083,14 @@ class RemixMenuTriggerStyler class RemixMenuStyler extends MixStyler { final Prop>? $trigger; final Prop>? $overlay; + final Prop? $containerEffects; final Prop>? $item; final Prop>? $divider; const RemixMenuStyler.create({ Prop>? trigger, Prop>? overlay, + Prop? containerEffects, Prop>? item, Prop>? divider, super.variants, @@ -1083,12 +1098,14 @@ class RemixMenuStyler extends MixStyler { super.animation, }) : $trigger = trigger, $overlay = overlay, + $containerEffects = containerEffects, $item = item, $divider = divider; RemixMenuStyler({ RemixMenuTriggerStyler? trigger, FlexBoxStyler? overlay, + RemixBoxEffectsMix? containerEffects, RemixMenuItemStyler? item, RemixDividerStyler? divider, AnimationConfig? animation, @@ -1097,6 +1114,7 @@ class RemixMenuStyler extends MixStyler { }) : this.create( trigger: Prop.maybeMix(trigger), overlay: Prop.maybeMix(overlay), + containerEffects: Prop.maybeMix(containerEffects), item: Prop.maybeMix(item), divider: Prop.maybeMix(divider), variants: variants, @@ -1108,6 +1126,8 @@ class RemixMenuStyler extends MixStyler { RemixMenuStyler().trigger(value); factory RemixMenuStyler.overlay(FlexBoxStyler value) => RemixMenuStyler().overlay(value); + factory RemixMenuStyler.containerEffects(RemixBoxEffectsMix value) => + RemixMenuStyler().containerEffects(value); factory RemixMenuStyler.item(RemixMenuItemStyler value) => RemixMenuStyler().item(value); factory RemixMenuStyler.divider(RemixDividerStyler value) => @@ -1123,6 +1143,11 @@ class RemixMenuStyler extends MixStyler { return merge(RemixMenuStyler(overlay: value)); } + /// Sets the containerEffects. + RemixMenuStyler containerEffects(RemixBoxEffectsMix value) { + return merge(RemixMenuStyler(containerEffects: value)); + } + /// Sets the item. RemixMenuStyler item(RemixMenuItemStyler value) { return merge(RemixMenuStyler(item: value)); @@ -1162,6 +1187,10 @@ class RemixMenuStyler extends MixStyler { return RemixMenuStyler.create( trigger: MixOps.merge($trigger, other?.$trigger), overlay: MixOps.merge($overlay, other?.$overlay), + containerEffects: MixOps.merge( + $containerEffects, + other?.$containerEffects, + ), item: MixOps.merge($item, other?.$item), divider: MixOps.merge($divider, other?.$divider), variants: MixOps.mergeVariants($variants, other?.$variants), @@ -1176,6 +1205,7 @@ class RemixMenuStyler extends MixStyler { final spec = RemixMenuSpec( trigger: MixOps.resolve(context, $trigger), overlay: MixOps.resolve(context, $overlay), + containerEffects: MixOps.resolve(context, $containerEffects), item: MixOps.resolve(context, $item), divider: MixOps.resolve(context, $divider), ); @@ -1193,6 +1223,7 @@ class RemixMenuStyler extends MixStyler { properties ..add(DiagnosticsProperty('trigger', $trigger)) ..add(DiagnosticsProperty('overlay', $overlay)) + ..add(DiagnosticsProperty('containerEffects', $containerEffects)) ..add(DiagnosticsProperty('item', $item)) ..add(DiagnosticsProperty('divider', $divider)); } @@ -1201,6 +1232,7 @@ class RemixMenuStyler extends MixStyler { List get props => [ $trigger, $overlay, + $containerEffects, $item, $divider, $animation, diff --git a/packages/remix/lib/src/components/menu/menu_spec.dart b/packages/remix/lib/src/components/menu/menu_spec.dart index 815adbec..09c1d868 100644 --- a/packages/remix/lib/src/components/menu/menu_spec.dart +++ b/packages/remix/lib/src/components/menu/menu_spec.dart @@ -45,6 +45,11 @@ class RemixMenuSpec with _$RemixMenuSpec { @override final StyleSpec overlay; + /// Paint layers behind the popup overlay. + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? containerEffects; + /// Default style spec applied to menu items. @override final StyleSpec item; @@ -57,12 +62,26 @@ class RemixMenuSpec with _$RemixMenuSpec { const RemixMenuSpec({ StyleSpec? trigger, StyleSpec? overlay, + this.containerEffects, StyleSpec? item, StyleSpec? divider, }) : trigger = trigger ?? const StyleSpec(spec: RemixMenuTriggerSpec()), overlay = overlay ?? const StyleSpec(spec: FlexBoxSpec()), item = item ?? const StyleSpec(spec: RemixMenuItemSpec()), divider = divider ?? const StyleSpec(spec: RemixDividerSpec()); + + @override + RemixMenuSpec lerp(RemixMenuSpec? other, double t) { + final generated = super.lerp(other, t); + if (other == null) return generated; + return generated.copyWith( + containerEffects: RemixBoxEffectsSpec.lerpNullable( + containerEffects, + other.containerEffects, + t, + ), + ); + } } /// Resolved visual properties for a [RemixMenuItem]. diff --git a/packages/remix/lib/src/components/menu/menu_widget.dart b/packages/remix/lib/src/components/menu/menu_widget.dart index 2321de81..c4d83e20 100644 --- a/packages/remix/lib/src/components/menu/menu_widget.dart +++ b/packages/remix/lib/src/components/menu/menu_widget.dart @@ -218,8 +218,10 @@ class _RemixMenuState extends State> { style: style, styleSpec: widget.styleSpec, builder: (context, spec) { - return ColumnBox( + return RemixFlexBoxWithEffects( styleSpec: spec.overlay, + direction: Axis.vertical, + containerEffects: spec.containerEffects, children: widget.items.map((item) { // Pattern matching ensures exhaustiveness return switch (item) { diff --git a/packages/remix/lib/src/components/popover/fortal_popover_styles.dart b/packages/remix/lib/src/components/popover/fortal_popover_styles.dart index 83fed1d7..8fd5f07c 100644 --- a/packages/remix/lib/src/components/popover/fortal_popover_styles.dart +++ b/packages/remix/lib/src/components/popover/fortal_popover_styles.dart @@ -1,28 +1,39 @@ part of 'popover.dart'; +/// Fortal popover size presets matching Radix Themes 3.3.0. +enum FortalPopoverSize { size1, size2, size3, size4 } + /// Fortal-themed preset for [RemixPopover]. @MixWidget( name: 'FortalPopover', target: RemixPopover.new, - factoryParameters: .only({}), + factoryParameters: .only({'size'}), ) -RemixPopoverStyler fortalPopoverStyler() { +RemixPopoverStyler fortalPopoverStyler({ + FortalPopoverSize size = FortalPopoverSize.size2, +}) { + final radius = switch (size) { + FortalPopoverSize.size1 || + FortalPopoverSize.size2 => FortalTokens.radius4(), + FortalPopoverSize.size3 || + FortalPopoverSize.size4 => FortalTokens.radius5(), + }; + final padding = switch (size) { + FortalPopoverSize.size1 => FortalTokens.space3(), + FortalPopoverSize.size2 => FortalTokens.space4(), + FortalPopoverSize.size3 => FortalTokens.space5(), + FortalPopoverSize.size4 => FortalTokens.space6(), + }; + return RemixPopoverStyler() - .paddingAll(FortalTokens.space4()) - .marginTop(FortalTokens.space2()) - .constraints(BoxConstraintsMix(maxWidth: 360)) - .borderAll( - color: FortalTokens.gray6(), - width: FortalTokens.borderWidth1(), + .paddingAll(padding) + .borderRadiusAll(radius) + .color(FortalTokens.colorPanel()) + .decoration( + BoxDecorationMix.create(boxShadow: FortalTokens.shadow5.mix()), ) - .borderRadiusAll(FortalTokens.radius3()) - .backgroundColor(FortalTokens.gray1()) - .shadow( - BoxShadowMix() - .color(FortalTokens.blackA3()) - .offset(x: 0, y: 4) - .blurRadius(12) - .spreadRadius(0), + .containerEffects( + RemixBoxEffectsMix(backdropBlur: FortalTokens.panelBlur()), ); } diff --git a/packages/remix/lib/src/components/popover/popover.dart b/packages/remix/lib/src/components/popover/popover.dart index e861be47..8b5d7635 100644 --- a/packages/remix/lib/src/components/popover/popover.dart +++ b/packages/remix/lib/src/components/popover/popover.dart @@ -7,6 +7,7 @@ import 'package:mix_annotations/mix_annotations.dart'; import 'package:naked_ui/naked_ui.dart'; import '../../fortal/fortal.dart'; +import '../../rendering/remix_box_effects.dart'; import '../../utilities/remix_style.dart'; part 'fortal_popover_styles.dart'; diff --git a/packages/remix/lib/src/components/popover/popover.g.dart b/packages/remix/lib/src/components/popover/popover.g.dart index e73bbd38..cee260cf 100644 --- a/packages/remix/lib/src/components/popover/popover.g.dart +++ b/packages/remix/lib/src/components/popover/popover.g.dart @@ -8,22 +8,36 @@ part of 'popover.dart'; mixin _$RemixPopoverSpec implements Spec, Diagnosticable { StyleSpec get container; + RemixBoxEffectsSpec? get containerEffects; @override Type get type => RemixPopoverSpec; @override - RemixPopoverSpec copyWith({StyleSpec? container}) { - return RemixPopoverSpec(container: container ?? this.container); + RemixPopoverSpec copyWith({ + StyleSpec? container, + RemixBoxEffectsSpec? containerEffects, + }) { + return RemixPopoverSpec( + container: container ?? this.container, + containerEffects: containerEffects ?? this.containerEffects, + ); } @override RemixPopoverSpec lerp(RemixPopoverSpec? other, double t) { - return RemixPopoverSpec(container: container.lerp(other?.container, t)); + return RemixPopoverSpec( + container: container.lerp(other?.container, t), + containerEffects: MixOps.lerpSnap( + containerEffects, + other?.containerEffects, + t, + ), + ); } @override - List get props => [container]; + List get props => [container, containerEffects]; @override bool operator ==(Object other) { @@ -64,7 +78,9 @@ mixin _$RemixPopoverSpec implements Spec, Diagnosticable { @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { - properties.add(DiagnosticsProperty('container', container)); + properties + ..add(DiagnosticsProperty('container', container)) + ..add(DiagnosticsProperty('containerEffects', containerEffects)); } } @@ -81,6 +97,7 @@ typedef _$RemixPopoverSpecMethods = _$RemixPopoverSpec; // ignore: unused_elemen class FortalPopover extends StatelessWidget { const FortalPopover({ super.key, + this.size = FortalPopoverSize.size2, required this.popoverChild, required this.child, this.positioning = const OverlayPositionConfig(), @@ -97,6 +114,8 @@ class FortalPopover extends StatelessWidget { this.excludeSemantics = false, }); + final FortalPopoverSize size; + final Widget popoverChild; final Widget child; @@ -129,7 +148,7 @@ class FortalPopover extends StatelessWidget { Widget build(BuildContext context) { return RemixPopover( key: this.key, - style: fortalPopoverStyler(), + style: fortalPopoverStyler(size: this.size), popoverChild: this.popoverChild, child: this.child, positioning: this.positioning, @@ -155,21 +174,26 @@ class FortalPopover extends StatelessWidget { class RemixPopoverStyler extends MixStyler with RemixBoxStylerMixin { final Prop>? $container; + final Prop? $containerEffects; const RemixPopoverStyler.create({ Prop>? container, + Prop? containerEffects, super.variants, super.modifier, super.animation, - }) : $container = container; + }) : $container = container, + $containerEffects = containerEffects; RemixPopoverStyler({ BoxStyler? container, + RemixBoxEffectsMix? containerEffects, AnimationConfig? animation, WidgetModifierConfig? modifier, List>? variants, }) : this.create( container: Prop.maybeMix(container), + containerEffects: Prop.maybeMix(containerEffects), variants: variants, modifier: modifier, animation: animation, @@ -177,6 +201,8 @@ class RemixPopoverStyler extends MixStyler factory RemixPopoverStyler.container(BoxStyler value) => RemixPopoverStyler().container(value); + factory RemixPopoverStyler.containerEffects(RemixBoxEffectsMix value) => + RemixPopoverStyler().containerEffects(value); factory RemixPopoverStyler.alignment(AlignmentGeometry value) => RemixPopoverStyler().alignment(value); factory RemixPopoverStyler.padding(EdgeInsetsGeometryMix value) => @@ -654,6 +680,11 @@ class RemixPopoverStyler extends MixStyler return merge(RemixPopoverStyler(container: value)); } + /// Sets the containerEffects. + RemixPopoverStyler containerEffects(RemixBoxEffectsMix value) { + return merge(RemixPopoverStyler(containerEffects: value)); + } + /// Sets the animation configuration. @override RemixPopoverStyler animate(AnimationConfig value) { @@ -682,6 +713,10 @@ class RemixPopoverStyler extends MixStyler RemixPopoverStyler merge(RemixPopoverStyler? other) { return RemixPopoverStyler.create( container: MixOps.merge($container, other?.$container), + containerEffects: MixOps.merge( + $containerEffects, + other?.$containerEffects, + ), variants: MixOps.mergeVariants($variants, other?.$variants), modifier: MixOps.mergeModifier($modifier, other?.$modifier), animation: MixOps.mergeAnimation($animation, other?.$animation), @@ -693,6 +728,7 @@ class RemixPopoverStyler extends MixStyler StyleSpec resolve(BuildContext context) { final spec = RemixPopoverSpec( container: MixOps.resolve(context, $container), + containerEffects: MixOps.resolve(context, $containerEffects), ); return StyleSpec( @@ -705,9 +741,17 @@ class RemixPopoverStyler extends MixStyler @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('container', $container)); + properties + ..add(DiagnosticsProperty('container', $container)) + ..add(DiagnosticsProperty('containerEffects', $containerEffects)); } @override - List get props => [$container, $animation, $modifier, $variants]; + List get props => [ + $container, + $containerEffects, + $animation, + $modifier, + $variants, + ]; } diff --git a/packages/remix/lib/src/components/popover/popover_spec.dart b/packages/remix/lib/src/components/popover/popover_spec.dart index e238e403..dd8db92f 100644 --- a/packages/remix/lib/src/components/popover/popover_spec.dart +++ b/packages/remix/lib/src/components/popover/popover_spec.dart @@ -6,7 +6,23 @@ class RemixPopoverSpec with _$RemixPopoverSpec { @override @MixableField(forwardStyler: true) final StyleSpec container; + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? containerEffects; - const RemixPopoverSpec({StyleSpec? container}) + const RemixPopoverSpec({StyleSpec? container, this.containerEffects}) : container = container ?? const StyleSpec(spec: BoxSpec()); + + @override + RemixPopoverSpec lerp(RemixPopoverSpec? other, double t) { + final generated = super.lerp(other, t); + if (other == null) return generated; + return generated.copyWith( + containerEffects: RemixBoxEffectsSpec.lerpNullable( + containerEffects, + other.containerEffects, + t, + ), + ); + } } diff --git a/packages/remix/lib/src/components/popover/popover_widget.dart b/packages/remix/lib/src/components/popover/popover_widget.dart index 2fdb8592..3ea7d9ad 100644 --- a/packages/remix/lib/src/components/popover/popover_widget.dart +++ b/packages/remix/lib/src/components/popover/popover_widget.dart @@ -85,7 +85,11 @@ class RemixPopover extends StatelessWidget { builder: (context, spec) { return NakedPopover( popoverBuilder: (context, info) { - return Box(styleSpec: spec.container, child: popoverChild); + return RemixBoxWithEffects( + styleSpec: spec.container, + containerEffects: spec.containerEffects, + child: popoverChild, + ); }, positioning: positioning, consumeOutsideTaps: consumeOutsideTaps, diff --git a/packages/remix/lib/src/components/progress/fortal_progress_styles.dart b/packages/remix/lib/src/components/progress/fortal_progress_styles.dart index 471fb1f5..c85370b3 100644 --- a/packages/remix/lib/src/components/progress/fortal_progress_styles.dart +++ b/packages/remix/lib/src/components/progress/fortal_progress_styles.dart @@ -4,7 +4,7 @@ part of 'progress.dart'; enum FortalProgressSize { size1, size2, size3 } /// Fortal progress color variants. -enum FortalProgressVariant { surface, soft } +enum FortalProgressVariant { classic, surface, soft } /// Fortal-themed preset for [RemixProgress]. @MixWidget( @@ -15,83 +15,132 @@ enum FortalProgressVariant { surface, soft } RemixProgressStyler fortalProgressStyler({ FortalProgressVariant variant = .surface, FortalProgressSize size = .size2, + bool highContrast = false, }) { return switch (variant) { - .surface => _fortalProgressSurfaceStyler(size), - .soft => _fortalProgressSoftStyler(size), + .classic => _fortalProgressClassicStyler(size, highContrast: highContrast), + .surface => _fortalProgressSurfaceStyler(size, highContrast: highContrast), + .soft => _fortalProgressSoftStyler(size, highContrast: highContrast), }; } RemixProgressStyler _fortalProgressBaseStyler(FortalProgressSize size) { - return RemixProgressStyler() - .width(.infinity) - .merge(_fortalProgressSizeStyler(size)); + final metrics = _fortalProgressMetrics(size); + return RemixProgressStyler( + container: .width(.infinity) + .height(metrics.height) + .borderRadiusAll(metrics.radius) + .clipBehavior(.antiAlias), + track: .width(.infinity).height(metrics.height), + indicator: .height(metrics.height).borderRadiusAll(metrics.radius), + trackEffects: RemixBoxEffectsMix( + behindContent: _fortalProgressLayer(), + overContent: _fortalProgressLayer(), + ), + indicatorEffects: RemixBoxEffectsMix( + behindContent: _fortalProgressLayer(), + overContent: _fortalProgressLayer(), + ), + ); } -RemixProgressStyler _fortalProgressSurfaceStyler([ - FortalProgressSize size = .size2, -]) { +RemixProgressStyler _fortalProgressClassicStyler( + FortalProgressSize size, { + required bool highContrast, +}) { return _fortalProgressBaseStyler(size) - .foregroundDecoration( - BoxDecorationMix() - .border( - BoxBorderMix.all(BorderSideMix().color(FortalTokens.grayA5())), - ) - .borderRadius( - BorderRadiusGeometryMix.all(FortalTokens.radiusFull()), - ), + .trackColor(FortalTokens.grayA3()) + .trackEffects(RemixBoxEffectsMix(behindContent: _fortalProgressLayer())) + .trackEffects( + RemixBoxEffectsMix( + overContent: _fortalProgressLayer( + shadowToken: FortalTokens.shadow1Layers, + ), + ), ) - .track(BoxStyler().color(FortalTokens.gray3()).width(.infinity)) - .indicator(BoxStyler().color(FortalTokens.accentIndicator())); + .indicatorEffects( + RemixBoxEffectsMix(behindContent: _fortalProgressLayer()), + ) + .indicatorColor( + highContrast ? FortalTokens.accent12() : FortalTokens.accentTrack(), + ); } -RemixProgressStyler _fortalProgressSoftStyler([ - FortalProgressSize size = .size2, -]) { +RemixProgressStyler _fortalProgressSurfaceStyler( + FortalProgressSize size, { + required bool highContrast, +}) { return _fortalProgressBaseStyler(size) - .track( - BoxStyler() - .color(FortalTokens.gray4()) - .borderRadiusAll(FortalTokens.radiusFull()) - .width(.infinity), + .trackColor(FortalTokens.grayA3()) + .trackEffects(RemixBoxEffectsMix(behindContent: _fortalProgressLayer())) + .trackEffects( + RemixBoxEffectsMix( + overContent: _fortalProgressLayer( + shadows: [ + RemixBoxShadowMix( + kind: .inset, + color: FortalTokens.grayA4(), + spreadRadius: 1, + ), + ], + ), + ), ) - .indicator( - BoxStyler() - .color(FortalTokens.accent9()) - .borderRadiusAll(FortalTokens.radiusFull()), + .indicatorEffects( + RemixBoxEffectsMix(behindContent: _fortalProgressLayer()), + ) + .indicatorColor( + highContrast ? FortalTokens.accent12() : FortalTokens.accentTrack(), ); } -RemixProgressStyler _fortalProgressSizeStyler(FortalProgressSize size) { - return switch (size) { - .size1 => - RemixProgressStyler() - .height(4.0) - .track( - BoxStyler().height(4.0).borderRadiusAll(FortalTokens.radius1()), - ) - .indicator( - BoxStyler().height(4.0).borderRadiusAll(FortalTokens.radius1()), - ), - .size2 => - RemixProgressStyler() - .height(8.0) - .track( - BoxStyler().height(8.0).borderRadiusAll(FortalTokens.radius2()), - ) - .indicator( - BoxStyler().height(8.0).borderRadiusAll(FortalTokens.radius2()), - ), - .size3 => - RemixProgressStyler() - .height(12.0) - .track( - BoxStyler().height(12.0).borderRadiusAll(FortalTokens.radius3()), - ) - .indicator( - BoxStyler().height(12.0).borderRadiusAll(FortalTokens.radius3()), +RemixProgressStyler _fortalProgressSoftStyler( + FortalProgressSize size, { + required bool highContrast, +}) { + return _fortalProgressBaseStyler(size) + .trackColor(FortalTokens.grayA4()) + .track( + .foregroundDecoration(BoxDecorationMix(color: FortalTokens.whiteA1())), + ) + .trackEffects(RemixBoxEffectsMix(behindContent: _fortalProgressLayer())) + .trackEffects(RemixBoxEffectsMix(overContent: _fortalProgressLayer())) + .indicatorEffects( + RemixBoxEffectsMix(behindContent: _fortalProgressLayer()), + ) + .indicatorEffects(RemixBoxEffectsMix(overContent: _fortalProgressLayer())) + .indicatorColor( + highContrast ? FortalTokens.accent12() : FortalTokens.accent8(), + ) + .indicator( + .foregroundDecoration( + BoxDecorationMix( + color: highContrast ? null : FortalTokens.accentA5(), ), - }; + ), + ); } +({double height, Radius radius}) _fortalProgressMetrics( + FortalProgressSize size, +) => switch (size) { + .size1 => ( + height: FortalTokens.space1(), + radius: FortalTokens.progressRadius1(), + ), + .size2 => ( + height: FortalTokens.progressHeight2(), + radius: FortalTokens.progressRadius2(), + ), + .size3 => ( + height: FortalTokens.space2(), + radius: FortalTokens.progressRadius3(), + ), +}; + +RemixBoxEffectLayerMix _fortalProgressLayer({ + List? shadows, + RemixBoxShadowListToken? shadowToken, +}) => RemixBoxEffectLayerMix(shadows: shadows, shadowToken: shadowToken); + /// Fortal-themed preset for [RemixProgress]. diff --git a/packages/remix/lib/src/components/progress/progress.dart b/packages/remix/lib/src/components/progress/progress.dart index bb20a960..cd9dbdde 100644 --- a/packages/remix/lib/src/components/progress/progress.dart +++ b/packages/remix/lib/src/components/progress/progress.dart @@ -7,6 +7,7 @@ import 'package:mix_annotations/mix_annotations.dart'; import '../../utilities/remix_style.dart'; import '../../fortal/fortal.dart'; +import '../../rendering/remix_box_effects.dart'; part 'progress_spec.dart'; part 'progress_style.dart'; diff --git a/packages/remix/lib/src/components/progress/progress.g.dart b/packages/remix/lib/src/components/progress/progress.g.dart index 93a2185f..72f77bf9 100644 --- a/packages/remix/lib/src/components/progress/progress.g.dart +++ b/packages/remix/lib/src/components/progress/progress.g.dart @@ -11,6 +11,8 @@ mixin _$RemixProgressSpec implements Spec, Diagnosticable { StyleSpec get track; StyleSpec get indicator; StyleSpec get trackContainer; + RemixBoxEffectsSpec? get trackEffects; + RemixBoxEffectsSpec? get indicatorEffects; @override Type get type => RemixProgressSpec; @@ -21,12 +23,16 @@ mixin _$RemixProgressSpec implements Spec, Diagnosticable { StyleSpec? track, StyleSpec? indicator, StyleSpec? trackContainer, + RemixBoxEffectsSpec? trackEffects, + RemixBoxEffectsSpec? indicatorEffects, }) { return RemixProgressSpec( container: container ?? this.container, track: track ?? this.track, indicator: indicator ?? this.indicator, trackContainer: trackContainer ?? this.trackContainer, + trackEffects: trackEffects ?? this.trackEffects, + indicatorEffects: indicatorEffects ?? this.indicatorEffects, ); } @@ -37,11 +43,24 @@ mixin _$RemixProgressSpec implements Spec, Diagnosticable { track: track.lerp(other?.track, t), indicator: indicator.lerp(other?.indicator, t), trackContainer: trackContainer.lerp(other?.trackContainer, t), + trackEffects: MixOps.lerpSnap(trackEffects, other?.trackEffects, t), + indicatorEffects: MixOps.lerpSnap( + indicatorEffects, + other?.indicatorEffects, + t, + ), ); } @override - List get props => [container, track, indicator, trackContainer]; + List get props => [ + container, + track, + indicator, + trackContainer, + trackEffects, + indicatorEffects, + ]; @override bool operator ==(Object other) { @@ -86,7 +105,9 @@ mixin _$RemixProgressSpec implements Spec, Diagnosticable { ..add(DiagnosticsProperty('container', container)) ..add(DiagnosticsProperty('track', track)) ..add(DiagnosticsProperty('indicator', indicator)) - ..add(DiagnosticsProperty('trackContainer', trackContainer)); + ..add(DiagnosticsProperty('trackContainer', trackContainer)) + ..add(DiagnosticsProperty('trackEffects', trackEffects)) + ..add(DiagnosticsProperty('indicatorEffects', indicatorEffects)); } } @@ -108,6 +129,12 @@ class FortalProgress extends StatelessWidget { required this.value, }); + const FortalProgress.classic({ + super.key, + this.size = .size2, + required this.value, + }) : variant = FortalProgressVariant.classic; + const FortalProgress.surface({ super.key, this.size = .size2, @@ -147,25 +174,33 @@ class RemixProgressStyler final Prop>? $track; final Prop>? $indicator; final Prop>? $trackContainer; + final Prop? $trackEffects; + final Prop? $indicatorEffects; const RemixProgressStyler.create({ Prop>? container, Prop>? track, Prop>? indicator, Prop>? trackContainer, + Prop? trackEffects, + Prop? indicatorEffects, super.variants, super.modifier, super.animation, }) : $container = container, $track = track, $indicator = indicator, - $trackContainer = trackContainer; + $trackContainer = trackContainer, + $trackEffects = trackEffects, + $indicatorEffects = indicatorEffects; RemixProgressStyler({ BoxStyler? container, BoxStyler? track, BoxStyler? indicator, BoxStyler? trackContainer, + RemixBoxEffectsMix? trackEffects, + RemixBoxEffectsMix? indicatorEffects, AnimationConfig? animation, WidgetModifierConfig? modifier, List>? variants, @@ -174,6 +209,8 @@ class RemixProgressStyler track: Prop.maybeMix(track), indicator: Prop.maybeMix(indicator), trackContainer: Prop.maybeMix(trackContainer), + trackEffects: Prop.maybeMix(trackEffects), + indicatorEffects: Prop.maybeMix(indicatorEffects), variants: variants, modifier: modifier, animation: animation, @@ -187,6 +224,10 @@ class RemixProgressStyler RemixProgressStyler().indicator(value); factory RemixProgressStyler.trackContainer(BoxStyler value) => RemixProgressStyler().trackContainer(value); + factory RemixProgressStyler.trackEffects(RemixBoxEffectsMix value) => + RemixProgressStyler().trackEffects(value); + factory RemixProgressStyler.indicatorEffects(RemixBoxEffectsMix value) => + RemixProgressStyler().indicatorEffects(value); factory RemixProgressStyler.alignment(AlignmentGeometry value) => RemixProgressStyler().alignment(value); factory RemixProgressStyler.padding(EdgeInsetsGeometryMix value) => @@ -682,6 +723,16 @@ class RemixProgressStyler return merge(RemixProgressStyler(trackContainer: value)); } + /// Sets the trackEffects. + RemixProgressStyler trackEffects(RemixBoxEffectsMix value) { + return merge(RemixProgressStyler(trackEffects: value)); + } + + /// Sets the indicatorEffects. + RemixProgressStyler indicatorEffects(RemixBoxEffectsMix value) { + return merge(RemixProgressStyler(indicatorEffects: value)); + } + /// Sets the animation configuration. @override RemixProgressStyler animate(AnimationConfig value) { @@ -713,6 +764,11 @@ class RemixProgressStyler track: MixOps.merge($track, other?.$track), indicator: MixOps.merge($indicator, other?.$indicator), trackContainer: MixOps.merge($trackContainer, other?.$trackContainer), + trackEffects: MixOps.merge($trackEffects, other?.$trackEffects), + indicatorEffects: MixOps.merge( + $indicatorEffects, + other?.$indicatorEffects, + ), variants: MixOps.mergeVariants($variants, other?.$variants), modifier: MixOps.mergeModifier($modifier, other?.$modifier), animation: MixOps.mergeAnimation($animation, other?.$animation), @@ -727,6 +783,8 @@ class RemixProgressStyler track: MixOps.resolve(context, $track), indicator: MixOps.resolve(context, $indicator), trackContainer: MixOps.resolve(context, $trackContainer), + trackEffects: MixOps.resolve(context, $trackEffects), + indicatorEffects: MixOps.resolve(context, $indicatorEffects), ); return StyleSpec( @@ -743,7 +801,9 @@ class RemixProgressStyler ..add(DiagnosticsProperty('container', $container)) ..add(DiagnosticsProperty('track', $track)) ..add(DiagnosticsProperty('indicator', $indicator)) - ..add(DiagnosticsProperty('trackContainer', $trackContainer)); + ..add(DiagnosticsProperty('trackContainer', $trackContainer)) + ..add(DiagnosticsProperty('trackEffects', $trackEffects)) + ..add(DiagnosticsProperty('indicatorEffects', $indicatorEffects)); } @override @@ -752,6 +812,8 @@ class RemixProgressStyler $track, $indicator, $trackContainer, + $trackEffects, + $indicatorEffects, $animation, $modifier, $variants, diff --git a/packages/remix/lib/src/components/progress/progress_spec.dart b/packages/remix/lib/src/components/progress/progress_spec.dart index 3bec0686..f77a1fda 100644 --- a/packages/remix/lib/src/components/progress/progress_spec.dart +++ b/packages/remix/lib/src/components/progress/progress_spec.dart @@ -13,13 +13,41 @@ class RemixProgressSpec with _$RemixProgressSpec { @override final StyleSpec trackContainer; + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? trackEffects; + + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? indicatorEffects; + const RemixProgressSpec({ StyleSpec? container, StyleSpec? track, StyleSpec? indicator, StyleSpec? trackContainer, + this.trackEffects, + this.indicatorEffects, }) : container = container ?? const StyleSpec(spec: BoxSpec()), track = track ?? const StyleSpec(spec: BoxSpec()), indicator = indicator ?? const StyleSpec(spec: BoxSpec()), trackContainer = trackContainer ?? const StyleSpec(spec: BoxSpec()); + + @override + RemixProgressSpec lerp(RemixProgressSpec? other, double t) { + final generated = super.lerp(other, t); + if (other == null) return generated; + return generated.copyWith( + trackEffects: RemixBoxEffectsSpec.lerpNullable( + trackEffects, + other.trackEffects, + t, + ), + indicatorEffects: RemixBoxEffectsSpec.lerpNullable( + indicatorEffects, + other.indicatorEffects, + t, + ), + ); + } } diff --git a/packages/remix/lib/src/components/progress/progress_widget.dart b/packages/remix/lib/src/components/progress/progress_widget.dart index c3a6f300..dbc18835 100644 --- a/packages/remix/lib/src/components/progress/progress_widget.dart +++ b/packages/remix/lib/src/components/progress/progress_widget.dart @@ -47,7 +47,10 @@ class RemixProgress extends StatelessWidget { child: Stack( children: [ // Track background - Box(styleSpec: spec.track), + RemixBoxWithEffects( + styleSpec: spec.track, + containerEffects: spec.trackEffects, + ), // Indicator foreground based on value LayoutBuilder( builder: (context, constraints) { @@ -55,7 +58,10 @@ class RemixProgress extends StatelessWidget { return SizedBox( width: biggestSize.width * value.clamp(0.0, 1.0), - child: Box(styleSpec: spec.indicator), + child: RemixBoxWithEffects( + styleSpec: spec.indicator, + containerEffects: spec.indicatorEffects, + ), ); }, ), diff --git a/packages/remix/lib/src/components/radio/fortal_radio_styles.dart b/packages/remix/lib/src/components/radio/fortal_radio_styles.dart index 57041ee4..0b789122 100644 --- a/packages/remix/lib/src/components/radio/fortal_radio_styles.dart +++ b/packages/remix/lib/src/components/radio/fortal_radio_styles.dart @@ -14,6 +14,9 @@ enum FortalRadioSize { /// Fortal radio color variants. enum FortalRadioVariant { + /// Raised treatment with Radix's classic shadow and gradient layers. + classic, + /// Surface treatment with neutral border. surface, @@ -30,93 +33,259 @@ enum FortalRadioVariant { RemixRadioStyler fortalRadioStyler({ FortalRadioVariant variant = .surface, FortalRadioSize size = .size2, + bool highContrast = false, }) { return switch (variant) { - .surface => _fortalRadioSurfaceStyler(size), - .soft => _fortalRadioSoftStyler(size), + .classic => _fortalRadioClassicStyler(size, highContrast: highContrast), + .surface => _fortalRadioSurfaceStyler(size, highContrast: highContrast), + .soft => _fortalRadioSoftStyler(size, highContrast: highContrast), }; } RemixRadioStyler _fortalRadioBaseStyler(FortalRadioSize size) { - return RemixRadioStyler() - .onFocused( - RemixRadioStyler().borderAll( - color: FortalTokens.focusA8(), - width: FortalTokens.focusRingWidth(), + final metrics = _fortalRadioMetrics(size); + return RemixRadioStyler( + container: .size( + metrics.size, + metrics.size, + ).alignment(.center).borderRadiusAll(FortalTokens.radiusCircle()), + indicator: .size( + metrics.indicatorSize, + metrics.indicatorSize, + ).borderRadiusAll(FortalTokens.radiusCircle()), + containerEffects: RemixBoxEffectsMix( + behindContent: _fortalRadioLayer(), + overContent: _fortalRadioLayer(), + ), + ).onFocused( + .containerEffects( + RemixBoxEffectsMix( + outline: BorderSideMix( + color: FortalTokens.focus8(), + width: 2, + strokeAlign: BorderSide.strokeAlignInside, + ), + outlineOffset: 2, + ), + ), + ); +} + +RemixRadioStyler _fortalRadioClassicStyler( + FortalRadioSize size, { + required bool highContrast, +}) { + final selectedColor = highContrast + ? FortalTokens.accent12() + : FortalTokens.accentIndicator(); + return _fortalRadioBaseStyler(size) + .color(FortalTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix( + behindContent: _fortalRadioLayer( + shadowToken: FortalTokens.shadow1Layers, + ), + ), + ) + .containerEffects( + RemixBoxEffectsMix( + overContent: _fortalRadioInsetRing(FortalTokens.gray7()), ), ) - .merge(_fortalRadioSizeStyler(size)); + .indicatorColor( + highContrast ? FortalTokens.accent1() : FortalTokens.accentContrast(), + ) + .onSelected( + .color(selectedColor) + .containerEffects( + RemixBoxEffectsMix( + behindContent: _fortalRadioLayer( + gradients: [ + RemixLinearGradientMix( + colors: [ + FortalTokens.whiteA3(), + Colors.transparent, + FortalTokens.blackA3(), + ], + ), + ], + shadows: [ + RemixBoxShadowMix( + kind: .inset, + color: FortalTokens.whiteA4(), + offset: const Offset(0, 0.5), + blurRadius: 0.5, + ), + RemixBoxShadowMix( + kind: .inset, + color: FortalTokens.blackA4(), + offset: const Offset(0, -0.5), + blurRadius: 0.5, + ), + ], + ), + ), + ) + .indicatorColor( + highContrast + ? FortalTokens.accent1() + : FortalTokens.accentContrast(), + ), + ) + .onDisabled( + .color(FortalTokens.grayA3()) + .containerEffects( + RemixBoxEffectsMix( + behindContent: _fortalRadioLayer( + shadowToken: FortalTokens.shadow1Layers, + ), + ), + ) + .containerEffects( + RemixBoxEffectsMix( + overContent: _fortalRadioLayer(shadows: const []), + ), + ) + .indicatorColor(FortalTokens.grayA8()), + ); } -RemixRadioStyler _fortalRadioSurfaceStyler([FortalRadioSize size = .size2]) { +RemixRadioStyler _fortalRadioSurfaceStyler( + FortalRadioSize size, { + required bool highContrast, +}) { return _fortalRadioBaseStyler(size) - .fillColor(FortalTokens.colorSurface()) - .borderAll( - color: FortalTokens.gray6(), - width: FortalTokens.borderWidth1(), + .color(FortalTokens.colorSurface()) + .containerEffects(RemixBoxEffectsMix(behindContent: _fortalRadioLayer())) + .containerEffects( + RemixBoxEffectsMix( + overContent: _fortalRadioInsetRing(FortalTokens.grayA7()), + ), ) - .borderRadiusAll(FortalTokens.radiusFull()) .indicator( - BoxStyler() - .color(FortalTokens.accent9()) - .borderRadiusAll(FortalTokens.radiusFull()), + .color( + FortalTokens.accent9(), + ).borderRadiusAll(FortalTokens.radiusCircle()), ) .onSelected( - RemixRadioStyler() - .fillColor(FortalTokens.accent9()) - .borderAll( - color: FortalTokens.accent9(), - width: FortalTokens.borderWidth1(), + .color( + highContrast + ? FortalTokens.accent12() + : FortalTokens.accentIndicator(), + ) + .containerEffects( + RemixBoxEffectsMix(behindContent: _fortalRadioLayer()), + ) + .containerEffects( + RemixBoxEffectsMix( + overContent: _fortalRadioLayer(shadows: const []), + ), ) - .indicator(BoxStyler().color(Colors.white)), + .indicatorColor( + highContrast + ? FortalTokens.accent1() + : FortalTokens.accentContrast(), + ), ) .onDisabled( - RemixRadioStyler() - .fillColor(FortalTokens.gray3()) - .borderAll( - color: FortalTokens.gray6(), - width: FortalTokens.borderWidth1(), + .color(FortalTokens.grayA3()) + .containerEffects( + RemixBoxEffectsMix(behindContent: _fortalRadioLayer()), ) - .indicator(BoxStyler().color(FortalTokens.gray9())), + .containerEffects( + RemixBoxEffectsMix( + overContent: _fortalRadioInsetRing(FortalTokens.grayA6()), + ), + ) + .indicatorColor(FortalTokens.grayA8()), ); } -RemixRadioStyler _fortalRadioSoftStyler([FortalRadioSize size = .size2]) { +RemixRadioStyler _fortalRadioSoftStyler( + FortalRadioSize size, { + required bool highContrast, +}) { return _fortalRadioBaseStyler(size) - .fillColor(FortalTokens.accentA4()) - .borderRadiusAll(FortalTokens.radiusFull()) + .color(FortalTokens.accentA4()) + .containerEffects(RemixBoxEffectsMix(behindContent: _fortalRadioLayer())) .indicator( - BoxStyler() - .color(FortalTokens.accent11()) - .borderRadiusAll(FortalTokens.radiusFull()), + .color( + highContrast ? FortalTokens.accent12() : FortalTokens.accent11(), + ).borderRadiusAll(FortalTokens.radiusCircle()), ) .onSelected( - RemixRadioStyler() - .fillColor(FortalTokens.accentA4()) - .indicator(BoxStyler().color(FortalTokens.accent11())), + .color(FortalTokens.accentA4()) + .containerEffects( + RemixBoxEffectsMix(behindContent: _fortalRadioLayer()), + ) + .indicator( + .color( + highContrast + ? FortalTokens.accent12() + : FortalTokens.accent11(), + ), + ), ) .onDisabled( - RemixRadioStyler() - .fillColor(FortalTokens.gray3()) - .indicator(BoxStyler().color(FortalTokens.gray7())), + .color(FortalTokens.grayA3()) + .containerEffects( + RemixBoxEffectsMix(behindContent: _fortalRadioLayer()), + ) + .indicatorColor(FortalTokens.grayA8()), ); } -RemixRadioStyler _fortalRadioSizeStyler(FortalRadioSize size) { - return switch (size) { - .size1 => RemixRadioStyler( - container: BoxStyler().width(16.0).height(16.0).alignment(.center), - indicator: BoxStyler().width(6.0).height(6.0), - ), - .size2 => RemixRadioStyler( - container: BoxStyler().width(20.0).height(20.0).alignment(.center), - indicator: BoxStyler().width(8.0).height(8.0), - ), - .size3 => RemixRadioStyler( - container: BoxStyler().width(24.0).height(24.0).alignment(.center), - indicator: BoxStyler().width(10.0).height(10.0), - ), - }; -} +({double size, double indicatorSize}) _fortalRadioMetrics( + FortalRadioSize size, +) => switch (size) { + .size1 => ( + size: FortalTokens.checkboxSize1(), + indicatorSize: FortalTokens.radioIndicatorSize1(), + ), + .size2 => ( + size: FortalTokens.space4(), + indicatorSize: FortalTokens.radioIndicatorSize2(), + ), + .size3 => ( + size: FortalTokens.checkboxSize3(), + indicatorSize: FortalTokens.radioIndicatorSize3(), + ), +}; + +RemixBoxEffectLayerMix _fortalRadioInsetRing(Color color) => _fortalRadioLayer( + shadows: [RemixBoxShadowMix(kind: .inset, color: color, spreadRadius: 1)], +); + +RemixBoxEffectLayerMix _fortalRadioLayer({ + List? gradients, + List? shadows, + RemixBoxShadowListToken? shadowToken, +}) => RemixBoxEffectLayerMix( + gradients: gradients, + shadows: shadows, + shadowToken: shadowToken, +); /// Fortal-themed preset for [RemixRadio]. +/// Typed selection scope for a set of [FortalRadio] widgets. +class FortalRadioGroup extends StatelessWidget { + const FortalRadioGroup({ + super.key, + required this.value, + this.onChanged, + required this.child, + }); + + /// The currently selected group value. + final T? value; + + /// Called when a radio requests a new selection. Null disables the group. + final ValueChanged? onChanged; + + /// The subtree containing the group's radio items and labels. + final Widget child; + + @override + Widget build(BuildContext context) => + RemixRadioGroup(groupValue: value, onChanged: onChanged, child: child); +} diff --git a/packages/remix/lib/src/components/radio/radio.dart b/packages/remix/lib/src/components/radio/radio.dart index 9b8d7660..c902c048 100644 --- a/packages/remix/lib/src/components/radio/radio.dart +++ b/packages/remix/lib/src/components/radio/radio.dart @@ -9,6 +9,7 @@ import 'package:naked_ui/naked_ui.dart'; import '../../utilities/remix_style.dart'; import '../../fortal/fortal.dart'; +import '../../rendering/remix_box_effects.dart'; import '../../utilities/selected_mixin.dart'; part 'radio_group_widget.dart'; diff --git a/packages/remix/lib/src/components/radio/radio.g.dart b/packages/remix/lib/src/components/radio/radio.g.dart index 0e1a5cfe..3985cc72 100644 --- a/packages/remix/lib/src/components/radio/radio.g.dart +++ b/packages/remix/lib/src/components/radio/radio.g.dart @@ -9,6 +9,7 @@ part of 'radio.dart'; mixin _$RemixRadioSpec implements Spec, Diagnosticable { StyleSpec get container; StyleSpec get indicator; + RemixBoxEffectsSpec? get containerEffects; @override Type get type => RemixRadioSpec; @@ -17,10 +18,12 @@ mixin _$RemixRadioSpec implements Spec, Diagnosticable { RemixRadioSpec copyWith({ StyleSpec? container, StyleSpec? indicator, + RemixBoxEffectsSpec? containerEffects, }) { return RemixRadioSpec( container: container ?? this.container, indicator: indicator ?? this.indicator, + containerEffects: containerEffects ?? this.containerEffects, ); } @@ -29,11 +32,16 @@ mixin _$RemixRadioSpec implements Spec, Diagnosticable { return RemixRadioSpec( container: container.lerp(other?.container, t), indicator: indicator.lerp(other?.indicator, t), + containerEffects: MixOps.lerpSnap( + containerEffects, + other?.containerEffects, + t, + ), ); } @override - List get props => [container, indicator]; + List get props => [container, indicator, containerEffects]; @override bool operator ==(Object other) { @@ -76,7 +84,8 @@ mixin _$RemixRadioSpec implements Spec, Diagnosticable { void debugFillProperties(DiagnosticPropertiesBuilder properties) { properties ..add(DiagnosticsProperty('container', container)) - ..add(DiagnosticsProperty('indicator', indicator)); + ..add(DiagnosticsProperty('indicator', indicator)) + ..add(DiagnosticsProperty('containerEffects', containerEffects)); } } @@ -103,6 +112,18 @@ class FortalRadio extends StatelessWidget { this.autofocus = false, }); + /// Raised treatment with Radix's classic shadow and gradient layers. + const FortalRadio.classic({ + super.key, + this.size = .size2, + required this.value, + this.enabled = true, + this.toggleable = false, + this.mouseCursor, + this.focusNode, + this.autofocus = false, + }) : variant = FortalRadioVariant.classic; + /// Surface treatment with neutral border. const FortalRadio.surface({ super.key, @@ -166,25 +187,30 @@ class RemixRadioStyler extends MixStyler with RemixBoxStylerMixin { final Prop>? $container; final Prop>? $indicator; + final Prop? $containerEffects; const RemixRadioStyler.create({ Prop>? container, Prop>? indicator, + Prop? containerEffects, super.variants, super.modifier, super.animation, }) : $container = container, - $indicator = indicator; + $indicator = indicator, + $containerEffects = containerEffects; RemixRadioStyler({ BoxStyler? container, BoxStyler? indicator, + RemixBoxEffectsMix? containerEffects, AnimationConfig? animation, WidgetModifierConfig? modifier, List>? variants, }) : this.create( container: Prop.maybeMix(container), indicator: Prop.maybeMix(indicator), + containerEffects: Prop.maybeMix(containerEffects), variants: variants, modifier: modifier, animation: animation, @@ -194,6 +220,8 @@ class RemixRadioStyler extends MixStyler RemixRadioStyler().container(value); factory RemixRadioStyler.indicator(BoxStyler value) => RemixRadioStyler().indicator(value); + factory RemixRadioStyler.containerEffects(RemixBoxEffectsMix value) => + RemixRadioStyler().containerEffects(value); factory RemixRadioStyler.alignment(AlignmentGeometry value) => RemixRadioStyler().alignment(value); factory RemixRadioStyler.padding(EdgeInsetsGeometryMix value) => @@ -676,6 +704,11 @@ class RemixRadioStyler extends MixStyler return merge(RemixRadioStyler(indicator: value)); } + /// Sets the containerEffects. + RemixRadioStyler containerEffects(RemixBoxEffectsMix value) { + return merge(RemixRadioStyler(containerEffects: value)); + } + /// Sets the animation configuration. @override RemixRadioStyler animate(AnimationConfig value) { @@ -705,6 +738,10 @@ class RemixRadioStyler extends MixStyler return RemixRadioStyler.create( container: MixOps.merge($container, other?.$container), indicator: MixOps.merge($indicator, other?.$indicator), + containerEffects: MixOps.merge( + $containerEffects, + other?.$containerEffects, + ), variants: MixOps.mergeVariants($variants, other?.$variants), modifier: MixOps.mergeModifier($modifier, other?.$modifier), animation: MixOps.mergeAnimation($animation, other?.$animation), @@ -717,6 +754,7 @@ class RemixRadioStyler extends MixStyler final spec = RemixRadioSpec( container: MixOps.resolve(context, $container), indicator: MixOps.resolve(context, $indicator), + containerEffects: MixOps.resolve(context, $containerEffects), ); return StyleSpec( @@ -731,13 +769,15 @@ class RemixRadioStyler extends MixStyler super.debugFillProperties(properties); properties ..add(DiagnosticsProperty('container', $container)) - ..add(DiagnosticsProperty('indicator', $indicator)); + ..add(DiagnosticsProperty('indicator', $indicator)) + ..add(DiagnosticsProperty('containerEffects', $containerEffects)); } @override List get props => [ $container, $indicator, + $containerEffects, $animation, $modifier, $variants, diff --git a/packages/remix/lib/src/components/radio/radio_spec.dart b/packages/remix/lib/src/components/radio/radio_spec.dart index 83544233..5bbc4a10 100644 --- a/packages/remix/lib/src/components/radio/radio_spec.dart +++ b/packages/remix/lib/src/components/radio/radio_spec.dart @@ -13,9 +13,27 @@ class RemixRadioSpec with _$RemixRadioSpec { @override final StyleSpec indicator; + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? containerEffects; + const RemixRadioSpec({ StyleSpec? container, StyleSpec? indicator, + this.containerEffects, }) : container = container ?? const StyleSpec(spec: BoxSpec()), indicator = indicator ?? const StyleSpec(spec: BoxSpec()); + + @override + RemixRadioSpec lerp(RemixRadioSpec? other, double t) { + final generated = super.lerp(other, t); + if (other == null) return generated; + return generated.copyWith( + containerEffects: RemixBoxEffectsSpec.lerpNullable( + containerEffects, + other.containerEffects, + t, + ), + ); + } } diff --git a/packages/remix/lib/src/components/radio/radio_widget.dart b/packages/remix/lib/src/components/radio/radio_widget.dart index 3a226889..a5dcbb1f 100644 --- a/packages/remix/lib/src/components/radio/radio_widget.dart +++ b/packages/remix/lib/src/components/radio/radio_widget.dart @@ -116,8 +116,9 @@ class RemixRadio extends StatelessWidget { styleSpec: styleSpec, controller: NakedRadioState.controllerOf(context), builder: (context, spec) { - return Box( + return RemixBoxWithEffects( styleSpec: spec.container, + containerEffects: spec.containerEffects, child: isSelected ? Box(styleSpec: spec.indicator) : null, ); }, diff --git a/packages/remix/lib/src/components/select/fortal_select_styles.dart b/packages/remix/lib/src/components/select/fortal_select_styles.dart index 1d854549..8f1df9aa 100644 --- a/packages/remix/lib/src/components/select/fortal_select_styles.dart +++ b/packages/remix/lib/src/components/select/fortal_select_styles.dart @@ -1,30 +1,12 @@ part of 'select.dart'; -/// Fortal select size presets. -enum FortalSelectSize { - /// Compact select. - size1, +/// Radix Themes Select root size presets. +enum FortalSelectSize { size1, size2, size3 } - /// Default select. - size2, +/// Radix Themes Select variants. +enum FortalSelectVariant { surface, soft, ghost } - /// Large select. - size3, -} - -/// Fortal select color and emphasis variants. -enum FortalSelectVariant { - /// Surface-backed trigger with border. - surface, - - /// Soft accent trigger. - soft, - - /// Transparent trigger. - ghost, -} - -/// Fortal-themed preset for [RemixSelect]. +/// Fortal recipe for a complete Select. @MixWidget( name: 'FortalSelect', target: RemixSelect.new, @@ -33,162 +15,314 @@ enum FortalSelectVariant { RemixSelectStyler fortalSelectStyler({ FortalSelectVariant variant = .surface, FortalSelectSize size = .size2, + bool highContrast = false, }) { - return switch (variant) { - .surface => _fortalSelectSurfaceStyler(size), - .soft => _fortalSelectSoftStyler(size), - .ghost => _fortalSelectGhostStyler(size), - }; + return RemixSelectStyler() + .trigger(_fortalSelectTriggerStyler(variant, size)) + .content(_fortalSelectContentStyler(size)) + .item(_fortalSelectItemStyler(variant, size, highContrast: highContrast)); } -RemixSelectStyler _fortalSelectBaseStyler( +/// Creates the established combined-variant Select item recipe. +RemixSelectMenuItemStyler fortalSelectMenuItemStyler({ + FortalSelectVariant variant = .surface, + FortalSelectSize size = .size2, +}) => _fortalSelectItemStyler(variant, size); + +RemixSelectTriggerStyler _fortalSelectTriggerStyler( FortalSelectVariant variant, FortalSelectSize size, ) { - return RemixSelectStyler() - .trigger( - RemixSelectTriggerStyler() - .direction(.horizontal) - .mainAxisAlignment(.spaceBetween) - .crossAxisAlignment(.center) - .borderRadiusAll(FortalTokens.radius3()) - .label(TextStyler().color(FortalTokens.gray12())) - .icon(IconStyler(color: FortalTokens.gray12(), size: 16.0)), - ) - .menuContainer( - FlexBoxStyler() - .width(150) - .color(FortalTokens.colorPanelSolid()) - .marginTop(8) - .borderAll( - color: FortalTokens.gray6(), - width: FortalTokens.borderWidth1(), - ) - .borderRadiusAll(FortalTokens.radius3()) - .padding(EdgeInsetsMix.all(8.0)), + final radius = _fortalSelectTriggerRadius(size); + final base = RemixSelectTriggerStyler() + .direction(.horizontal) + .mainAxisAlignment(.spaceBetween) + .crossAxisAlignment(.center) + .borderRadiusAll(radius) + .label(_fortalSelectTriggerText(size, color: FortalTokens.gray12())) + .placeholder( + _fortalSelectTriggerText(size, color: FortalTokens.grayA10()), ) - .item(fortalSelectMenuItemStyler(variant: variant, size: size)) + .icon(.color(FortalTokens.gray12())) + .chevron(.color(FortalTokens.gray12()).size(size == .size3 ? 11 : 9)) .onFocused( - RemixSelectStyler().trigger( - RemixSelectTriggerStyler().borderAll( - color: FortalTokens.focusA8(), - width: FortalTokens.focusRingWidth(), - ), + .containerEffects( + RemixBoxEffectsMix(overContent: _fortalSelectFocusRing()), ), ) - .merge(_fortalSelectSizeStyler(size)); -} + .merge(_fortalSelectTriggerSizeStyler(variant, size)); -RemixSelectStyler _fortalSelectSurfaceStyler([FortalSelectSize size = .size2]) { - return _fortalSelectBaseStyler(.surface, size).trigger( - RemixSelectTriggerStyler() - .color(FortalTokens.colorSurface()) - .borderAll( - color: FortalTokens.gray6(), - width: FortalTokens.borderWidth1(), - ), - ); + return switch (variant) { + .surface => _fortalSelectSurfaceTrigger(base), + .soft => _fortalSelectSoftTrigger(base), + .ghost => _fortalSelectGhostTrigger(base), + }; } -RemixSelectStyler _fortalSelectSoftStyler([FortalSelectSize size = .size2]) { - return _fortalSelectBaseStyler(.soft, size).trigger( - RemixSelectTriggerStyler() - .color(FortalTokens.accent3()) - .label(TextStyler().color(FortalTokens.accent11())) - .icon(IconStyler(color: FortalTokens.accent11(), size: 16.0)), - ); +TextStyler _fortalSelectTriggerText(FortalSelectSize size, {Color? color}) { + final token = switch (size) { + .size1 => FortalTokens.text1, + .size2 => FortalTokens.text2, + .size3 => FortalTokens.text3, + }; + return TextStyler(style: token.mix()) + .fontWeight(FortalTokens.fontWeightRegular()) + .color(color ?? FortalTokens.gray12()); } -RemixSelectStyler _fortalSelectGhostStyler([FortalSelectSize size = .size2]) { - return _fortalSelectBaseStyler( - .ghost, - size, - ).trigger(RemixSelectTriggerStyler().color(Colors.transparent).paddingY(6.0)); -} +Radius _fortalSelectTriggerRadius(FortalSelectSize size) => switch (size) { + .size1 => FortalTokens.radius1OrFull(), + .size2 => FortalTokens.radius2OrFull(), + .size3 => FortalTokens.radius3OrFull(), +}; -RemixSelectStyler _fortalSelectSizeStyler(FortalSelectSize size) { +RemixSelectTriggerStyler _fortalSelectTriggerSizeStyler( + FortalSelectVariant variant, + FortalSelectSize size, +) { + final style = RemixSelectTriggerStyler().spacing(switch (size) { + .size1 => FortalTokens.space1(), + .size2 => FortalTokens.selectSpace1Half(), + .size3 => FortalTokens.space2(), + }); + if (variant == .ghost) { + return switch (size) { + .size1 || .size2 => + style + .paddingX(FortalTokens.space2()) + .paddingY(FortalTokens.space1()) + .marginX(FortalTokens.selectGhostMarginX12()) + .marginY(FortalTokens.selectGhostMarginY12()), + .size3 => + style + .paddingX(FortalTokens.space3()) + .paddingY(FortalTokens.selectSpace1Half()) + .marginX(FortalTokens.selectGhostMarginX3()) + .marginY(FortalTokens.selectGhostMarginY3()), + }; + } return switch (size) { - .size1 => RemixSelectStyler().trigger( - RemixSelectTriggerStyler().paddingX(8.0).height(24.0), - ), - .size2 => RemixSelectStyler().trigger( - RemixSelectTriggerStyler().paddingX(12.0).height(32.0), - ), - .size3 => RemixSelectStyler().trigger( - RemixSelectTriggerStyler().paddingX(16.0).height(40.0), - ), + .size1 => + style.height(FortalTokens.space5()).paddingX(FortalTokens.space2()), + .size2 => + style.height(FortalTokens.space6()).paddingX(FortalTokens.space3()), + .size3 => + style.height(FortalTokens.space7()).paddingX(FortalTokens.space4()), }; } -/// Creates a Fortal-themed [RemixSelectMenuItemStyler]. -RemixSelectMenuItemStyler fortalSelectMenuItemStyler({ - FortalSelectVariant variant = .surface, - FortalSelectSize size = .size2, -}) { - return switch (variant) { - .surface => _fortalSelectMenuItemSurfaceStyler(size), - .soft => _fortalSelectMenuItemSoftStyler(size), - .ghost => _fortalSelectMenuItemGhostStyler(size), - }; +RemixBoxEffectLayerMix _fortalSelectFocusRing() { + return RemixBoxEffectLayerMix( + shadows: [ + RemixBoxShadowMix(color: FortalTokens.focus8(), spreadRadius: 1), + RemixBoxShadowMix( + kind: RemixBoxShadowKind.inset, + color: FortalTokens.focus8(), + spreadRadius: 1, + ), + ], + ); } -RemixSelectMenuItemStyler _fortalSelectMenuItemBaseStyler( - FortalSelectSize size, -) { - return RemixSelectMenuItemStyler() - .icon(IconStyler(size: 16.0)) - .borderRadiusAll(FortalTokens.radius2()) - .merge(_fortalSelectMenuItemSizeStyler(size)); +RemixBoxEffectLayerMix _fortalSelectInsetStroke(Color color) { + return RemixBoxEffectLayerMix( + shadows: [ + RemixBoxShadowMix( + kind: RemixBoxShadowKind.inset, + color: color, + spreadRadius: 1, + ), + ], + ); } -RemixSelectMenuItemStyler _fortalSelectMenuItemSurfaceStyler([ - FortalSelectSize size = .size2, -]) { - return _fortalSelectMenuItemBaseStyler(size) - .color(Colors.transparent) - .text(TextStyler().color(FortalTokens.gray12())) +RemixSelectTriggerStyler _fortalSelectSurfaceTrigger( + RemixSelectTriggerStyler base, +) { + return base + .chevronOpacity(0.9) + .color(FortalTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix( + behindContent: _fortalSelectInsetStroke( + FortalTokens.grayA7(), + ).merge(RemixBoxEffectLayerMix()), + ), + ) .onHovered( - RemixSelectMenuItemStyler() - .color(FortalTokens.grayA3()) - .text(TextStyler().color(FortalTokens.gray12())), + .containerEffects( + RemixBoxEffectsMix( + behindContent: _fortalSelectInsetStroke(FortalTokens.grayA8()), + ), + ), + ) + .onSelected( + .containerEffects( + RemixBoxEffectsMix( + behindContent: _fortalSelectInsetStroke(FortalTokens.grayA8()), + ), + ), + ) + .onDisabled( + .color(FortalTokens.grayA2()) + .label(.color(FortalTokens.grayA11())) + .icon(.color(FortalTokens.grayA9())) + .chevron(.color(FortalTokens.grayA9())) + .containerEffects( + RemixBoxEffectsMix( + behindContent: _fortalSelectInsetStroke( + FortalTokens.grayA6(), + ).merge(RemixBoxEffectLayerMix()), + ), + ), ); } -RemixSelectMenuItemStyler _fortalSelectMenuItemSoftStyler([ - FortalSelectSize size = .size2, -]) { - return _fortalSelectMenuItemBaseStyler(size) - .color(Colors.transparent) - .text(TextStyler().color(FortalTokens.gray12())) - .onHovered( - RemixSelectMenuItemStyler() - .color(FortalTokens.accentA3()) - .iconColor(FortalTokens.accent11()) - .text(TextStyler().color(FortalTokens.accent11())), +RemixSelectTriggerStyler _fortalSelectSoftTrigger( + RemixSelectTriggerStyler base, +) { + return base + .label(.color(FortalTokens.accent12())) + .placeholder(.color(FortalTokens.accent12())) + .placeholderOpacity(0.6) + .icon(.color(FortalTokens.accent12())) + .chevron(.color(FortalTokens.accent12())) + .color(FortalTokens.accentA3()) + .onHovered(.color(FortalTokens.accentA4())) + .onSelected(.color(FortalTokens.accentA4())) + .onDisabled( + .label(.color(FortalTokens.grayA11())) + .icon(.color(FortalTokens.grayA9())) + .chevron(.color(FortalTokens.grayA9())) + .color(FortalTokens.grayA3()), ); } -RemixSelectMenuItemStyler _fortalSelectMenuItemGhostStyler([ - FortalSelectSize size = .size2, -]) { - return _fortalSelectMenuItemBaseStyler(size) +RemixSelectTriggerStyler _fortalSelectGhostTrigger( + RemixSelectTriggerStyler base, +) { + return base + .label(.color(FortalTokens.accent12())) + .placeholder(.color(FortalTokens.accent12())) + .placeholderOpacity(0.6) + .icon(.color(FortalTokens.accent12())) + .chevron(.color(FortalTokens.accent12())) .color(Colors.transparent) - .text(TextStyler().color(FortalTokens.gray12())) - .onHovered( - RemixSelectMenuItemStyler() - .color(FortalTokens.grayA2()) - .text(TextStyler().color(FortalTokens.gray12())), + .onHovered(.color(FortalTokens.accentA3())) + .onSelected(.color(FortalTokens.accentA3())) + .onDisabled( + .label(.color(FortalTokens.grayA11())) + .icon(.color(FortalTokens.grayA9())) + .chevron(.color(FortalTokens.grayA9())) + .color(Colors.transparent), ); } -RemixSelectMenuItemStyler _fortalSelectMenuItemSizeStyler( - FortalSelectSize size, -) { - return switch (size) { - .size1 => RemixSelectMenuItemStyler().paddingX(6.0).height(20.0), - .size2 => RemixSelectMenuItemStyler().paddingX(8.0).height(24.0), - .size3 => RemixSelectMenuItemStyler().paddingX(10.0).height(28.0), +RemixSelectContentStyler _fortalSelectContentStyler(FortalSelectSize size) { + final radius = size == .size1 + ? FortalTokens.radius3() + : FortalTokens.radius4(); + return RemixSelectContentStyler() + .paddingAll( + size == .size1 ? FortalTokens.space1() : FortalTokens.space2(), + ) + .borderRadiusAll(radius) + .color(FortalTokens.colorPanel()) + .decoration( + BoxDecorationMix.create(boxShadow: FortalTokens.shadow5.mix()), + ) + .clipBehavior(Clip.antiAlias) + .containerEffects( + RemixBoxEffectsMix(backdropBlur: FortalTokens.panelBlur()), + ); +} + +RemixSelectMenuItemStyler _fortalSelectItemStyler( + FortalSelectVariant variant, + FortalSelectSize size, { + bool highContrast = false, +}) { + final metrics = _fortalSelectContentMetrics(size); + final base = RemixSelectMenuItemStyler() + .direction(.horizontal) + .crossAxisAlignment(.center) + .height(metrics.itemHeight) + .paddingX(metrics.indicatorWidth) + .borderRadiusAll(metrics.itemRadius) + .text( + TextStyler(style: metrics.itemText.mix()).color(FortalTokens.gray12()), + ) + .indicator( + BoxStyler( + alignment: .center, + constraints: BoxConstraintsMix.width(metrics.indicatorWidth), + ), + ) + .icon( + IconStyler(color: FortalTokens.gray12(), size: metrics.indicatorSize), + ); + + final highlighted = switch (variant) { + .surface || .ghost => + RemixSelectMenuItemStyler() + .color( + highContrast ? FortalTokens.accent12() : FortalTokens.accent9(), + ) + .text( + TextStyler().color( + highContrast + ? FortalTokens.accent1() + : FortalTokens.accentContrast(), + ), + ) + .iconColor( + highContrast + ? FortalTokens.accent1() + : FortalTokens.accentContrast(), + ), + .soft => RemixSelectMenuItemStyler().color(FortalTokens.accentA4()), }; + + return base + .onHovered(highlighted) + .onFocused(highlighted) + .onPressed(highlighted) + .onDisabled( + .color( + Colors.transparent, + ).text(.color(FortalTokens.grayA8())).iconColor(FortalTokens.grayA8()), + ); } -/// Fortal-themed preset for [RemixSelect]. +({ + double itemHeight, + double indicatorWidth, + double indicatorSize, + Radius itemRadius, + TextStyleToken itemText, +}) +_fortalSelectContentMetrics(FortalSelectSize size) => switch (size) { + .size1 => ( + itemHeight: FortalTokens.space5(), + indicatorWidth: FortalTokens.selectIndicatorWidth1(), + indicatorSize: FortalTokens.selectIndicatorSize1(), + itemRadius: FortalTokens.radius1(), + itemText: FortalTokens.text1, + ), + .size2 => ( + itemHeight: FortalTokens.space6(), + indicatorWidth: FortalTokens.space5(), + indicatorSize: FortalTokens.selectIndicatorSize2(), + itemRadius: FortalTokens.radius2(), + itemText: FortalTokens.text2, + ), + .size3 => ( + itemHeight: FortalTokens.space6(), + indicatorWidth: FortalTokens.space5(), + indicatorSize: FortalTokens.selectIndicatorSize2(), + itemRadius: FortalTokens.radius2(), + itemText: FortalTokens.text3, + ), +}; + +/// Fortal-themed Select with Radix-owned trigger and content configuration. diff --git a/packages/remix/lib/src/components/select/select.dart b/packages/remix/lib/src/components/select/select.dart index e61e2816..3e5439b4 100644 --- a/packages/remix/lib/src/components/select/select.dart +++ b/packages/remix/lib/src/components/select/select.dart @@ -1,5 +1,7 @@ library remix_select; +import 'dart:math' as math; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:mix/mix.dart'; @@ -7,8 +9,11 @@ import 'package:mix_annotations/mix_annotations.dart'; import 'package:naked_ui/naked_ui.dart'; import '../../style/style.dart'; +import '../../utilities/remix_path_icon.dart'; import '../../utilities/remix_style.dart'; +import '../../utilities/selected_mixin.dart'; import '../../fortal/fortal.dart'; +import '../../rendering/remix_box_effects.dart'; part 'select_spec.dart'; part 'select_style.dart'; diff --git a/packages/remix/lib/src/components/select/select.g.dart b/packages/remix/lib/src/components/select/select.g.dart index 2579f2d3..6e734e00 100644 --- a/packages/remix/lib/src/components/select/select.g.dart +++ b/packages/remix/lib/src/components/select/select.g.dart @@ -8,6 +8,7 @@ part of 'select.dart'; mixin _$RemixSelectSpec implements Spec, Diagnosticable { StyleSpec get trigger; + StyleSpec get content; StyleSpec get menuContainer; StyleSpec get item; @@ -17,11 +18,13 @@ mixin _$RemixSelectSpec implements Spec, Diagnosticable { @override RemixSelectSpec copyWith({ StyleSpec? trigger, + StyleSpec? content, StyleSpec? menuContainer, StyleSpec? item, }) { return RemixSelectSpec( trigger: trigger ?? this.trigger, + content: content ?? this.content, menuContainer: menuContainer ?? this.menuContainer, item: item ?? this.item, ); @@ -31,13 +34,14 @@ mixin _$RemixSelectSpec implements Spec, Diagnosticable { RemixSelectSpec lerp(RemixSelectSpec? other, double t) { return RemixSelectSpec( trigger: trigger.lerp(other?.trigger, t), + content: content.lerp(other?.content, t), menuContainer: menuContainer.lerp(other?.menuContainer, t), item: item.lerp(other?.item, t), ); } @override - List get props => [trigger, menuContainer, item]; + List get props => [trigger, content, menuContainer, item]; @override bool operator ==(Object other) { @@ -80,6 +84,7 @@ mixin _$RemixSelectSpec implements Spec, Diagnosticable { void debugFillProperties(DiagnosticPropertiesBuilder properties) { properties ..add(DiagnosticsProperty('trigger', trigger)) + ..add(DiagnosticsProperty('content', content)) ..add(DiagnosticsProperty('menuContainer', menuContainer)) ..add(DiagnosticsProperty('item', item)); } @@ -94,7 +99,12 @@ mixin _$RemixSelectTriggerSpec implements Spec, Diagnosticable { StyleSpec get container; StyleSpec get label; + StyleSpec get placeholder; StyleSpec get icon; + StyleSpec get chevron; + RemixBoxEffectsSpec? get containerEffects; + double? get chevronOpacity; + double? get placeholderOpacity; @override Type get type => RemixSelectTriggerSpec; @@ -103,12 +113,22 @@ mixin _$RemixSelectTriggerSpec RemixSelectTriggerSpec copyWith({ StyleSpec? container, StyleSpec? label, + StyleSpec? placeholder, StyleSpec? icon, + StyleSpec? chevron, + RemixBoxEffectsSpec? containerEffects, + double? chevronOpacity, + double? placeholderOpacity, }) { return RemixSelectTriggerSpec( container: container ?? this.container, label: label ?? this.label, + placeholder: placeholder ?? this.placeholder, icon: icon ?? this.icon, + chevron: chevron ?? this.chevron, + containerEffects: containerEffects ?? this.containerEffects, + chevronOpacity: chevronOpacity ?? this.chevronOpacity, + placeholderOpacity: placeholderOpacity ?? this.placeholderOpacity, ); } @@ -117,12 +137,34 @@ mixin _$RemixSelectTriggerSpec return RemixSelectTriggerSpec( container: container.lerp(other?.container, t), label: label.lerp(other?.label, t), + placeholder: placeholder.lerp(other?.placeholder, t), icon: icon.lerp(other?.icon, t), + chevron: chevron.lerp(other?.chevron, t), + containerEffects: MixOps.lerpSnap( + containerEffects, + other?.containerEffects, + t, + ), + chevronOpacity: MixOps.lerp(chevronOpacity, other?.chevronOpacity, t), + placeholderOpacity: MixOps.lerp( + placeholderOpacity, + other?.placeholderOpacity, + t, + ), ); } @override - List get props => [container, label, icon]; + List get props => [ + container, + label, + placeholder, + icon, + chevron, + containerEffects, + chevronOpacity, + placeholderOpacity, + ]; @override bool operator ==(Object other) { @@ -166,7 +208,12 @@ mixin _$RemixSelectTriggerSpec properties ..add(DiagnosticsProperty('container', container)) ..add(DiagnosticsProperty('label', label)) - ..add(DiagnosticsProperty('icon', icon)); + ..add(DiagnosticsProperty('placeholder', placeholder)) + ..add(DiagnosticsProperty('icon', icon)) + ..add(DiagnosticsProperty('chevron', chevron)) + ..add(DiagnosticsProperty('containerEffects', containerEffects)) + ..add(DoubleProperty('chevronOpacity', chevronOpacity)) + ..add(DoubleProperty('placeholderOpacity', placeholderOpacity)); } } @@ -175,10 +222,95 @@ mixin _$RemixSelectTriggerSpec ) typedef _$RemixSelectTriggerSpecMethods = _$RemixSelectTriggerSpec; // ignore: unused_element +mixin _$RemixSelectContentSpec + implements Spec, Diagnosticable { + StyleSpec get container; + RemixBoxEffectsSpec? get containerEffects; + + @override + Type get type => RemixSelectContentSpec; + + @override + RemixSelectContentSpec copyWith({ + StyleSpec? container, + RemixBoxEffectsSpec? containerEffects, + }) { + return RemixSelectContentSpec( + container: container ?? this.container, + containerEffects: containerEffects ?? this.containerEffects, + ); + } + + @override + RemixSelectContentSpec lerp(RemixSelectContentSpec? other, double t) { + return RemixSelectContentSpec( + container: container.lerp(other?.container, t), + containerEffects: MixOps.lerpSnap( + containerEffects, + other?.containerEffects, + t, + ), + ); + } + + @override + List get props => [container, containerEffects]; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is RemixSelectContentSpec && + runtimeType == other.runtimeType && + propsEquals(props, other.props); + } + + @override + int get hashCode => propsHash(runtimeType, props); + + @override + bool get stringify => true; + + @override + Map getDiff(Equatable other) { + if (this == other) return const {}; + + return propsDiff(props, other.props); + } + + @override + String toStringShort() => '$runtimeType'; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + toDiagnosticsNode( + style: DiagnosticsTreeStyle.singleLine, + ).toString(minLevel: minLevel); + + @override + DiagnosticsNode toDiagnosticsNode({ + String? name, + DiagnosticsTreeStyle? style, + }) => + DiagnosticableNode(name: name, value: this, style: style); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('container', container)) + ..add(DiagnosticsProperty('containerEffects', containerEffects)); + } +} + +@Deprecated( + 'Rename to `_\$RemixSelectContentSpec` and migrate the class declaration to `class RemixSelectContentSpec with _\$RemixSelectContentSpec`. The `_\$RemixSelectContentSpecMethods` alias will be removed in mix_generator 3.0.', +) +typedef _$RemixSelectContentSpecMethods = _$RemixSelectContentSpec; // ignore: unused_element + mixin _$RemixSelectMenuItemSpec implements Spec, Diagnosticable { StyleSpec get container; StyleSpec get text; + StyleSpec get indicator; StyleSpec get icon; @override @@ -188,11 +320,13 @@ mixin _$RemixSelectMenuItemSpec RemixSelectMenuItemSpec copyWith({ StyleSpec? container, StyleSpec? text, + StyleSpec? indicator, StyleSpec? icon, }) { return RemixSelectMenuItemSpec( container: container ?? this.container, text: text ?? this.text, + indicator: indicator ?? this.indicator, icon: icon ?? this.icon, ); } @@ -202,12 +336,13 @@ mixin _$RemixSelectMenuItemSpec return RemixSelectMenuItemSpec( container: container.lerp(other?.container, t), text: text.lerp(other?.text, t), + indicator: indicator.lerp(other?.indicator, t), icon: icon.lerp(other?.icon, t), ); } @override - List get props => [container, text, icon]; + List get props => [container, text, indicator, icon]; @override bool operator ==(Object other) { @@ -251,6 +386,7 @@ mixin _$RemixSelectMenuItemSpec properties ..add(DiagnosticsProperty('container', container)) ..add(DiagnosticsProperty('text', text)) + ..add(DiagnosticsProperty('indicator', indicator)) ..add(DiagnosticsProperty('icon', icon)); } } @@ -264,7 +400,7 @@ typedef _$RemixSelectMenuItemSpecMethods = _$RemixSelectMenuItemSpec; // ignore: // MixWidgetGenerator // ************************************************************************** -/// Fortal-themed preset for [RemixSelect]. +/// Fortal recipe for a complete Select. class FortalSelect extends StatelessWidget { const FortalSelect({ super.key, @@ -274,8 +410,8 @@ class FortalSelect extends StatelessWidget { required this.items, this.selectedValue, this.positioning = const OverlayPositionConfig( - targetAnchor: .bottomCenter, - followerAnchor: .topCenter, + side: .bottom, + alignment: .center, ), this.onChanged, this.onOpen, @@ -286,7 +422,6 @@ class FortalSelect extends StatelessWidget { this.focusNode, }); - /// Surface-backed trigger with border. const FortalSelect.surface({ super.key, this.size = .size2, @@ -294,8 +429,8 @@ class FortalSelect extends StatelessWidget { required this.items, this.selectedValue, this.positioning = const OverlayPositionConfig( - targetAnchor: .bottomCenter, - followerAnchor: .topCenter, + side: .bottom, + alignment: .center, ), this.onChanged, this.onOpen, @@ -306,7 +441,6 @@ class FortalSelect extends StatelessWidget { this.focusNode, }) : variant = FortalSelectVariant.surface; - /// Soft accent trigger. const FortalSelect.soft({ super.key, this.size = .size2, @@ -314,8 +448,8 @@ class FortalSelect extends StatelessWidget { required this.items, this.selectedValue, this.positioning = const OverlayPositionConfig( - targetAnchor: .bottomCenter, - followerAnchor: .topCenter, + side: .bottom, + alignment: .center, ), this.onChanged, this.onOpen, @@ -326,7 +460,6 @@ class FortalSelect extends StatelessWidget { this.focusNode, }) : variant = FortalSelectVariant.soft; - /// Transparent trigger. const FortalSelect.ghost({ super.key, this.size = .size2, @@ -334,8 +467,8 @@ class FortalSelect extends StatelessWidget { required this.items, this.selectedValue, this.positioning = const OverlayPositionConfig( - targetAnchor: .bottomCenter, - followerAnchor: .topCenter, + side: .bottom, + alignment: .center, ), this.onChanged, this.onOpen, @@ -399,22 +532,26 @@ class FortalSelect extends StatelessWidget { class RemixSelectStyler extends MixStyler with RemixBoxStylerMixin { final Prop>? $trigger; + final Prop>? $content; final Prop>? $menuContainer; final Prop>? $item; const RemixSelectStyler.create({ Prop>? trigger, + Prop>? content, Prop>? menuContainer, Prop>? item, super.variants, super.modifier, super.animation, }) : $trigger = trigger, + $content = content, $menuContainer = menuContainer, $item = item; RemixSelectStyler({ RemixSelectTriggerStyler? trigger, + RemixSelectContentStyler? content, FlexBoxStyler? menuContainer, RemixSelectMenuItemStyler? item, AnimationConfig? animation, @@ -422,6 +559,7 @@ class RemixSelectStyler extends MixStyler List>? variants, }) : this.create( trigger: Prop.maybeMix(trigger), + content: Prop.maybeMix(content), menuContainer: Prop.maybeMix(menuContainer), item: Prop.maybeMix(item), variants: variants, @@ -431,6 +569,8 @@ class RemixSelectStyler extends MixStyler factory RemixSelectStyler.trigger(RemixSelectTriggerStyler value) => RemixSelectStyler().trigger(value); + factory RemixSelectStyler.content(RemixSelectContentStyler value) => + RemixSelectStyler().content(value); factory RemixSelectStyler.menuContainer(FlexBoxStyler value) => RemixSelectStyler().menuContainer(value); factory RemixSelectStyler.item(RemixSelectMenuItemStyler value) => @@ -914,6 +1054,11 @@ class RemixSelectStyler extends MixStyler return merge(RemixSelectStyler(trigger: value)); } + /// Sets the content. + RemixSelectStyler content(RemixSelectContentStyler value) { + return merge(RemixSelectStyler(content: value)); + } + /// Sets the menuContainer. RemixSelectStyler menuContainer(FlexBoxStyler value) { return merge(RemixSelectStyler(menuContainer: value)); @@ -952,6 +1097,7 @@ class RemixSelectStyler extends MixStyler RemixSelectStyler merge(RemixSelectStyler? other) { return RemixSelectStyler.create( trigger: MixOps.merge($trigger, other?.$trigger), + content: MixOps.merge($content, other?.$content), menuContainer: MixOps.merge($menuContainer, other?.$menuContainer), item: MixOps.merge($item, other?.$item), variants: MixOps.mergeVariants($variants, other?.$variants), @@ -965,6 +1111,7 @@ class RemixSelectStyler extends MixStyler StyleSpec resolve(BuildContext context) { final spec = RemixSelectSpec( trigger: MixOps.resolve(context, $trigger), + content: MixOps.resolve(context, $content), menuContainer: MixOps.resolve(context, $menuContainer), item: MixOps.resolve(context, $item), ); @@ -981,6 +1128,7 @@ class RemixSelectStyler extends MixStyler super.debugFillProperties(properties); properties ..add(DiagnosticsProperty('trigger', $trigger)) + ..add(DiagnosticsProperty('content', $content)) ..add(DiagnosticsProperty('menuContainer', $menuContainer)) ..add(DiagnosticsProperty('item', $item)); } @@ -988,6 +1136,7 @@ class RemixSelectStyler extends MixStyler @override List get props => [ $trigger, + $content, $menuContainer, $item, $animation, @@ -1004,30 +1153,55 @@ class RemixSelectTriggerStyler IconStyleMixin { final Prop>? $container; final Prop>? $label; + final Prop>? $placeholder; final Prop>? $icon; + final Prop>? $chevron; + final Prop? $containerEffects; + final Prop? $chevronOpacity; + final Prop? $placeholderOpacity; const RemixSelectTriggerStyler.create({ Prop>? container, Prop>? label, + Prop>? placeholder, Prop>? icon, + Prop>? chevron, + Prop? containerEffects, + Prop? chevronOpacity, + Prop? placeholderOpacity, super.variants, super.modifier, super.animation, }) : $container = container, $label = label, - $icon = icon; + $placeholder = placeholder, + $icon = icon, + $chevron = chevron, + $containerEffects = containerEffects, + $chevronOpacity = chevronOpacity, + $placeholderOpacity = placeholderOpacity; RemixSelectTriggerStyler({ FlexBoxStyler? container, TextStyler? label, + TextStyler? placeholder, IconStyler? icon, + IconStyler? chevron, + RemixBoxEffectsMix? containerEffects, + double? chevronOpacity, + double? placeholderOpacity, AnimationConfig? animation, WidgetModifierConfig? modifier, List>? variants, }) : this.create( container: Prop.maybeMix(container), label: Prop.maybeMix(label), + placeholder: Prop.maybeMix(placeholder), icon: Prop.maybeMix(icon), + chevron: Prop.maybeMix(chevron), + containerEffects: Prop.maybeMix(containerEffects), + chevronOpacity: Prop.maybe(chevronOpacity), + placeholderOpacity: Prop.maybe(placeholderOpacity), variants: variants, modifier: modifier, animation: animation, @@ -1037,8 +1211,18 @@ class RemixSelectTriggerStyler RemixSelectTriggerStyler().container(value); factory RemixSelectTriggerStyler.label(TextStyler value) => RemixSelectTriggerStyler().label(value); + factory RemixSelectTriggerStyler.placeholder(TextStyler value) => + RemixSelectTriggerStyler().placeholder(value); factory RemixSelectTriggerStyler.icon(IconStyler value) => RemixSelectTriggerStyler().icon(value); + factory RemixSelectTriggerStyler.chevron(IconStyler value) => + RemixSelectTriggerStyler().chevron(value); + factory RemixSelectTriggerStyler.containerEffects(RemixBoxEffectsMix value) => + RemixSelectTriggerStyler().containerEffects(value); + factory RemixSelectTriggerStyler.chevronOpacity(double value) => + RemixSelectTriggerStyler().chevronOpacity(value); + factory RemixSelectTriggerStyler.placeholderOpacity(double value) => + RemixSelectTriggerStyler().placeholderOpacity(value); factory RemixSelectTriggerStyler.color(Color value) => RemixSelectTriggerStyler().color(value); factory RemixSelectTriggerStyler.gradient(GradientMix value) => @@ -1595,12 +1779,37 @@ class RemixSelectTriggerStyler return merge(RemixSelectTriggerStyler(label: value)); } + /// Sets the placeholder. + RemixSelectTriggerStyler placeholder(TextStyler value) { + return merge(RemixSelectTriggerStyler(placeholder: value)); + } + /// Sets the icon. @override RemixSelectTriggerStyler icon(IconStyler value) { return merge(RemixSelectTriggerStyler(icon: value)); } + /// Sets the chevron. + RemixSelectTriggerStyler chevron(IconStyler value) { + return merge(RemixSelectTriggerStyler(chevron: value)); + } + + /// Sets the containerEffects. + RemixSelectTriggerStyler containerEffects(RemixBoxEffectsMix value) { + return merge(RemixSelectTriggerStyler(containerEffects: value)); + } + + /// Sets the chevronOpacity. + RemixSelectTriggerStyler chevronOpacity(double value) { + return merge(RemixSelectTriggerStyler(chevronOpacity: value)); + } + + /// Sets the placeholderOpacity. + RemixSelectTriggerStyler placeholderOpacity(double value) { + return merge(RemixSelectTriggerStyler(placeholderOpacity: value)); + } + /// Sets the animation configuration. @override RemixSelectTriggerStyler animate(AnimationConfig value) { @@ -1632,7 +1841,18 @@ class RemixSelectTriggerStyler return RemixSelectTriggerStyler.create( container: MixOps.merge($container, other?.$container), label: MixOps.merge($label, other?.$label), + placeholder: MixOps.merge($placeholder, other?.$placeholder), icon: MixOps.merge($icon, other?.$icon), + chevron: MixOps.merge($chevron, other?.$chevron), + containerEffects: MixOps.merge( + $containerEffects, + other?.$containerEffects, + ), + chevronOpacity: MixOps.merge($chevronOpacity, other?.$chevronOpacity), + placeholderOpacity: MixOps.merge( + $placeholderOpacity, + other?.$placeholderOpacity, + ), variants: MixOps.mergeVariants($variants, other?.$variants), modifier: MixOps.mergeModifier($modifier, other?.$modifier), animation: MixOps.mergeAnimation($animation, other?.$animation), @@ -1645,7 +1865,12 @@ class RemixSelectTriggerStyler final spec = RemixSelectTriggerSpec( container: MixOps.resolve(context, $container), label: MixOps.resolve(context, $label), + placeholder: MixOps.resolve(context, $placeholder), icon: MixOps.resolve(context, $icon), + chevron: MixOps.resolve(context, $chevron), + containerEffects: MixOps.resolve(context, $containerEffects), + chevronOpacity: MixOps.resolve(context, $chevronOpacity), + placeholderOpacity: MixOps.resolve(context, $placeholderOpacity), ); return StyleSpec( @@ -1661,14 +1886,625 @@ class RemixSelectTriggerStyler properties ..add(DiagnosticsProperty('container', $container)) ..add(DiagnosticsProperty('label', $label)) - ..add(DiagnosticsProperty('icon', $icon)); + ..add(DiagnosticsProperty('placeholder', $placeholder)) + ..add(DiagnosticsProperty('icon', $icon)) + ..add(DiagnosticsProperty('chevron', $chevron)) + ..add(DiagnosticsProperty('containerEffects', $containerEffects)) + ..add(DiagnosticsProperty('chevronOpacity', $chevronOpacity)) + ..add(DiagnosticsProperty('placeholderOpacity', $placeholderOpacity)); } @override List get props => [ $container, $label, + $placeholder, $icon, + $chevron, + $containerEffects, + $chevronOpacity, + $placeholderOpacity, + $animation, + $modifier, + $variants, + ]; +} + +class RemixSelectContentStyler + extends MixStyler + with RemixBoxStylerMixin { + final Prop>? $container; + final Prop? $containerEffects; + + const RemixSelectContentStyler.create({ + Prop>? container, + Prop? containerEffects, + super.variants, + super.modifier, + super.animation, + }) : $container = container, + $containerEffects = containerEffects; + + RemixSelectContentStyler({ + BoxStyler? container, + RemixBoxEffectsMix? containerEffects, + AnimationConfig? animation, + WidgetModifierConfig? modifier, + List>? variants, + }) : this.create( + container: Prop.maybeMix(container), + containerEffects: Prop.maybeMix(containerEffects), + variants: variants, + modifier: modifier, + animation: animation, + ); + + factory RemixSelectContentStyler.container(BoxStyler value) => + RemixSelectContentStyler().container(value); + factory RemixSelectContentStyler.containerEffects(RemixBoxEffectsMix value) => + RemixSelectContentStyler().containerEffects(value); + factory RemixSelectContentStyler.alignment(AlignmentGeometry value) => + RemixSelectContentStyler().alignment(value); + factory RemixSelectContentStyler.padding(EdgeInsetsGeometryMix value) => + RemixSelectContentStyler().padding(value); + factory RemixSelectContentStyler.margin(EdgeInsetsGeometryMix value) => + RemixSelectContentStyler().margin(value); + factory RemixSelectContentStyler.constraints(BoxConstraintsMix value) => + RemixSelectContentStyler().constraints(value); + factory RemixSelectContentStyler.decoration(DecorationMix value) => + RemixSelectContentStyler().decoration(value); + factory RemixSelectContentStyler.foregroundDecoration(DecorationMix value) => + RemixSelectContentStyler().foregroundDecoration(value); + factory RemixSelectContentStyler.clipBehavior(Clip value) => + RemixSelectContentStyler().clipBehavior(value); + factory RemixSelectContentStyler.color(Color value) => + RemixSelectContentStyler().color(value); + factory RemixSelectContentStyler.gradient(GradientMix value) => + RemixSelectContentStyler().gradient(value); + factory RemixSelectContentStyler.border(BoxBorderMix value) => + RemixSelectContentStyler().border(value); + factory RemixSelectContentStyler.borderRadius( + BorderRadiusGeometryMix value, + ) => RemixSelectContentStyler().borderRadius(value); + factory RemixSelectContentStyler.elevation(ElevationShadow value) => + RemixSelectContentStyler().elevation(value); + factory RemixSelectContentStyler.shadow(BoxShadowMix value) => + RemixSelectContentStyler().shadow(value); + factory RemixSelectContentStyler.shadows(List value) => + RemixSelectContentStyler().shadows(value); + factory RemixSelectContentStyler.width(double value) => + RemixSelectContentStyler().width(value); + factory RemixSelectContentStyler.height(double value) => + RemixSelectContentStyler().height(value); + factory RemixSelectContentStyler.size(double width, double height) => + RemixSelectContentStyler().size(width, height); + factory RemixSelectContentStyler.minWidth(double value) => + RemixSelectContentStyler().minWidth(value); + factory RemixSelectContentStyler.maxWidth(double value) => + RemixSelectContentStyler().maxWidth(value); + factory RemixSelectContentStyler.minHeight(double value) => + RemixSelectContentStyler().minHeight(value); + factory RemixSelectContentStyler.maxHeight(double value) => + RemixSelectContentStyler().maxHeight(value); + factory RemixSelectContentStyler.scale( + double scale, { + Alignment alignment = .center, + }) => RemixSelectContentStyler().scale(scale, alignment: alignment); + factory RemixSelectContentStyler.rotate( + double radians, { + Alignment alignment = .center, + }) => RemixSelectContentStyler().rotate(radians, alignment: alignment); + factory RemixSelectContentStyler.translate( + double x, + double y, [ + double z = 0.0, + ]) => RemixSelectContentStyler().translate(x, y, z); + factory RemixSelectContentStyler.skew(double skewX, double skewY) => + RemixSelectContentStyler().skew(skewX, skewY); + factory RemixSelectContentStyler.textStyle(TextStyler value) => + RemixSelectContentStyler().textStyle(value); + factory RemixSelectContentStyler.image(DecorationImageMix value) => + RemixSelectContentStyler().image(value); + factory RemixSelectContentStyler.shape(ShapeBorderMix value) => + RemixSelectContentStyler().shape(value); + factory RemixSelectContentStyler.backgroundImage( + ImageProvider image, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) => RemixSelectContentStyler().backgroundImage( + image, + fit: fit, + alignment: alignment, + repeat: repeat, + ); + factory RemixSelectContentStyler.backgroundImageUrl( + String url, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) => RemixSelectContentStyler().backgroundImageUrl( + url, + fit: fit, + alignment: alignment, + repeat: repeat, + ); + factory RemixSelectContentStyler.backgroundImageAsset( + String path, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) => RemixSelectContentStyler().backgroundImageAsset( + path, + fit: fit, + alignment: alignment, + repeat: repeat, + ); + factory RemixSelectContentStyler.linearGradient({ + required List colors, + List? stops, + AlignmentGeometry? begin, + AlignmentGeometry? end, + TileMode? tileMode, + }) => RemixSelectContentStyler().linearGradient( + colors: colors, + stops: stops, + begin: begin, + end: end, + tileMode: tileMode, + ); + factory RemixSelectContentStyler.radialGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? radius, + AlignmentGeometry? focal, + double? focalRadius, + TileMode? tileMode, + }) => RemixSelectContentStyler().radialGradient( + colors: colors, + stops: stops, + center: center, + radius: radius, + focal: focal, + focalRadius: focalRadius, + tileMode: tileMode, + ); + factory RemixSelectContentStyler.sweepGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? startAngle, + double? endAngle, + TileMode? tileMode, + }) => RemixSelectContentStyler().sweepGradient( + colors: colors, + stops: stops, + center: center, + startAngle: startAngle, + endAngle: endAngle, + tileMode: tileMode, + ); + factory RemixSelectContentStyler.foregroundLinearGradient({ + required List colors, + List? stops, + AlignmentGeometry? begin, + AlignmentGeometry? end, + TileMode? tileMode, + }) => RemixSelectContentStyler().foregroundLinearGradient( + colors: colors, + stops: stops, + begin: begin, + end: end, + tileMode: tileMode, + ); + factory RemixSelectContentStyler.foregroundRadialGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? radius, + AlignmentGeometry? focal, + double? focalRadius, + TileMode? tileMode, + }) => RemixSelectContentStyler().foregroundRadialGradient( + colors: colors, + stops: stops, + center: center, + radius: radius, + focal: focal, + focalRadius: focalRadius, + tileMode: tileMode, + ); + factory RemixSelectContentStyler.foregroundSweepGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? startAngle, + double? endAngle, + TileMode? tileMode, + }) => RemixSelectContentStyler().foregroundSweepGradient( + colors: colors, + stops: stops, + center: center, + startAngle: startAngle, + endAngle: endAngle, + tileMode: tileMode, + ); + factory RemixSelectContentStyler.transform( + Matrix4 value, { + Alignment alignment = .center, + }) => RemixSelectContentStyler().transform(value, alignment: alignment); + + RemixSelectContentStyler alignment(AlignmentGeometry value) { + return container(BoxStyler().alignment(value)); + } + + RemixSelectContentStyler padding(EdgeInsetsGeometryMix value) { + return container(BoxStyler().padding(value)); + } + + RemixSelectContentStyler margin(EdgeInsetsGeometryMix value) { + return container(BoxStyler().margin(value)); + } + + RemixSelectContentStyler constraints(BoxConstraintsMix value) { + return container(BoxStyler().constraints(value)); + } + + RemixSelectContentStyler decoration(DecorationMix value) { + return container(BoxStyler().decoration(value)); + } + + RemixSelectContentStyler foregroundDecoration(DecorationMix value) { + return container(BoxStyler().foregroundDecoration(value)); + } + + RemixSelectContentStyler clipBehavior(Clip value) { + return container(BoxStyler().clipBehavior(value)); + } + + RemixSelectContentStyler color(Color value) { + return container(BoxStyler().color(value)); + } + + RemixSelectContentStyler gradient(GradientMix value) { + return container(BoxStyler().gradient(value)); + } + + RemixSelectContentStyler border(BoxBorderMix value) { + return container(BoxStyler().border(value)); + } + + RemixSelectContentStyler borderRadius(BorderRadiusGeometryMix value) { + return container(BoxStyler().borderRadius(value)); + } + + RemixSelectContentStyler elevation(ElevationShadow value) { + return container(BoxStyler().elevation(value)); + } + + RemixSelectContentStyler shadow(BoxShadowMix value) { + return container(BoxStyler().shadow(value)); + } + + RemixSelectContentStyler shadows(List value) { + return container(BoxStyler().shadows(value)); + } + + RemixSelectContentStyler width(double value) { + return container(BoxStyler().width(value)); + } + + RemixSelectContentStyler height(double value) { + return container(BoxStyler().height(value)); + } + + RemixSelectContentStyler size(double width, double height) { + return container(BoxStyler().size(width, height)); + } + + RemixSelectContentStyler minWidth(double value) { + return container(BoxStyler().minWidth(value)); + } + + RemixSelectContentStyler maxWidth(double value) { + return container(BoxStyler().maxWidth(value)); + } + + RemixSelectContentStyler minHeight(double value) { + return container(BoxStyler().minHeight(value)); + } + + RemixSelectContentStyler maxHeight(double value) { + return container(BoxStyler().maxHeight(value)); + } + + RemixSelectContentStyler scale( + double scale, { + Alignment alignment = .center, + }) { + return container(BoxStyler().scale(scale, alignment: alignment)); + } + + RemixSelectContentStyler rotate( + double radians, { + Alignment alignment = .center, + }) { + return container(BoxStyler().rotate(radians, alignment: alignment)); + } + + RemixSelectContentStyler translate(double x, double y, [double z = 0.0]) { + return container(BoxStyler().translate(x, y, z)); + } + + RemixSelectContentStyler skew(double skewX, double skewY) { + return container(BoxStyler().skew(skewX, skewY)); + } + + RemixSelectContentStyler textStyle(TextStyler value) { + return container(BoxStyler().textStyle(value)); + } + + RemixSelectContentStyler image(DecorationImageMix value) { + return container(BoxStyler().image(value)); + } + + RemixSelectContentStyler shape(ShapeBorderMix value) { + return container(BoxStyler().shape(value)); + } + + RemixSelectContentStyler backgroundImage( + ImageProvider image, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) { + return container( + BoxStyler().backgroundImage( + image, + fit: fit, + alignment: alignment, + repeat: repeat, + ), + ); + } + + RemixSelectContentStyler backgroundImageUrl( + String url, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) { + return container( + BoxStyler().backgroundImageUrl( + url, + fit: fit, + alignment: alignment, + repeat: repeat, + ), + ); + } + + RemixSelectContentStyler backgroundImageAsset( + String path, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) { + return container( + BoxStyler().backgroundImageAsset( + path, + fit: fit, + alignment: alignment, + repeat: repeat, + ), + ); + } + + RemixSelectContentStyler linearGradient({ + required List colors, + List? stops, + AlignmentGeometry? begin, + AlignmentGeometry? end, + TileMode? tileMode, + }) { + return container( + BoxStyler().linearGradient( + colors: colors, + stops: stops, + begin: begin, + end: end, + tileMode: tileMode, + ), + ); + } + + RemixSelectContentStyler radialGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? radius, + AlignmentGeometry? focal, + double? focalRadius, + TileMode? tileMode, + }) { + return container( + BoxStyler().radialGradient( + colors: colors, + stops: stops, + center: center, + radius: radius, + focal: focal, + focalRadius: focalRadius, + tileMode: tileMode, + ), + ); + } + + RemixSelectContentStyler sweepGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? startAngle, + double? endAngle, + TileMode? tileMode, + }) { + return container( + BoxStyler().sweepGradient( + colors: colors, + stops: stops, + center: center, + startAngle: startAngle, + endAngle: endAngle, + tileMode: tileMode, + ), + ); + } + + RemixSelectContentStyler foregroundLinearGradient({ + required List colors, + List? stops, + AlignmentGeometry? begin, + AlignmentGeometry? end, + TileMode? tileMode, + }) { + return container( + BoxStyler().foregroundLinearGradient( + colors: colors, + stops: stops, + begin: begin, + end: end, + tileMode: tileMode, + ), + ); + } + + RemixSelectContentStyler foregroundRadialGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? radius, + AlignmentGeometry? focal, + double? focalRadius, + TileMode? tileMode, + }) { + return container( + BoxStyler().foregroundRadialGradient( + colors: colors, + stops: stops, + center: center, + radius: radius, + focal: focal, + focalRadius: focalRadius, + tileMode: tileMode, + ), + ); + } + + RemixSelectContentStyler foregroundSweepGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? startAngle, + double? endAngle, + TileMode? tileMode, + }) { + return container( + BoxStyler().foregroundSweepGradient( + colors: colors, + stops: stops, + center: center, + startAngle: startAngle, + endAngle: endAngle, + tileMode: tileMode, + ), + ); + } + + RemixSelectContentStyler transform( + Matrix4 value, { + Alignment alignment = .center, + }) { + return container(BoxStyler().transform(value, alignment: alignment)); + } + + /// Sets the container. + RemixSelectContentStyler container(BoxStyler value) { + return merge(RemixSelectContentStyler(container: value)); + } + + /// Sets the containerEffects. + RemixSelectContentStyler containerEffects(RemixBoxEffectsMix value) { + return merge(RemixSelectContentStyler(containerEffects: value)); + } + + /// Sets the animation configuration. + @override + RemixSelectContentStyler animate(AnimationConfig value) { + return merge(RemixSelectContentStyler(animation: value)); + } + + /// Sets the style variants. + @override + RemixSelectContentStyler variants( + List> value, + ) { + return merge(RemixSelectContentStyler(variants: value)); + } + + /// Wraps with a widget modifier. + @override + RemixSelectContentStyler wrap(WidgetModifierConfig value) { + return merge(RemixSelectContentStyler(modifier: value)); + } + + /// Sets the widget modifier. + RemixSelectContentStyler modifier(WidgetModifierConfig value) { + return merge(RemixSelectContentStyler(modifier: value)); + } + + /// Merges with another [RemixSelectContentStyler]. + @override + RemixSelectContentStyler merge(RemixSelectContentStyler? other) { + return RemixSelectContentStyler.create( + container: MixOps.merge($container, other?.$container), + containerEffects: MixOps.merge( + $containerEffects, + other?.$containerEffects, + ), + variants: MixOps.mergeVariants($variants, other?.$variants), + modifier: MixOps.mergeModifier($modifier, other?.$modifier), + animation: MixOps.mergeAnimation($animation, other?.$animation), + ); + } + + /// Resolves to [StyleSpec] using [context]. + @override + StyleSpec resolve(BuildContext context) { + final spec = RemixSelectContentSpec( + container: MixOps.resolve(context, $container), + containerEffects: MixOps.resolve(context, $containerEffects), + ); + + return StyleSpec( + spec: spec, + animation: $animation, + widgetModifiers: $modifier?.resolve(context), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('container', $container)) + ..add(DiagnosticsProperty('containerEffects', $containerEffects)); + } + + @override + List get props => [ + $container, + $containerEffects, $animation, $modifier, $variants, @@ -1682,22 +2518,26 @@ class RemixSelectMenuItemStyler IconStyleMixin { final Prop>? $container; final Prop>? $text; + final Prop>? $indicator; final Prop>? $icon; const RemixSelectMenuItemStyler.create({ Prop>? container, Prop>? text, + Prop>? indicator, Prop>? icon, super.variants, super.modifier, super.animation, }) : $container = container, $text = text, + $indicator = indicator, $icon = icon; RemixSelectMenuItemStyler({ FlexBoxStyler? container, TextStyler? text, + BoxStyler? indicator, IconStyler? icon, AnimationConfig? animation, WidgetModifierConfig? modifier, @@ -1705,6 +2545,7 @@ class RemixSelectMenuItemStyler }) : this.create( container: Prop.maybeMix(container), text: Prop.maybeMix(text), + indicator: Prop.maybeMix(indicator), icon: Prop.maybeMix(icon), variants: variants, modifier: modifier, @@ -1715,6 +2556,8 @@ class RemixSelectMenuItemStyler RemixSelectMenuItemStyler().container(value); factory RemixSelectMenuItemStyler.text(TextStyler value) => RemixSelectMenuItemStyler().text(value); + factory RemixSelectMenuItemStyler.indicator(BoxStyler value) => + RemixSelectMenuItemStyler().indicator(value); factory RemixSelectMenuItemStyler.icon(IconStyler value) => RemixSelectMenuItemStyler().icon(value); factory RemixSelectMenuItemStyler.color(Color value) => @@ -2274,6 +3117,11 @@ class RemixSelectMenuItemStyler return merge(RemixSelectMenuItemStyler(text: value)); } + /// Sets the indicator. + RemixSelectMenuItemStyler indicator(BoxStyler value) { + return merge(RemixSelectMenuItemStyler(indicator: value)); + } + /// Sets the icon. @override RemixSelectMenuItemStyler icon(IconStyler value) { @@ -2311,6 +3159,7 @@ class RemixSelectMenuItemStyler return RemixSelectMenuItemStyler.create( container: MixOps.merge($container, other?.$container), text: MixOps.merge($text, other?.$text), + indicator: MixOps.merge($indicator, other?.$indicator), icon: MixOps.merge($icon, other?.$icon), variants: MixOps.mergeVariants($variants, other?.$variants), modifier: MixOps.mergeModifier($modifier, other?.$modifier), @@ -2324,6 +3173,7 @@ class RemixSelectMenuItemStyler final spec = RemixSelectMenuItemSpec( container: MixOps.resolve(context, $container), text: MixOps.resolve(context, $text), + indicator: MixOps.resolve(context, $indicator), icon: MixOps.resolve(context, $icon), ); @@ -2340,6 +3190,7 @@ class RemixSelectMenuItemStyler properties ..add(DiagnosticsProperty('container', $container)) ..add(DiagnosticsProperty('text', $text)) + ..add(DiagnosticsProperty('indicator', $indicator)) ..add(DiagnosticsProperty('icon', $icon)); } @@ -2347,6 +3198,7 @@ class RemixSelectMenuItemStyler List get props => [ $container, $text, + $indicator, $icon, $animation, $modifier, diff --git a/packages/remix/lib/src/components/select/select_spec.dart b/packages/remix/lib/src/components/select/select_spec.dart index 18506226..719add4e 100644 --- a/packages/remix/lib/src/components/select/select_spec.dart +++ b/packages/remix/lib/src/components/select/select_spec.dart @@ -6,6 +6,8 @@ class RemixSelectSpec with _$RemixSelectSpec { @override final StyleSpec trigger; @override + final StyleSpec content; + @override @MixableField(forwardStyler: true, stylerSurface: BoxSpec) final StyleSpec menuContainer; @override @@ -13,9 +15,11 @@ class RemixSelectSpec with _$RemixSelectSpec { const RemixSelectSpec({ StyleSpec? trigger, + StyleSpec? content, StyleSpec? menuContainer, StyleSpec? item, }) : trigger = trigger ?? const StyleSpec(spec: RemixSelectTriggerSpec()), + content = content ?? const StyleSpec(spec: RemixSelectContentSpec()), item = item ?? const StyleSpec(spec: RemixSelectMenuItemSpec()), menuContainer = menuContainer ?? const StyleSpec(spec: FlexBoxSpec()); } @@ -31,15 +35,75 @@ class RemixSelectTriggerSpec with _$RemixSelectTriggerSpec { @override final StyleSpec label; @override + final StyleSpec placeholder; + @override final StyleSpec icon; + @override + final StyleSpec chevron; + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? containerEffects; + @override + final double? chevronOpacity; + @override + final double? placeholderOpacity; const RemixSelectTriggerSpec({ StyleSpec? container, StyleSpec? label, + StyleSpec? placeholder, StyleSpec? icon, + StyleSpec? chevron, + this.containerEffects, + this.chevronOpacity, + this.placeholderOpacity, }) : container = container ?? const StyleSpec(spec: FlexBoxSpec()), label = label ?? const StyleSpec(spec: TextSpec()), - icon = icon ?? const StyleSpec(spec: IconSpec()); + placeholder = placeholder ?? const StyleSpec(spec: TextSpec()), + icon = icon ?? const StyleSpec(spec: IconSpec()), + chevron = chevron ?? const StyleSpec(spec: IconSpec()); + + @override + RemixSelectTriggerSpec lerp(RemixSelectTriggerSpec? other, double t) { + final generated = super.lerp(other, t); + if (other == null) return generated; + return generated.copyWith( + containerEffects: RemixBoxEffectsSpec.lerpNullable( + containerEffects, + other.containerEffects, + t, + ), + ); + } +} + +/// Resolved visual values for the select popup content. +@MixableSpec(extraStylerMixins: [RemixBoxStylerMixin]) +class RemixSelectContentSpec with _$RemixSelectContentSpec { + @override + @MixableField(forwardStyler: true) + final StyleSpec container; + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? containerEffects; + + const RemixSelectContentSpec({ + StyleSpec? container, + this.containerEffects, + }) : container = container ?? const StyleSpec(spec: BoxSpec()); + + @override + RemixSelectContentSpec lerp(RemixSelectContentSpec? other, double t) { + final generated = super.lerp(other, t); + if (other == null) return generated; + return generated.copyWith( + containerEffects: RemixBoxEffectsSpec.lerpNullable( + containerEffects, + other.containerEffects, + t, + ), + ); + } } /// Resolved visual values for a select menu item. @@ -51,13 +115,17 @@ class RemixSelectMenuItemSpec with _$RemixSelectMenuItemSpec { @override final StyleSpec text; @override + final StyleSpec indicator; + @override final StyleSpec icon; const RemixSelectMenuItemSpec({ StyleSpec? container, StyleSpec? text, + StyleSpec? indicator, StyleSpec? icon, }) : container = container ?? const StyleSpec(spec: FlexBoxSpec()), text = text ?? const StyleSpec(spec: TextSpec()), + indicator = indicator ?? const StyleSpec(spec: BoxSpec()), icon = icon ?? const StyleSpec(spec: IconSpec()); } diff --git a/packages/remix/lib/src/components/select/select_style.dart b/packages/remix/lib/src/components/select/select_style.dart index 1db11fb9..695ba942 100644 --- a/packages/remix/lib/src/components/select/select_style.dart +++ b/packages/remix/lib/src/components/select/select_style.dart @@ -23,8 +23,8 @@ extension RemixSelectStylerRemixHelpers on RemixSelectStyler { required List> items, T? selectedValue, OverlayPositionConfig positioning = const OverlayPositionConfig( - targetAnchor: .bottomCenter, - followerAnchor: .topCenter, + side: .bottom, + alignment: .center, ), ValueChanged? onChanged, VoidCallback? onOpen, diff --git a/packages/remix/lib/src/components/select/select_widget.dart b/packages/remix/lib/src/components/select/select_widget.dart index 140599e5..3f7a7555 100644 --- a/packages/remix/lib/src/components/select/select_widget.dart +++ b/packages/remix/lib/src/components/select/select_widget.dart @@ -78,8 +78,8 @@ class RemixSelect extends StatefulWidget { required this.items, this.selectedValue, this.positioning = const OverlayPositionConfig( - targetAnchor: .bottomCenter, - followerAnchor: .topCenter, + side: .bottom, + alignment: .center, ), this.onChanged, this.onOpen, @@ -175,6 +175,7 @@ class _RemixSelectState extends State> controller: animationController, duration: const Duration(milliseconds: 150), curve: Curves.easeInOut, + content: spec.content, menuContainer: menuContainerSpec, children: widget.items .map( @@ -254,6 +255,7 @@ class _RemixSelectState extends State> return _RemixSelectTriggerWidget( trigger: widget.trigger, displayLabel: _getDisplayLabel(), + isPlaceholder: widget.selectedValue == null, isOpen: state.isOpen, styleSpec: triggerSpec, ); @@ -269,6 +271,7 @@ class _AnimatedOverlayMenu extends StatefulWidget { required this.controller, required this.duration, required this.curve, + required this.content, required this.menuContainer, required this.children, }); @@ -276,6 +279,7 @@ class _AnimatedOverlayMenu extends StatefulWidget { final AnimationController controller; final Duration duration; final Curve curve; + final StyleSpec content; final StyleSpec menuContainer; final List children; @@ -322,9 +326,16 @@ class _AnimatedOverlayMenuState extends State<_AnimatedOverlayMenu> { scale: scaleAnimation.value, child: Opacity( opacity: fadeAnimation.value, - child: ColumnBox( - styleSpec: widget.menuContainer, - children: widget.children, + child: StyleSpecBuilder( + styleSpec: widget.content, + builder: (context, spec) => RemixBoxWithEffects( + styleSpec: spec.container, + containerEffects: spec.containerEffects, + child: ColumnBox( + styleSpec: widget.menuContainer, + children: widget.children, + ), + ), ), ), ); @@ -342,12 +353,14 @@ class _RemixSelectTriggerWidget extends StatelessWidget { const _RemixSelectTriggerWidget({ required this.trigger, required this.displayLabel, + required this.isPlaceholder, required this.isOpen, required this.styleSpec, }); final RemixSelectTrigger trigger; final String displayLabel; + final bool isPlaceholder; final bool isOpen; final StyleSpec styleSpec; @@ -356,18 +369,33 @@ class _RemixSelectTriggerWidget extends StatelessWidget { return StyleSpecBuilder( styleSpec: styleSpec, builder: (context, spec) { - return RowBox( + return RemixFlexBoxWithEffects( styleSpec: spec.container, + direction: Axis.horizontal, + containerEffects: spec.containerEffects, children: [ if (trigger.icon != null) StyledIcon(icon: trigger.icon!, styleSpec: spec.icon), // ignore: avoid-flexible-outside-flex - Expanded(child: StyledText(displayLabel, styleSpec: spec.label)), - StyledIcon( - icon: isOpen - ? Icons.keyboard_arrow_up - : Icons.keyboard_arrow_down, - styleSpec: spec.icon, + Expanded( + child: Opacity( + opacity: isPlaceholder ? spec.placeholderOpacity ?? 1 : 1, + child: StyledText( + displayLabel, + styleSpec: isPlaceholder ? spec.placeholder : spec.label, + ), + ), + ), + Transform.rotate( + angle: isOpen ? math.pi : 0, + child: Opacity( + opacity: spec.chevronOpacity ?? 1, + child: RemixPathIcon( + key: const ValueKey('fortal-select-chevron'), + glyph: RemixPathGlyph.chevronDown, + styleSpec: spec.chevron, + ), + ), ), ], ); @@ -427,7 +455,14 @@ class _RemixSelectItemWidget extends StatelessWidget { child: StyledText(data.label, styleSpec: spec.text), ), if (states.isSelected) - StyledIcon(icon: Icons.check, styleSpec: spec.icon), + Box( + styleSpec: spec.indicator, + child: RemixPathIcon( + key: const ValueKey('fortal-select-indicator'), + glyph: RemixPathGlyph.thickCheck, + styleSpec: spec.icon, + ), + ), ], ), ), diff --git a/packages/remix/lib/src/components/slider/fortal_slider_styles.dart b/packages/remix/lib/src/components/slider/fortal_slider_styles.dart index 5aa9188e..a44641c8 100644 --- a/packages/remix/lib/src/components/slider/fortal_slider_styles.dart +++ b/packages/remix/lib/src/components/slider/fortal_slider_styles.dart @@ -1,27 +1,12 @@ part of 'slider.dart'; -/// Fortal slider size presets. -enum FortalSliderSize { - /// Compact slider with a 13 px thumb and 6 px track. - size1, +/// Radix Themes slider sizes. +enum FortalSliderSize { size1, size2, size3 } - /// Default slider with a 16 px thumb and 8 px track. - size2, +/// Radix Themes slider variants. +enum FortalSliderVariant { classic, surface, soft } - /// Large slider with a 19 px thumb and 10 px track. - size3, -} - -/// Fortal slider color variants. -enum FortalSliderVariant { - /// Neutral track with the active accent indicator. - surface, - - /// Softer accent treatment for lower-emphasis controls. - soft, -} - -/// Fortal-themed preset for [RemixSlider]. +/// Fortal recipe for a multi-thumb slider. @MixWidget( name: 'FortalSlider', target: RemixSlider.new, @@ -30,75 +15,336 @@ enum FortalSliderVariant { RemixSliderStyler fortalSliderStyler({ FortalSliderVariant variant = .surface, FortalSliderSize size = .size2, + bool highContrast = false, }) { - return switch (variant) { - .surface => _fortalSliderSurfaceStyler(size), - .soft => _fortalSliderSoftStyler(size), - }; -} - -RemixSliderStyler _fortalSliderBaseStyler(FortalSliderSize size) { - return RemixSliderStyler() + final metrics = _fortalSliderMetrics(size); + final radius = BorderRadiusMix.all(metrics.trackRadius); + final thumbRadius = BorderRadiusMix.all(FortalTokens.radius1OrThumb()); + final base = RemixSliderStyler() + .track(.borderRadius(radius)) + .range(.borderRadius(radius)) .thumb( - BoxStyler() - .color(Colors.white) - .shapeCircle( - side: BorderSideMix() - .color(FortalTokens.grayA6()) - .strokeAlign(BorderSide.strokeAlignOutside), - ) - .shadow( - BoxShadowMix() - .blurRadius(2) - .offset(x: 0, y: 1) - .color(FortalTokens.gray7()), - ), + .size(metrics.thumbSize, metrics.thumbSize).borderRadius(thumbRadius), ) - .merge(_fortalSliderSizeStyler(size)); -} - -RemixSliderStyler _fortalSliderSurfaceStyler([FortalSliderSize size = .size2]) { - return _fortalSliderBaseStyler(size) - .trackColor(FortalTokens.gray3()) - .rangeColor(FortalTokens.accentIndicator()) - .onDisabled( - RemixSliderStyler() - .trackColor(FortalTokens.accentTrack()) - .rangeColor(FortalTokens.accentIndicator()) - .thumbColor(FortalTokens.colorSurface()), + .thickness(metrics.trackSize) + .thumbFocusEffects( + RemixBoxEffectsMix( + overContent: RemixBoxEffectLayerMix( + shadows: [ + RemixBoxShadowMix(color: FortalTokens.accent3(), spreadRadius: 3), + RemixBoxShadowMix(color: FortalTokens.focus8(), spreadRadius: 5), + ], + ), + ), ); -} -RemixSliderStyler _fortalSliderSoftStyler([FortalSliderSize size = .size2]) { - return _fortalSliderBaseStyler(size) - .trackColor(FortalTokens.gray4()) - .rangeColor(FortalTokens.accent6()) + final styled = switch (variant) { + .classic => _fortalSliderClassic( + base, + trackRadius: radius, + thumbRadius: thumbRadius, + highContrast: highContrast, + ), + .surface => _fortalSliderSurface( + base, + trackRadius: radius, + thumbRadius: thumbRadius, + highContrast: highContrast, + ), + .soft => _fortalSliderSoft( + base, + trackRadius: radius, + thumbRadius: thumbRadius, + highContrast: highContrast, + ), + }; + return styled .onDisabled( - RemixSliderStyler() - .trackColor(FortalTokens.accent4()) - .rangeColor(FortalTokens.accent9()) - .thumbColor(FortalTokens.accent9()), + _fortalSliderDisabled( + variant, + trackRadius: radius, + thumbRadius: thumbRadius, + ), + ) + .variant( + ContextVariant( + 'fortalSliderDisabledDarkBlend', + (context) => FortalTheme.of(context).isDark, + ), + RemixSliderStyler().onDisabled(.blendMode(BlendMode.screen)), ); } -RemixSliderStyler _fortalSliderSizeStyler(FortalSliderSize size) { - return switch (size) { - .size1 => RemixSliderStyler( - thumb: BoxStyler().size(13.0, 13.0), - trackWidth: 6.0, - rangeWidth: 6.0, - ), - .size2 => RemixSliderStyler( - thumb: BoxStyler().size(16.0, 16.0), - trackWidth: 8.0, - rangeWidth: 8.0, - ), - .size3 => RemixSliderStyler( - thumb: BoxStyler().size(19.0, 19.0), - trackWidth: 10.0, - rangeWidth: 10.0, - ), +RemixSliderStyler _fortalSliderSurface( + RemixSliderStyler base, { + required BorderRadiusMix trackRadius, + required BorderRadiusMix thumbRadius, + required bool highContrast, +}) => base + .track(.color(FortalTokens.grayA3())) + .range(.color(FortalTokens.accentTrack())) + .thumbColor(Colors.white) + .trackEffects( + RemixBoxEffectsMix( + behindContent: RemixBoxEffectLayerMix( + shadows: [_fortalSliderInset(FortalTokens.grayA5())], + ), + ), + ) + .rangeEffects( + RemixBoxEffectsMix( + behindContent: RemixBoxEffectLayerMix( + gradients: _fortalSliderHighContrastGradients(highContrast), + shadows: [_fortalSliderInset(FortalTokens.grayA5())], + ), + ), + ) + .thumb( + BoxStyler().boxShadows([ + BoxShadowMix(color: FortalTokens.blackA4(), spreadRadius: 1), + ]), + ); + +RemixSliderStyler _fortalSliderClassic( + RemixSliderStyler base, { + required BorderRadiusMix trackRadius, + required BorderRadiusMix thumbRadius, + required bool highContrast, +}) => base + .track(.color(FortalTokens.grayA3())) + .range(.color(FortalTokens.accentTrack())) + .thumbColor(Colors.white) + .trackEffects( + RemixBoxEffectsMix( + overContent: RemixBoxEffectLayerMix( + shadowToken: FortalTokens.shadow1Layers, + ), + ), + ) + .rangeEffects( + RemixBoxEffectsMix( + behindContent: RemixBoxEffectLayerMix( + gradients: _fortalSliderHighContrastGradients(highContrast), + shadows: highContrast + ? [ + _fortalSliderInset(FortalTokens.grayA3()), + _fortalSliderInset(FortalTokens.blackA2()), + _fortalSliderInset( + FortalTokens.blackA2(), + offset: const Offset(0, 1.5), + blurRadius: 2, + spreadRadius: 0, + ), + ] + : [ + _fortalSliderInset(FortalTokens.grayA3()), + _fortalSliderInset(FortalTokens.accentA4()), + _fortalSliderInset(FortalTokens.blackA1()), + _fortalSliderInset( + FortalTokens.blackA2(), + offset: const Offset(0, 1.5), + blurRadius: 2, + spreadRadius: 0, + ), + ], + ), + ), + ) + .thumb( + BoxStyler().boxShadows([ + BoxShadowMix(color: FortalTokens.blackA3(), spreadRadius: 1), + BoxShadowMix( + color: FortalTokens.blackA1(), + offset: const Offset(0, 1), + blurRadius: 3, + ), + BoxShadowMix( + color: FortalTokens.blackA1(), + offset: const Offset(0, 2), + blurRadius: 4, + spreadRadius: -1, + ), + ]), + ); + +RemixSliderStyler _fortalSliderSoft( + RemixSliderStyler base, { + required BorderRadiusMix trackRadius, + required BorderRadiusMix thumbRadius, + required bool highContrast, +}) => base + .track(.color(FortalTokens.grayA4())) + .range(.color(FortalTokens.accent6())) + .thumbColor(Colors.white) + .trackEffects( + RemixBoxEffectsMix( + behindContent: RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix( + colors: [FortalTokens.whiteA1(), FortalTokens.whiteA1()], + ), + ], + ), + ), + ) + .rangeEffects( + RemixBoxEffectsMix( + behindContent: RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix( + colors: [FortalTokens.accentA5(), FortalTokens.accentA5()], + ), + ..._fortalSliderHighContrastGradients(highContrast), + ], + ), + ), + ) + .thumb( + BoxStyler().boxShadows([ + BoxShadowMix(color: FortalTokens.blackA3(), spreadRadius: 1), + BoxShadowMix(color: FortalTokens.grayA2(), spreadRadius: 1), + BoxShadowMix(color: FortalTokens.accentA2(), spreadRadius: 1), + BoxShadowMix( + color: FortalTokens.grayA4(), + offset: const Offset(0, 1), + blurRadius: 2, + ), + BoxShadowMix( + color: FortalTokens.grayA3(), + offset: const Offset(0, 1), + blurRadius: 3, + spreadRadius: -0.5, + ), + ]), + ); + +RemixSliderStyler _fortalSliderDisabled( + FortalSliderVariant variant, { + required BorderRadiusMix trackRadius, + required BorderRadiusMix thumbRadius, +}) { + final track = switch (variant) { + .surface => + RemixSliderStyler() + .track(.color(FortalTokens.grayA3())) + .trackEffects( + RemixBoxEffectsMix( + behindContent: RemixBoxEffectLayerMix( + shadows: [_fortalSliderInset(FortalTokens.grayA4())], + ), + ), + ), + .classic => + RemixSliderStyler() + .track(.color(FortalTokens.grayA3())) + .trackEffects( + RemixBoxEffectsMix( + overContent: RemixBoxEffectLayerMix( + shadowToken: FortalTokens.sliderClassicDisabledTrackShadows, + ), + ), + ), + .soft => + RemixSliderStyler() + .track(.color(FortalTokens.grayA4())) + .trackEffects( + RemixBoxEffectsMix( + behindContent: RemixBoxEffectLayerMix(gradients: const []), + ), + ), }; + return track + .range(.color(Colors.transparent)) + .thumbColor(FortalTokens.gray1()) + .rangeEffects( + RemixBoxEffectsMix( + behindContent: RemixBoxEffectLayerMix( + gradients: const [], + shadows: const [], + ), + ), + ) + .rangeEffects( + RemixBoxEffectsMix( + overContent: RemixBoxEffectLayerMix( + gradients: const [], + shadows: const [], + ), + ), + ) + .thumb( + BoxStyler().boxShadows([ + BoxShadowMix( + color: variant == .soft + ? FortalTokens.gray5() + : FortalTokens.gray6(), + spreadRadius: 1, + ), + ]), + ) + .thumbFocusEffects( + RemixBoxEffectsMix( + overContent: RemixBoxEffectLayerMix(shadows: const []), + ), + ) + .blendMode(BlendMode.multiply); +} + +RemixBoxShadowMix _fortalSliderInset( + Color color, { + Offset offset = Offset.zero, + double blurRadius = 0, + double spreadRadius = 1, +}) => RemixBoxShadowMix( + kind: RemixBoxShadowKind.inset, + color: color, + offset: offset, + blurRadius: blurRadius, + spreadRadius: spreadRadius, +); + +List _fortalSliderHighContrastGradients( + bool highContrast, +) => highContrast + ? [ + RemixLinearGradientMix( + colors: [ + FortalTokens.sliderHighContrastOverlay(), + FortalTokens.sliderHighContrastOverlay(), + ], + ), + ] + : const []; + +class _FortalSliderMetrics { + const _FortalSliderMetrics({ + required this.trackSize, + required this.thumbSize, + required this.trackRadius, + }); + + final double trackSize; + final double thumbSize; + final Radius trackRadius; } -/// Fortal-themed preset for [RemixSlider]. +_FortalSliderMetrics _fortalSliderMetrics(FortalSliderSize size) => + switch (size) { + .size1 => _FortalSliderMetrics( + trackSize: FortalTokens.sliderTrackSize1(), + thumbSize: FortalTokens.sliderThumbSize1(), + trackRadius: FortalTokens.sliderTrackRadius1(), + ), + .size2 => _FortalSliderMetrics( + trackSize: FortalTokens.sliderTrackSize2(), + thumbSize: FortalTokens.sliderThumbSize2(), + trackRadius: FortalTokens.sliderTrackRadius2(), + ), + .size3 => _FortalSliderMetrics( + trackSize: FortalTokens.sliderTrackSize3(), + thumbSize: FortalTokens.sliderThumbSize3(), + trackRadius: FortalTokens.sliderTrackRadius3(), + ), + }; + +/// Fortal slider with Radix-owned size, variant, and component overrides. diff --git a/packages/remix/lib/src/components/slider/slider.dart b/packages/remix/lib/src/components/slider/slider.dart index 36c72ddb..f95e8751 100644 --- a/packages/remix/lib/src/components/slider/slider.dart +++ b/packages/remix/lib/src/components/slider/slider.dart @@ -11,6 +11,8 @@ import 'package:naked_ui/naked_ui.dart'; import '../../utilities/remix_style.dart'; import '../../fortal/fortal.dart'; +import '../../rendering/remix_blend_mode.dart'; +import '../../rendering/remix_box_effects.dart'; part 'slider_spec.dart'; part 'slider_style.dart'; diff --git a/packages/remix/lib/src/components/slider/slider.g.dart b/packages/remix/lib/src/components/slider/slider.g.dart index de951155..afd37d63 100644 --- a/packages/remix/lib/src/components/slider/slider.g.dart +++ b/packages/remix/lib/src/components/slider/slider.g.dart @@ -7,50 +7,89 @@ part of 'slider.dart'; // ************************************************************************** mixin _$RemixSliderSpec implements Spec, Diagnosticable { - StyleSpec get thumb; + StyleSpec get track; Color get trackColor; double get trackWidth; + RemixBoxEffectsSpec? get trackEffects; + StyleSpec get range; Color get rangeColor; double get rangeWidth; + RemixBoxEffectsSpec? get rangeEffects; + StyleSpec get thumb; + RemixBoxEffectsSpec? get thumbEffects; + RemixBoxEffectsSpec? get thumbFocusEffects; + BlendMode? get blendMode; @override Type get type => RemixSliderSpec; @override RemixSliderSpec copyWith({ - StyleSpec? thumb, + StyleSpec? track, Color? trackColor, double? trackWidth, + RemixBoxEffectsSpec? trackEffects, + StyleSpec? range, Color? rangeColor, double? rangeWidth, + RemixBoxEffectsSpec? rangeEffects, + StyleSpec? thumb, + RemixBoxEffectsSpec? thumbEffects, + RemixBoxEffectsSpec? thumbFocusEffects, + BlendMode? blendMode, }) { return RemixSliderSpec( - thumb: thumb ?? this.thumb, + track: track ?? this.track, trackColor: trackColor ?? this.trackColor, trackWidth: trackWidth ?? this.trackWidth, + trackEffects: trackEffects ?? this.trackEffects, + range: range ?? this.range, rangeColor: rangeColor ?? this.rangeColor, rangeWidth: rangeWidth ?? this.rangeWidth, + rangeEffects: rangeEffects ?? this.rangeEffects, + thumb: thumb ?? this.thumb, + thumbEffects: thumbEffects ?? this.thumbEffects, + thumbFocusEffects: thumbFocusEffects ?? this.thumbFocusEffects, + blendMode: blendMode ?? this.blendMode, ); } @override RemixSliderSpec lerp(RemixSliderSpec? other, double t) { return RemixSliderSpec( - thumb: thumb.lerp(other?.thumb, t), + track: track.lerp(other?.track, t), trackColor: MixOps.lerp(trackColor, other?.trackColor, t), trackWidth: MixOps.lerp(trackWidth, other?.trackWidth, t), + trackEffects: MixOps.lerpSnap(trackEffects, other?.trackEffects, t), + range: range.lerp(other?.range, t), rangeColor: MixOps.lerp(rangeColor, other?.rangeColor, t), rangeWidth: MixOps.lerp(rangeWidth, other?.rangeWidth, t), + rangeEffects: MixOps.lerpSnap(rangeEffects, other?.rangeEffects, t), + thumb: thumb.lerp(other?.thumb, t), + thumbEffects: MixOps.lerpSnap(thumbEffects, other?.thumbEffects, t), + thumbFocusEffects: MixOps.lerpSnap( + thumbFocusEffects, + other?.thumbFocusEffects, + t, + ), + blendMode: MixOps.lerpSnap(blendMode, other?.blendMode, t), ); } @override List get props => [ - thumb, + track, trackColor, trackWidth, + trackEffects, + range, rangeColor, rangeWidth, + rangeEffects, + thumb, + thumbEffects, + thumbFocusEffects, + blendMode, ]; @override @@ -93,11 +132,18 @@ mixin _$RemixSliderSpec implements Spec, Diagnosticable { @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { properties - ..add(DiagnosticsProperty('thumb', thumb)) + ..add(DiagnosticsProperty('track', track)) ..add(ColorProperty('trackColor', trackColor)) ..add(DoubleProperty('trackWidth', trackWidth)) + ..add(DiagnosticsProperty('trackEffects', trackEffects)) + ..add(DiagnosticsProperty('range', range)) ..add(ColorProperty('rangeColor', rangeColor)) - ..add(DoubleProperty('rangeWidth', rangeWidth)); + ..add(DoubleProperty('rangeWidth', rangeWidth)) + ..add(DiagnosticsProperty('rangeEffects', rangeEffects)) + ..add(DiagnosticsProperty('thumb', thumb)) + ..add(DiagnosticsProperty('thumbEffects', thumbEffects)) + ..add(DiagnosticsProperty('thumbFocusEffects', thumbFocusEffects)) + ..add(EnumProperty('blendMode', blendMode)); } } @@ -110,7 +156,7 @@ typedef _$RemixSliderSpecMethods = _$RemixSliderSpec; // ignore: unused_element // MixWidgetGenerator // ************************************************************************** -/// Fortal-themed preset for [RemixSlider]. +/// Fortal recipe for a multi-thumb slider. class FortalSlider extends StatelessWidget { const FortalSlider({ super.key, @@ -129,7 +175,22 @@ class FortalSlider extends StatelessWidget { this.snapDivisions, }); - /// Neutral track with the active accent indicator. + const FortalSlider.classic({ + super.key, + this.size = .size2, + required this.value, + this.onChanged, + this.onChangeStart, + this.onChangeEnd, + this.min = 0.0, + this.max = 1.0, + this.enabled = true, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.snapDivisions, + }) : variant = FortalSliderVariant.classic; + const FortalSlider.surface({ super.key, this.size = .size2, @@ -146,7 +207,6 @@ class FortalSlider extends StatelessWidget { this.snapDivisions, }) : variant = FortalSliderVariant.surface; - /// Softer accent treatment for lower-emphasis controls. const FortalSlider.soft({ super.key, this.size = .size2, @@ -215,57 +275,106 @@ class FortalSlider extends StatelessWidget { class RemixSliderStyler extends MixStyler with RemixBoxStylerMixin { - final Prop>? $thumb; + final Prop>? $track; final Prop? $trackColor; final Prop? $trackWidth; + final Prop? $trackEffects; + final Prop>? $range; final Prop? $rangeColor; final Prop? $rangeWidth; + final Prop? $rangeEffects; + final Prop>? $thumb; + final Prop? $thumbEffects; + final Prop? $thumbFocusEffects; + final Prop? $blendMode; const RemixSliderStyler.create({ - Prop>? thumb, + Prop>? track, Prop? trackColor, Prop? trackWidth, + Prop? trackEffects, + Prop>? range, Prop? rangeColor, Prop? rangeWidth, + Prop? rangeEffects, + Prop>? thumb, + Prop? thumbEffects, + Prop? thumbFocusEffects, + Prop? blendMode, super.variants, super.modifier, super.animation, - }) : $thumb = thumb, + }) : $track = track, $trackColor = trackColor, $trackWidth = trackWidth, + $trackEffects = trackEffects, + $range = range, $rangeColor = rangeColor, - $rangeWidth = rangeWidth; + $rangeWidth = rangeWidth, + $rangeEffects = rangeEffects, + $thumb = thumb, + $thumbEffects = thumbEffects, + $thumbFocusEffects = thumbFocusEffects, + $blendMode = blendMode; RemixSliderStyler({ - BoxStyler? thumb, + BoxStyler? track, Color? trackColor, double? trackWidth, + RemixBoxEffectsMix? trackEffects, + BoxStyler? range, Color? rangeColor, double? rangeWidth, + RemixBoxEffectsMix? rangeEffects, + BoxStyler? thumb, + RemixBoxEffectsMix? thumbEffects, + RemixBoxEffectsMix? thumbFocusEffects, + BlendMode? blendMode, AnimationConfig? animation, WidgetModifierConfig? modifier, List>? variants, }) : this.create( - thumb: Prop.maybeMix(thumb), + track: Prop.maybeMix(track), trackColor: Prop.maybe(trackColor), trackWidth: Prop.maybe(trackWidth), + trackEffects: Prop.maybeMix(trackEffects), + range: Prop.maybeMix(range), rangeColor: Prop.maybe(rangeColor), rangeWidth: Prop.maybe(rangeWidth), + rangeEffects: Prop.maybeMix(rangeEffects), + thumb: Prop.maybeMix(thumb), + thumbEffects: Prop.maybeMix(thumbEffects), + thumbFocusEffects: Prop.maybeMix(thumbFocusEffects), + blendMode: Prop.maybe(blendMode), variants: variants, modifier: modifier, animation: animation, ); - factory RemixSliderStyler.thumb(BoxStyler value) => - RemixSliderStyler().thumb(value); + factory RemixSliderStyler.track(BoxStyler value) => + RemixSliderStyler().track(value); factory RemixSliderStyler.trackColor(Color value) => RemixSliderStyler().trackColor(value); factory RemixSliderStyler.trackWidth(double value) => RemixSliderStyler().trackWidth(value); + factory RemixSliderStyler.trackEffects(RemixBoxEffectsMix value) => + RemixSliderStyler().trackEffects(value); + factory RemixSliderStyler.range(BoxStyler value) => + RemixSliderStyler().range(value); factory RemixSliderStyler.rangeColor(Color value) => RemixSliderStyler().rangeColor(value); factory RemixSliderStyler.rangeWidth(double value) => RemixSliderStyler().rangeWidth(value); + factory RemixSliderStyler.rangeEffects(RemixBoxEffectsMix value) => + RemixSliderStyler().rangeEffects(value); + factory RemixSliderStyler.thumb(BoxStyler value) => + RemixSliderStyler().thumb(value); + factory RemixSliderStyler.thumbEffects(RemixBoxEffectsMix value) => + RemixSliderStyler().thumbEffects(value); + factory RemixSliderStyler.thumbFocusEffects(RemixBoxEffectsMix value) => + RemixSliderStyler().thumbFocusEffects(value); + factory RemixSliderStyler.blendMode(BlendMode value) => + RemixSliderStyler().blendMode(value); factory RemixSliderStyler.alignment(AlignmentGeometry value) => RemixSliderStyler().alignment(value); factory RemixSliderStyler.padding(EdgeInsetsGeometryMix value) => @@ -738,9 +847,9 @@ class RemixSliderStyler extends MixStyler return thumb(BoxStyler().transform(value, alignment: alignment)); } - /// Sets the thumb. - RemixSliderStyler thumb(BoxStyler value) { - return merge(RemixSliderStyler(thumb: value)); + /// Sets the track. + RemixSliderStyler track(BoxStyler value) { + return merge(RemixSliderStyler(track: value)); } /// Sets the trackColor. @@ -753,6 +862,16 @@ class RemixSliderStyler extends MixStyler return merge(RemixSliderStyler(trackWidth: value)); } + /// Sets the trackEffects. + RemixSliderStyler trackEffects(RemixBoxEffectsMix value) { + return merge(RemixSliderStyler(trackEffects: value)); + } + + /// Sets the range. + RemixSliderStyler range(BoxStyler value) { + return merge(RemixSliderStyler(range: value)); + } + /// Sets the rangeColor. RemixSliderStyler rangeColor(Color value) { return merge(RemixSliderStyler(rangeColor: value)); @@ -763,6 +882,31 @@ class RemixSliderStyler extends MixStyler return merge(RemixSliderStyler(rangeWidth: value)); } + /// Sets the rangeEffects. + RemixSliderStyler rangeEffects(RemixBoxEffectsMix value) { + return merge(RemixSliderStyler(rangeEffects: value)); + } + + /// Sets the thumb. + RemixSliderStyler thumb(BoxStyler value) { + return merge(RemixSliderStyler(thumb: value)); + } + + /// Sets the thumbEffects. + RemixSliderStyler thumbEffects(RemixBoxEffectsMix value) { + return merge(RemixSliderStyler(thumbEffects: value)); + } + + /// Sets the thumbFocusEffects. + RemixSliderStyler thumbFocusEffects(RemixBoxEffectsMix value) { + return merge(RemixSliderStyler(thumbFocusEffects: value)); + } + + /// Sets the blendMode. + RemixSliderStyler blendMode(BlendMode value) { + return merge(RemixSliderStyler(blendMode: value)); + } + /// Sets the animation configuration. @override RemixSliderStyler animate(AnimationConfig value) { @@ -790,11 +934,21 @@ class RemixSliderStyler extends MixStyler @override RemixSliderStyler merge(RemixSliderStyler? other) { return RemixSliderStyler.create( - thumb: MixOps.merge($thumb, other?.$thumb), + track: MixOps.merge($track, other?.$track), trackColor: MixOps.merge($trackColor, other?.$trackColor), trackWidth: MixOps.merge($trackWidth, other?.$trackWidth), + trackEffects: MixOps.merge($trackEffects, other?.$trackEffects), + range: MixOps.merge($range, other?.$range), rangeColor: MixOps.merge($rangeColor, other?.$rangeColor), rangeWidth: MixOps.merge($rangeWidth, other?.$rangeWidth), + rangeEffects: MixOps.merge($rangeEffects, other?.$rangeEffects), + thumb: MixOps.merge($thumb, other?.$thumb), + thumbEffects: MixOps.merge($thumbEffects, other?.$thumbEffects), + thumbFocusEffects: MixOps.merge( + $thumbFocusEffects, + other?.$thumbFocusEffects, + ), + blendMode: MixOps.merge($blendMode, other?.$blendMode), variants: MixOps.mergeVariants($variants, other?.$variants), modifier: MixOps.mergeModifier($modifier, other?.$modifier), animation: MixOps.mergeAnimation($animation, other?.$animation), @@ -805,11 +959,18 @@ class RemixSliderStyler extends MixStyler @override StyleSpec resolve(BuildContext context) { final spec = RemixSliderSpec( - thumb: MixOps.resolve(context, $thumb), + track: MixOps.resolve(context, $track), trackColor: MixOps.resolve(context, $trackColor), trackWidth: MixOps.resolve(context, $trackWidth), + trackEffects: MixOps.resolve(context, $trackEffects), + range: MixOps.resolve(context, $range), rangeColor: MixOps.resolve(context, $rangeColor), rangeWidth: MixOps.resolve(context, $rangeWidth), + rangeEffects: MixOps.resolve(context, $rangeEffects), + thumb: MixOps.resolve(context, $thumb), + thumbEffects: MixOps.resolve(context, $thumbEffects), + thumbFocusEffects: MixOps.resolve(context, $thumbFocusEffects), + blendMode: MixOps.resolve(context, $blendMode), ); return StyleSpec( @@ -823,20 +984,34 @@ class RemixSliderStyler extends MixStyler void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties - ..add(DiagnosticsProperty('thumb', $thumb)) + ..add(DiagnosticsProperty('track', $track)) ..add(DiagnosticsProperty('trackColor', $trackColor)) ..add(DiagnosticsProperty('trackWidth', $trackWidth)) + ..add(DiagnosticsProperty('trackEffects', $trackEffects)) + ..add(DiagnosticsProperty('range', $range)) ..add(DiagnosticsProperty('rangeColor', $rangeColor)) - ..add(DiagnosticsProperty('rangeWidth', $rangeWidth)); + ..add(DiagnosticsProperty('rangeWidth', $rangeWidth)) + ..add(DiagnosticsProperty('rangeEffects', $rangeEffects)) + ..add(DiagnosticsProperty('thumb', $thumb)) + ..add(DiagnosticsProperty('thumbEffects', $thumbEffects)) + ..add(DiagnosticsProperty('thumbFocusEffects', $thumbFocusEffects)) + ..add(DiagnosticsProperty('blendMode', $blendMode)); } @override List get props => [ - $thumb, + $track, $trackColor, $trackWidth, + $trackEffects, + $range, $rangeColor, $rangeWidth, + $rangeEffects, + $thumb, + $thumbEffects, + $thumbFocusEffects, + $blendMode, $animation, $modifier, $variants, diff --git a/packages/remix/lib/src/components/slider/slider_spec.dart b/packages/remix/lib/src/components/slider/slider_spec.dart index 4eab825b..fcb6b7c5 100644 --- a/packages/remix/lib/src/components/slider/slider_spec.dart +++ b/packages/remix/lib/src/components/slider/slider_spec.dart @@ -1,58 +1,122 @@ part of 'slider.dart'; -const Size _remixSliderDefaultThumbSize = Size(16.0, 16.0); -const double _remixSliderDefaultTrackStrokeWidth = 8.0; +const Size _remixSliderDefaultThumbSize = Size.square(16); +const double _remixSliderDefaultTrackThickness = 8; +const Color _remixSliderDefaultTrackColor = MixColors.grey; +const Color _remixSliderDefaultRangeColor = MixColors.black; -const Color _defaultTrackColor = MixColors.grey; -const Color _defaultRangeColor = MixColors.black; - -/// Resolved visual values for a [RemixSlider]. -/// -/// A slider spec contains the thumb box style plus the track and range colors -/// and stroke widths used when painting the control. +/// Resolved visuals for a multi-thumb [RemixSlider]. @MixableSpec(extraStylerMixins: [RemixBoxStylerMixin]) class RemixSliderSpec with _$RemixSliderSpec { - /// Default thumb size used when the thumb style does not resolve a size. static const Size defaultThumbSize = _remixSliderDefaultThumbSize; - - /// Default stroke width for both the track and range. static const double defaultTrackStrokeWidth = - _remixSliderDefaultTrackStrokeWidth; + _remixSliderDefaultTrackThickness; - /// Resolved style for the slider thumb. + /// Preferred alias for [defaultTrackStrokeWidth]. + static const double defaultTrackThickness = _remixSliderDefaultTrackThickness; + + /// Layout boundary for the complete track. @override - @MixableField(forwardStyler: true) - final StyleSpec thumb; + final StyleSpec track; - /// Color of the unfilled slider track. + /// Established solid-color track API. @override final Color trackColor; - /// Stroke width of the unfilled slider track. + /// Established unfilled-track width API. @override final double trackWidth; - /// Color of the filled slider range. + /// Paint layers behind the track. + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? trackEffects; + + /// Layout boundary for the selected range. + @override + final StyleSpec range; + + /// Established solid-color selected-range API. @override final Color rangeColor; - /// Stroke width of the filled slider range. + /// Established selected-range width API. @override final double rangeWidth; - /// Creates resolved slider styling values. - RemixSliderSpec({ - StyleSpec? thumb, + /// Paint layers behind the selected range. + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? rangeEffects; + + /// Layout and decoration for every visual thumb. + @override + @MixableField(forwardStyler: true) + final StyleSpec thumb; + + /// Paint layers behind every thumb. + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? thumbEffects; + + /// Additional overlay painted only on the focused thumb. + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? thumbFocusEffects; + + /// Whole-subtree blend used for state recipes such as disabled sliders. + @override + final BlendMode? blendMode; + + const RemixSliderSpec({ + StyleSpec? track, Color? trackColor, double? trackWidth, + this.trackEffects, + StyleSpec? range, Color? rangeColor, double? rangeWidth, - }) : thumb = thumb ?? const StyleSpec(spec: BoxSpec()), - rangeColor = rangeColor ?? _defaultRangeColor, - trackWidth = trackWidth ?? _remixSliderDefaultTrackStrokeWidth, - rangeWidth = rangeWidth ?? _remixSliderDefaultTrackStrokeWidth, - trackColor = trackColor ?? _defaultTrackColor; + this.rangeEffects, + StyleSpec? thumb, + this.thumbEffects, + this.thumbFocusEffects, + this.blendMode, + }) : trackColor = trackColor ?? _remixSliderDefaultTrackColor, + trackWidth = trackWidth ?? _remixSliderDefaultTrackThickness, + rangeColor = rangeColor ?? _remixSliderDefaultRangeColor, + rangeWidth = rangeWidth ?? _remixSliderDefaultTrackThickness, + track = track ?? const StyleSpec(spec: BoxSpec()), + range = range ?? const StyleSpec(spec: BoxSpec()), + thumb = thumb ?? const StyleSpec(spec: BoxSpec()); /// Largest track or range width used for layout clearance. double get trackThickness => math.max(trackWidth, rangeWidth); + + @override + RemixSliderSpec lerp(RemixSliderSpec? other, double t) { + final generated = super.lerp(other, t); + if (other == null) return generated; + return generated.copyWith( + trackEffects: RemixBoxEffectsSpec.lerpNullable( + trackEffects, + other.trackEffects, + t, + ), + rangeEffects: RemixBoxEffectsSpec.lerpNullable( + rangeEffects, + other.rangeEffects, + t, + ), + thumbEffects: RemixBoxEffectsSpec.lerpNullable( + thumbEffects, + other.thumbEffects, + t, + ), + thumbFocusEffects: RemixBoxEffectsSpec.lerpNullable( + thumbFocusEffects, + other.thumbFocusEffects, + t, + ), + ); + } } diff --git a/packages/remix/lib/src/components/slider/slider_widget.dart b/packages/remix/lib/src/components/slider/slider_widget.dart index 86e63fae..6f87cd9a 100644 --- a/packages/remix/lib/src/components/slider/slider_widget.dart +++ b/packages/remix/lib/src/components/slider/slider_widget.dart @@ -86,22 +86,33 @@ class RemixSlider extends StatelessWidget { /// The focus node for the slider. final FocusNode? focusNode; + double get _effectiveStep { + final divisions = snapDivisions; + if (divisions != null) return (max - min) / divisions; + return (max - min) / 1000; + } + @override Widget build(BuildContext context) { // NakedSlider handles semantics internally, no outer Semantics needed return NakedSlider( - value: value, + values: [value], min: min, max: max, - onChanged: onChanged, - onDragStart: () => onChangeStart?.call(value), - onDragEnd: onChangeEnd, + step: _effectiveStep, + onChanged: onChanged == null + ? null + : (values) => onChanged!(values.single), + onChangeStart: onChangeStart == null + ? null + : (values) => onChangeStart!(values.single), + onChangeEnd: onChangeEnd == null + ? null + : (values) => onChangeEnd!(values.single), enabled: enabled, enableFeedback: enableFeedback, - focusNode: focusNode, - autofocus: autofocus, - direction: .horizontal, - divisions: snapDivisions, + focusNodes: focusNode == null ? null : [focusNode], + autofocusThumbIndex: autofocus ? 0 : null, builder: (context, state, _) { return RemixStyleSpecBuilder( style: style, @@ -127,7 +138,7 @@ class RemixSlider extends StatelessWidget { ); final horizontalPadding = horizontalOverflow * 2; - return SizedBox( + final slider = SizedBox( height: sliderHeight, child: LayoutBuilder( builder: (context, constraints) { @@ -175,28 +186,64 @@ class RemixSlider extends StatelessWidget { Padding( padding: .symmetric(horizontal: horizontalPadding / 2), child: SizedBox( - width: constraints.maxWidth, + width: double.infinity, height: constraints.maxHeight, - child: _AnimatedTrack( - value: normalizedValue, - rangeColor: spec.rangeColor, - rangeWidth: spec.rangeWidth, - trackColor: spec.trackColor, - trackWidth: spec.trackWidth, - duration: const Duration(milliseconds: 200), - curve: Curves.linear, + child: Stack( + alignment: Alignment.centerLeft, + children: [ + SizedBox( + width: double.infinity, + height: spec.trackWidth, + child: RemixBoxWithEffects( + styleSpec: _sliderRailStyle( + spec.track, + color: spec.trackColor, + thickness: spec.trackWidth, + ), + containerEffects: spec.trackEffects, + ), + ), + FractionallySizedBox( + widthFactor: normalizedValue, + child: SizedBox( + width: double.infinity, + height: spec.rangeWidth, + child: RemixBoxWithEffects( + styleSpec: _sliderRailStyle( + spec.range, + color: spec.rangeColor, + thickness: spec.rangeWidth, + ), + containerEffects: spec.rangeEffects, + ), + ), + ), + ], ), ), ), Transform.translate( offset: Offset(thumbPosition, 0), - child: Box(styleSpec: spec.thumb), + child: RemixBoxWithEffects( + styleSpec: spec.thumb, + containerEffects: + (spec.thumbEffects ?? const RemixBoxEffectsSpec()) + .merge( + state.focusedThumbIndex == 0 + ? spec.thumbFocusEffects + : null, + ), + ), ), ], ); }, ), ); + final blendMode = spec.blendMode; + return blendMode == null + ? slider + : RemixBlendMode(blendMode: blendMode, child: slider); }, ); }, @@ -204,6 +251,27 @@ class RemixSlider extends StatelessWidget { } } +StyleSpec _sliderRailStyle( + StyleSpec style, { + required Color color, + required double thickness, +}) { + final box = style.spec; + final decoration = switch (box.decoration) { + final BoxDecoration value => + value.color == null ? value.copyWith(color: color) : value, + null => BoxDecoration(color: color), + final value => value, + }; + final constraints = (box.constraints ?? const BoxConstraints()).copyWith( + minHeight: thickness, + maxHeight: thickness, + ); + return style.copyWith( + spec: box.copyWith(decoration: decoration, constraints: constraints), + ); +} + Size _resolveThumbSize(BuildContext context, StyleSpec thumb) { final box = thumb.spec; final constraints = box.constraints; diff --git a/packages/remix/lib/src/components/spinner/fortal_spinner_styles.dart b/packages/remix/lib/src/components/spinner/fortal_spinner_styles.dart index e81fcab4..710708d1 100644 --- a/packages/remix/lib/src/components/spinner/fortal_spinner_styles.dart +++ b/packages/remix/lib/src/components/spinner/fortal_spinner_styles.dart @@ -11,17 +11,18 @@ enum FortalSpinnerSize { size1, size2, size3 } ) RemixSpinnerStyler fortalSpinnerStyler({FortalSpinnerSize size = .size2}) { return RemixSpinnerStyler( - indicatorColor: FortalTokens.accent9(), + opacity: 0.65, + leafRadius: FortalTokens.radius1(), duration: const Duration(milliseconds: 800), ).merge(_fortalSpinnerSizeStyler(size)); } RemixSpinnerStyler _fortalSpinnerSizeStyler(FortalSpinnerSize size) { return switch (size) { - .size1 => RemixSpinnerStyler(size: 16.0, strokeWidth: 1.5), - .size2 => RemixSpinnerStyler(size: 20.0, strokeWidth: 2.0), - .size3 => RemixSpinnerStyler(size: 24.0, strokeWidth: 2.5), + .size1 => RemixSpinnerStyler(size: FortalTokens.space3()), + .size2 => RemixSpinnerStyler(size: FortalTokens.space4()), + .size3 => RemixSpinnerStyler(size: FortalTokens.spinnerSize3()), }; } -/// Fortal-themed preset for [RemixSpinner]. +/// Fortal-themed spinner using the inherited foreground color. diff --git a/packages/remix/lib/src/components/spinner/spinner.dart b/packages/remix/lib/src/components/spinner/spinner.dart index ba35b626..bed61b1f 100644 --- a/packages/remix/lib/src/components/spinner/spinner.dart +++ b/packages/remix/lib/src/components/spinner/spinner.dart @@ -1,7 +1,6 @@ library remix_spinner; import 'dart:math'; - import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:mix/mix.dart'; diff --git a/packages/remix/lib/src/components/spinner/spinner.g.dart b/packages/remix/lib/src/components/spinner/spinner.g.dart index 57a36d65..f19efd03 100644 --- a/packages/remix/lib/src/components/spinner/spinner.g.dart +++ b/packages/remix/lib/src/components/spinner/spinner.g.dart @@ -12,6 +12,9 @@ mixin _$RemixSpinnerSpec implements Spec, Diagnosticable { Color? get indicatorColor; Color? get trackColor; double? get trackStrokeWidth; + Color? get color; + double? get opacity; + Radius? get leafRadius; Duration? get duration; @override @@ -24,6 +27,9 @@ mixin _$RemixSpinnerSpec implements Spec, Diagnosticable { Color? indicatorColor, Color? trackColor, double? trackStrokeWidth, + Color? color, + double? opacity, + Radius? leafRadius, Duration? duration, }) { return RemixSpinnerSpec( @@ -32,6 +38,9 @@ mixin _$RemixSpinnerSpec implements Spec, Diagnosticable { indicatorColor: indicatorColor ?? this.indicatorColor, trackColor: trackColor ?? this.trackColor, trackStrokeWidth: trackStrokeWidth ?? this.trackStrokeWidth, + color: color ?? this.color, + opacity: opacity ?? this.opacity, + leafRadius: leafRadius ?? this.leafRadius, duration: duration ?? this.duration, ); } @@ -48,6 +57,9 @@ mixin _$RemixSpinnerSpec implements Spec, Diagnosticable { other?.trackStrokeWidth, t, ), + color: MixOps.lerp(color, other?.color, t), + opacity: MixOps.lerp(opacity, other?.opacity, t), + leafRadius: MixOps.lerpSnap(leafRadius, other?.leafRadius, t), duration: MixOps.lerpSnap(duration, other?.duration, t), ); } @@ -59,6 +71,9 @@ mixin _$RemixSpinnerSpec implements Spec, Diagnosticable { indicatorColor, trackColor, trackStrokeWidth, + color, + opacity, + leafRadius, duration, ]; @@ -107,6 +122,9 @@ mixin _$RemixSpinnerSpec implements Spec, Diagnosticable { ..add(ColorProperty('indicatorColor', indicatorColor)) ..add(ColorProperty('trackColor', trackColor)) ..add(DoubleProperty('trackStrokeWidth', trackStrokeWidth)) + ..add(ColorProperty('color', color)) + ..add(DoubleProperty('opacity', opacity)) + ..add(DiagnosticsProperty('leafRadius', leafRadius)) ..add(DiagnosticsProperty('duration', duration)); } } @@ -146,6 +164,9 @@ class RemixSpinnerStyler final Prop? $indicatorColor; final Prop? $trackColor; final Prop? $trackStrokeWidth; + final Prop? $color; + final Prop? $opacity; + final Prop? $leafRadius; final Prop? $duration; const RemixSpinnerStyler.create({ @@ -154,6 +175,9 @@ class RemixSpinnerStyler Prop? indicatorColor, Prop? trackColor, Prop? trackStrokeWidth, + Prop? color, + Prop? opacity, + Prop? leafRadius, Prop? duration, super.variants, super.modifier, @@ -163,6 +187,9 @@ class RemixSpinnerStyler $indicatorColor = indicatorColor, $trackColor = trackColor, $trackStrokeWidth = trackStrokeWidth, + $color = color, + $opacity = opacity, + $leafRadius = leafRadius, $duration = duration; RemixSpinnerStyler({ @@ -171,6 +198,9 @@ class RemixSpinnerStyler Color? indicatorColor, Color? trackColor, double? trackStrokeWidth, + Color? color, + double? opacity, + Radius? leafRadius, Duration? duration, AnimationConfig? animation, WidgetModifierConfig? modifier, @@ -181,6 +211,9 @@ class RemixSpinnerStyler indicatorColor: Prop.maybe(indicatorColor), trackColor: Prop.maybe(trackColor), trackStrokeWidth: Prop.maybe(trackStrokeWidth), + color: Prop.maybe(color), + opacity: Prop.maybe(opacity), + leafRadius: Prop.maybe(leafRadius), duration: Prop.maybe(duration), variants: variants, modifier: modifier, @@ -197,6 +230,12 @@ class RemixSpinnerStyler RemixSpinnerStyler().trackColor(value); factory RemixSpinnerStyler.trackStrokeWidth(double value) => RemixSpinnerStyler().trackStrokeWidth(value); + factory RemixSpinnerStyler.color(Color value) => + RemixSpinnerStyler().color(value); + factory RemixSpinnerStyler.opacity(double value) => + RemixSpinnerStyler().opacity(value); + factory RemixSpinnerStyler.leafRadius(Radius value) => + RemixSpinnerStyler().leafRadius(value); factory RemixSpinnerStyler.duration(Duration value) => RemixSpinnerStyler().duration(value); @@ -225,6 +264,21 @@ class RemixSpinnerStyler return merge(RemixSpinnerStyler(trackStrokeWidth: value)); } + /// Sets the color. + RemixSpinnerStyler color(Color value) { + return merge(RemixSpinnerStyler(color: value)); + } + + /// Sets the opacity. + RemixSpinnerStyler opacity(double value) { + return merge(RemixSpinnerStyler(opacity: value)); + } + + /// Sets the leafRadius. + RemixSpinnerStyler leafRadius(Radius value) { + return merge(RemixSpinnerStyler(leafRadius: value)); + } + /// Sets the duration. RemixSpinnerStyler duration(Duration value) { return merge(RemixSpinnerStyler(duration: value)); @@ -265,6 +319,9 @@ class RemixSpinnerStyler $trackStrokeWidth, other?.$trackStrokeWidth, ), + color: MixOps.merge($color, other?.$color), + opacity: MixOps.merge($opacity, other?.$opacity), + leafRadius: MixOps.merge($leafRadius, other?.$leafRadius), duration: MixOps.merge($duration, other?.$duration), variants: MixOps.mergeVariants($variants, other?.$variants), modifier: MixOps.mergeModifier($modifier, other?.$modifier), @@ -281,6 +338,9 @@ class RemixSpinnerStyler indicatorColor: MixOps.resolve(context, $indicatorColor), trackColor: MixOps.resolve(context, $trackColor), trackStrokeWidth: MixOps.resolve(context, $trackStrokeWidth), + color: MixOps.resolve(context, $color), + opacity: MixOps.resolve(context, $opacity), + leafRadius: MixOps.resolve(context, $leafRadius), duration: MixOps.resolve(context, $duration), ); @@ -300,6 +360,9 @@ class RemixSpinnerStyler ..add(DiagnosticsProperty('indicatorColor', $indicatorColor)) ..add(DiagnosticsProperty('trackColor', $trackColor)) ..add(DiagnosticsProperty('trackStrokeWidth', $trackStrokeWidth)) + ..add(DiagnosticsProperty('color', $color)) + ..add(DiagnosticsProperty('opacity', $opacity)) + ..add(DiagnosticsProperty('leafRadius', $leafRadius)) ..add(DiagnosticsProperty('duration', $duration)); } @@ -310,6 +373,9 @@ class RemixSpinnerStyler $indicatorColor, $trackColor, $trackStrokeWidth, + $color, + $opacity, + $leafRadius, $duration, $animation, $modifier, diff --git a/packages/remix/lib/src/components/spinner/spinner_painter.dart b/packages/remix/lib/src/components/spinner/spinner_painter.dart index 1b268a74..b66b7e88 100644 --- a/packages/remix/lib/src/components/spinner/spinner_painter.dart +++ b/packages/remix/lib/src/components/spinner/spinner_painter.dart @@ -1,39 +1,64 @@ part of 'spinner.dart'; -/// Paints a solid arc spinner that rotates continuously around a circle -/// with an optional track (background circle). -/// -/// This creates a smooth, modern loading indicator consisting of a partial -/// circle arc that rotates around the center. The arc covers approximately -/// 1/3 of the full circle (120°) and spins continuously. -/// -/// **Visual Characteristics:** -/// - Clean, minimalist appearance -/// - Smooth rotation without discrete steps -/// - Optional track for better visibility -/// - Works well in modern, flat design contexts -/// -/// **Mathematical Foundation:** -/// Uses Flutter's `drawArc` method with careful angle calculations -/// to create smooth rotation and proper arc proportions. -class RemixSpinnerPainter extends CustomPainter { - /// Animation controller providing values from 0.0 to 1.0 for rotation. +/// Paints the eight independently fading leaves used by Radix Themes Spinner. +class RemixLeafSpinnerPainter extends CustomPainter { + RemixLeafSpinnerPainter({ + required this.animation, + required this.color, + required this.opacity, + required this.leafRadius, + }) : super(repaint: animation); + + static const int leafCount = 8; + + /// Linear phase from zero to one over one complete fade cycle. final Animation animation; - /// Thickness of the indicator arc in logical pixels. - final double strokeWidth; + /// Resolved current color inherited by every leaf. + final Color color; - /// Color of the rotating indicator arc. - final Color indicatorColor; + /// Opacity applied to the complete spinner. + final double opacity; - /// Color of the background track circle. - /// If null, no track is drawn. - final Color? trackColor; + /// Corner radius of each leaf. + final Radius leafRadius; - /// Thickness of the track circle in logical pixels. - /// If null, uses the same width as the indicator. - final double? trackStrokeWidth; + @override + void paint(Canvas canvas, Size size) { + final extent = min(size.width, size.height); + final leaf = Rect.fromLTWH( + -extent * 0.125 / 2, + -extent / 2, + extent * 0.125, + extent * 0.3, + ); + final paint = Paint(); + canvas.save(); + canvas.translate(size.width / 2, size.height / 2); + for (var index = 0; index < leafCount; index++) { + // CSS delays are -8/8, -7/8, ... -1/8 of a cycle. + final phase = (animation.value + (leafCount - index) / leafCount) % 1; + final leafOpacity = 1 - 0.75 * phase; + paint.color = color.withValues(alpha: color.a * opacity * leafOpacity); + + canvas.save(); + canvas.rotate(index * pi / 4); + canvas.drawRRect(RRect.fromRectAndRadius(leaf, leafRadius), paint); + canvas.restore(); + } + canvas.restore(); + } + + @override + bool shouldRepaint(RemixLeafSpinnerPainter oldDelegate) => + animation != oldDelegate.animation || + color != oldDelegate.color || + opacity != oldDelegate.opacity || + leafRadius != oldDelegate.leafRadius; +} +/// Paints the established rotating circular indicator and optional track. +class RemixSpinnerPainter extends CustomPainter { RemixSpinnerPainter({ required this.animation, required this.strokeWidth, @@ -42,76 +67,50 @@ class RemixSpinnerPainter extends CustomPainter { this.trackStrokeWidth, }) : super(repaint: animation); + final Animation animation; + final double strokeWidth; + final Color indicatorColor; + final Color? trackColor; + final double? trackStrokeWidth; + @override void paint(Canvas canvas, Size size) { canvas.translate(size.width / 2, size.height / 2); - final indicatorThickness = strokeWidth * 2; - final trackThickness = trackColor != null - ? 2 * (trackStrokeWidth ?? strokeWidth) - : 0; - final maxThickness = max(indicatorThickness, trackThickness); - - final radius = min(size.width, size.height) / 2 - maxThickness; - - // Draw track (background circle) if trackColor is provided - if (trackColor != null) { - final trackPaint = Paint() - ..style = .stroke - ..strokeWidth = 2 * (trackStrokeWidth ?? strokeWidth) - ..color = trackColor!; + final trackThickness = trackColor == null + ? 0.0 + : 2 * (trackStrokeWidth ?? strokeWidth); + final radius = + min(size.width, size.height) / 2 - + max(indicatorThickness, trackThickness); - canvas.drawCircle(.zero, radius, trackPaint); + if (trackColor case final color?) { + canvas.drawCircle( + .zero, + radius, + Paint() + ..style = .stroke + ..strokeWidth = 2 * (trackStrokeWidth ?? strokeWidth) + ..color = color, + ); } - - // Draw indicator arc - final indicatorPaint = Paint() - ..style = .stroke - ..strokeWidth = 2 * strokeWidth - ..color = indicatorColor; - - /// **Arc Geometry Constants** - /// - /// **Start Angle: π/3 radians (60°)** - /// Positions the arc to start at the 2 o'clock position. - /// This creates a visually pleasing starting point that works well - /// with the sweep angle. - /// - /// **Sweep Angle: 2π/3 radians (120°)** - /// Creates an arc that covers exactly 1/3 of the circle. - /// This proportion provides good visual balance - substantial enough - /// to be clearly visible, but not so large as to dominate the space. - const startAngle = pi / 3; - const sweepAngle = 2 * pi / 3; - - /// **Arc Rotation Calculation** - /// - /// The arc is drawn with a starting angle that rotates based on animation: - /// `startAngle + animation.value * 2 * pi` - /// - /// **Animation Mapping:** - /// - animation.value: 0.0 to 1.0 (provided by AnimationController) - /// - 2 * π: Full circle in radians - /// - Result: Arc rotates 360° as animation progresses from 0 to 1 - /// - /// **Why This Works:** - /// As animation cycles from 0 to 1, the start angle advances by 2π, - /// making the arc appear to rotate smoothly around the center. canvas.drawArc( Rect.fromCircle(center: .zero, radius: radius), - startAngle + animation.value * 2 * pi, - sweepAngle, - false, // useCenter: false - draws arc stroke, not a pie slice - indicatorPaint, + pi / 3 + animation.value * 2 * pi, + 2 * pi / 3, + false, + Paint() + ..style = .stroke + ..strokeWidth = indicatorThickness + ..color = indicatorColor, ); } - /// Always repaints to maintain smooth arc rotation. - /// - /// **Performance Justification:** - /// Spinner animations require continuous repainting for smooth - /// rotation. The visual smoothness is worth the performance cost for - /// temporary loading states. @override - bool shouldRepaint(RemixSpinnerPainter oldDelegate) => true; + bool shouldRepaint(RemixSpinnerPainter oldDelegate) => + animation != oldDelegate.animation || + strokeWidth != oldDelegate.strokeWidth || + indicatorColor != oldDelegate.indicatorColor || + trackColor != oldDelegate.trackColor || + trackStrokeWidth != oldDelegate.trackStrokeWidth; } diff --git a/packages/remix/lib/src/components/spinner/spinner_spec.dart b/packages/remix/lib/src/components/spinner/spinner_spec.dart index de5b25af..3ae36b0e 100644 --- a/packages/remix/lib/src/components/spinner/spinner_spec.dart +++ b/packages/remix/lib/src/components/spinner/spinner_spec.dart @@ -1,18 +1,40 @@ part of 'spinner.dart'; -/// Resolved visual values for a [RemixSpinner]. +/// Resolved visual values for an eight-leaf [RemixSpinner]. @MixableSpec() class RemixSpinnerSpec with _$RemixSpinnerSpec { @override final double? size; + + /// Established circular indicator stroke width. @override final double? strokeWidth; + + /// Established circular indicator color. @override final Color? indicatorColor; + + /// Established optional circular track color. @override final Color? trackColor; + + /// Established optional circular track stroke width. @override final double? trackStrokeWidth; + + /// Explicit color override. When absent the spinner uses inherited color. + @override + final Color? color; + + /// Opacity applied to the complete spinner before per-leaf fading. + @override + final double? opacity; + + /// Corner radius of each leaf. + @override + final Radius? leafRadius; + + /// Duration of one complete leaf-fade cycle. @override final Duration? duration; @@ -22,6 +44,9 @@ class RemixSpinnerSpec with _$RemixSpinnerSpec { this.indicatorColor, this.trackColor, this.trackStrokeWidth, + this.color, + this.opacity, + this.leafRadius, this.duration, }); } diff --git a/packages/remix/lib/src/components/switch/fortal_switch_styles.dart b/packages/remix/lib/src/components/switch/fortal_switch_styles.dart index bef09d76..86694569 100644 --- a/packages/remix/lib/src/components/switch/fortal_switch_styles.dart +++ b/packages/remix/lib/src/components/switch/fortal_switch_styles.dart @@ -14,6 +14,9 @@ enum FortalSwitchSize { /// Fortal switch color variants. enum FortalSwitchVariant { + /// Raised treatment with Radix's classic shadows. + classic, + /// Surface treatment with a visible border. surface, @@ -30,93 +33,318 @@ enum FortalSwitchVariant { RemixSwitchStyler fortalSwitchStyler({ FortalSwitchVariant variant = .surface, FortalSwitchSize size = .size2, + bool highContrast = false, }) { return switch (variant) { - .surface => _fortalSwitchSurfaceStyler(size), - .soft => _fortalSwitchSoftStyler(size), + .classic => _fortalSwitchClassicStyler(size, highContrast: highContrast), + .surface => _fortalSwitchSurfaceStyler(size, highContrast: highContrast), + .soft => _fortalSwitchSoftStyler(size, highContrast: highContrast), }; } RemixSwitchStyler _fortalSwitchBaseStyler(FortalSwitchSize size) { - return RemixSwitchStyler() - .thumbColor(Colors.white) - .thumb( - BoxStyler().shapeCircle().shadow( - BoxShadowMix() - .color(FortalTokens.grayA7()) - .offset(x: 0, y: 2) - .blurRadius(3), + final metrics = _fortalSwitchMetrics(size); + return RemixSwitchStyler( + container: .size( + metrics.width, + metrics.height, + ).paddingAll(1).borderRadiusAll(metrics.radius), + thumb: .size( + metrics.thumbSize, + metrics.thumbSize, + ).borderRadiusAll(metrics.radius), + trackEffects: RemixBoxEffectsMix( + behindContent: _fortalSwitchLayer(), + overContent: _fortalSwitchLayer(), ), ) + .thumbColor(Colors.white) .onFocused( - RemixSwitchStyler().borderAll( - color: FortalTokens.focusA8(), - width: FortalTokens.focusRingWidth(), + .trackEffects( + RemixBoxEffectsMix( + outline: BorderSideMix( + color: FortalTokens.focus8(), + width: 2, + strokeAlign: BorderSide.strokeAlignInside, + ), + outlineOffset: 2, + ), ), - ) - .merge(_fortalSwitchSizeStyler(size)); + ); } -RemixSwitchStyler _fortalSwitchSurfaceStyler([FortalSwitchSize size = .size2]) { +RemixSwitchStyler _fortalSwitchClassicStyler( + FortalSwitchSize size, { + required bool highContrast, +}) { return _fortalSwitchBaseStyler(size) - .trackColor(FortalTokens.gray5()) - .borderRadius(BorderRadiusMix.circular(999)) - .borderAll( - color: FortalTokens.gray8(), - width: FortalTokens.borderWidth1(), - strokeAlign: BorderSide.strokeAlignCenter, + .trackColor(FortalTokens.grayA4()) + .trackEffects( + RemixBoxEffectsMix( + behindContent: _fortalSwitchLayer( + shadowToken: FortalTokens.shadow1Layers, + ), + ), + ) + .thumb( + _fortalSwitchThumbStyler(selected: false, highContrast: highContrast), ) .onSelected( RemixSwitchStyler() - .trackColor(FortalTokens.accentTrack()) - .borderAll(color: FortalTokens.accent9()), + .trackColor( + highContrast + ? FortalTokens.accent12() + : FortalTokens.accentTrack(), + ) + .trackEffects( + RemixBoxEffectsMix( + behindContent: _fortalSwitchLayer( + shadows: [ + RemixBoxShadowMix( + kind: .inset, + color: FortalTokens.grayA3(), + spreadRadius: 1, + ), + RemixBoxShadowMix( + kind: .inset, + color: highContrast + ? FortalTokens.blackA2() + : FortalTokens.accentA4(), + spreadRadius: 1, + ), + RemixBoxShadowMix( + kind: .inset, + color: FortalTokens.blackA2(), + offset: const Offset(0, 1.5), + blurRadius: 2, + ), + ], + ), + ), + ) + .thumb( + _fortalSwitchThumbStyler( + selected: true, + highContrast: highContrast, + ), + ), ) - .onDisabled( + .onPressed( RemixSwitchStyler() - .trackColor(FortalTokens.grayA3()) - .borderAll(color: FortalTokens.gray5()) - .thumbColor(FortalTokens.gray2()), - ); + .trackColor(FortalTokens.grayA5()) + .trackEffects( + RemixBoxEffectsMix(behindContent: _fortalSwitchLayer()), + ), + ) + .onDisabled(_fortalSwitchDisabledStyler(classic: true)); } -RemixSwitchStyler _fortalSwitchSoftStyler([FortalSwitchSize size = .size2]) { +RemixSwitchStyler _fortalSwitchSurfaceStyler( + FortalSwitchSize size, { + required bool highContrast, +}) { return _fortalSwitchBaseStyler(size) - .trackColor(FortalTokens.gray5()) - .borderRadius(BorderRadiusMix.circular(999)) - .borderAll( - color: FortalTokens.gray5(), - width: FortalTokens.borderWidth1(), - strokeAlign: BorderSide.strokeAlignCenter, + .trackColor(FortalTokens.grayA3()) + .trackEffects(RemixBoxEffectsMix(behindContent: _fortalSwitchLayer())) + .trackEffects( + RemixBoxEffectsMix( + overContent: _fortalSwitchInsetRing(FortalTokens.grayA5()), + ), + ) + .thumb( + _fortalSwitchThumbStyler(selected: false, highContrast: highContrast), ) .onSelected( RemixSwitchStyler() - .trackColor(FortalTokens.accentA7()) - .borderAll(color: FortalTokens.accent7()), + .trackColor( + highContrast + ? FortalTokens.accent12() + : FortalTokens.accentTrack(), + ) + .trackEffects( + RemixBoxEffectsMix(behindContent: _fortalSwitchLayer()), + ) + .thumb( + _fortalSwitchThumbStyler( + selected: true, + highContrast: highContrast, + ), + ), ) - .onDisabled( + .onPressed( RemixSwitchStyler() - .trackColor(FortalTokens.gray5()) - .borderAll(color: FortalTokens.gray5()) - .shadow(BoxShadowMix()) - .thumbColor(FortalTokens.gray2()), - ); + .trackColor(FortalTokens.grayA4()) + .trackEffects( + RemixBoxEffectsMix(behindContent: _fortalSwitchLayer()), + ), + ) + .onDisabled(_fortalSwitchDisabledStyler()); +} + +RemixSwitchStyler _fortalSwitchSoftStyler( + FortalSwitchSize size, { + required bool highContrast, +}) { + return _fortalSwitchBaseStyler(size) + .trackColor(FortalTokens.grayA3()) + .trackEffects(RemixBoxEffectsMix(behindContent: _fortalSwitchLayer())) + .thumb(_fortalSwitchSoftThumbStyler(false)) + .onSelected( + RemixSwitchStyler() + .trackColor( + highContrast ? FortalTokens.accentA6() : FortalTokens.accentA4(), + ) + .trackEffects( + RemixBoxEffectsMix(behindContent: _fortalSwitchLayer()), + ) + .thumb(_fortalSwitchSoftThumbStyler(true)), + ) + .onPressed( + RemixSwitchStyler() + .trackColor(FortalTokens.grayA4()) + .trackEffects( + RemixBoxEffectsMix(behindContent: _fortalSwitchLayer()), + ), + ) + .onDisabled(_fortalSwitchDisabledStyler(soft: true)); } -RemixSwitchStyler _fortalSwitchSizeStyler(FortalSwitchSize size) { - return switch (size) { - .size1 => RemixSwitchStyler( - container: BoxStyler().width(28.0).height(16.0), - thumb: BoxStyler().width(16.0).height(16.0), - ), - .size2 => RemixSwitchStyler( - container: BoxStyler().width(35.0).height(20.0), - thumb: BoxStyler().size(20.0, 20.0), - ), - .size3 => RemixSwitchStyler( - container: BoxStyler().width(42.0).height(24.0), - thumb: BoxStyler().size(24.0, 24.0), - ), +({double width, double height, double thumbSize, Radius radius}) +_fortalSwitchMetrics(FortalSwitchSize size) { + final height = switch (size) { + .size1 => FortalTokens.space4(), + .size2 => FortalTokens.switchHeight2(), + .size3 => FortalTokens.space5(), + }; + final width = switch (size) { + .size1 => FortalTokens.switchWidth1(), + .size2 => FortalTokens.switchWidth2(), + .size3 => FortalTokens.switchWidth3(), }; + final thumbSize = switch (size) { + .size1 => FortalTokens.switchThumbSize1(), + .size2 => FortalTokens.switchThumbSize2(), + .size3 => FortalTokens.switchThumbSize3(), + }; + final radius = switch (size) { + .size1 => FortalTokens.radius1OrThumb(), + .size2 || .size3 => FortalTokens.radius2OrThumb(), + }; + return (width: width, height: height, thumbSize: thumbSize, radius: radius); } +RemixSwitchStyler _fortalSwitchDisabledStyler({ + bool classic = false, + bool soft = false, +}) { + final trackColor = switch ((classic, soft)) { + (true, _) => FortalTokens.grayA5(), + (_, true) => FortalTokens.grayA4(), + _ => FortalTokens.grayA3(), + }; + return RemixSwitchStyler() + .trackColor(trackColor) + .trackEffects( + RemixBoxEffectsMix( + behindContent: _fortalSwitchLayer( + shadowToken: classic ? FortalTokens.shadow1Layers : null, + ), + ), + ) + .trackEffects( + RemixBoxEffectsMix( + overContent: classic || soft + ? _fortalSwitchLayer(shadows: const []) + : _fortalSwitchInsetRing(FortalTokens.grayA3()), + ), + ) + .thumb( + BoxStyler().boxShadows([ + BoxShadowMix(color: FortalTokens.grayA2(), spreadRadius: 1), + BoxShadowMix( + color: FortalTokens.blackA1(), + offset: const Offset(0, 1), + blurRadius: 3, + ), + ]), + ) + .thumbColor(FortalTokens.gray2()); +} + +BoxStyler _fortalSwitchThumbStyler({ + required bool selected, + required bool highContrast, +}) => BoxStyler().boxShadows( + selected + ? [ + BoxShadowMix( + color: FortalTokens.blackA2(), + offset: const Offset(0, 1), + blurRadius: 3, + ), + BoxShadowMix( + color: FortalTokens.blackA1(), + offset: const Offset(0, 2), + blurRadius: 4, + spreadRadius: -1, + ), + BoxShadowMix( + color: highContrast + ? FortalTokens.blackA2() + : FortalTokens.accentA4(), + spreadRadius: 1, + ), + BoxShadowMix( + color: FortalTokens.blackA2(), + offset: const Offset(-1, 0), + blurRadius: 1, + ), + ] + : [ + BoxShadowMix(color: FortalTokens.blackA2(), spreadRadius: 1), + BoxShadowMix( + color: FortalTokens.blackA1(), + offset: const Offset(0, 1), + blurRadius: 3, + ), + BoxShadowMix( + color: FortalTokens.blackA1(), + offset: const Offset(0, 2), + blurRadius: 4, + spreadRadius: -1, + ), + ], +); + +BoxStyler _fortalSwitchSoftThumbStyler(bool selected) => + BoxStyler().boxShadows([ + BoxShadowMix(color: FortalTokens.blackA1(), spreadRadius: 1), + BoxShadowMix( + color: selected ? FortalTokens.blackA2() : FortalTokens.blackA1(), + offset: const Offset(0, 1), + blurRadius: 3, + ), + BoxShadowMix( + color: selected ? FortalTokens.accentA3() : FortalTokens.blackA1(), + offset: const Offset(0, 1), + blurRadius: 3, + ), + BoxShadowMix( + color: selected ? FortalTokens.accentA3() : FortalTokens.blackA1(), + offset: const Offset(0, 2), + blurRadius: 4, + spreadRadius: -1, + ), + ]); + +RemixBoxEffectLayerMix _fortalSwitchInsetRing(Color color) => + _fortalSwitchLayer( + shadows: [RemixBoxShadowMix(kind: .inset, color: color, spreadRadius: 1)], + ); + +RemixBoxEffectLayerMix _fortalSwitchLayer({ + List? shadows, + RemixBoxShadowListToken? shadowToken, +}) => RemixBoxEffectLayerMix(shadows: shadows, shadowToken: shadowToken); + /// Fortal-themed preset for [RemixSwitch]. diff --git a/packages/remix/lib/src/components/switch/switch.dart b/packages/remix/lib/src/components/switch/switch.dart index 424d7377..9248847b 100644 --- a/packages/remix/lib/src/components/switch/switch.dart +++ b/packages/remix/lib/src/components/switch/switch.dart @@ -9,6 +9,7 @@ import 'package:naked_ui/naked_ui.dart'; import '../../utilities/remix_style.dart'; import '../../fortal/fortal.dart'; +import '../../rendering/remix_box_effects.dart'; import '../../utilities/selected_mixin.dart'; part 'switch_spec.dart'; diff --git a/packages/remix/lib/src/components/switch/switch.g.dart b/packages/remix/lib/src/components/switch/switch.g.dart index a6377f33..5d718a30 100644 --- a/packages/remix/lib/src/components/switch/switch.g.dart +++ b/packages/remix/lib/src/components/switch/switch.g.dart @@ -9,6 +9,8 @@ part of 'switch.dart'; mixin _$RemixSwitchSpec implements Spec, Diagnosticable { StyleSpec get container; StyleSpec get thumb; + RemixBoxEffectsSpec? get trackEffects; + RemixBoxEffectsSpec? get thumbEffects; @override Type get type => RemixSwitchSpec; @@ -17,10 +19,14 @@ mixin _$RemixSwitchSpec implements Spec, Diagnosticable { RemixSwitchSpec copyWith({ StyleSpec? container, StyleSpec? thumb, + RemixBoxEffectsSpec? trackEffects, + RemixBoxEffectsSpec? thumbEffects, }) { return RemixSwitchSpec( container: container ?? this.container, thumb: thumb ?? this.thumb, + trackEffects: trackEffects ?? this.trackEffects, + thumbEffects: thumbEffects ?? this.thumbEffects, ); } @@ -29,11 +35,13 @@ mixin _$RemixSwitchSpec implements Spec, Diagnosticable { return RemixSwitchSpec( container: container.lerp(other?.container, t), thumb: thumb.lerp(other?.thumb, t), + trackEffects: MixOps.lerpSnap(trackEffects, other?.trackEffects, t), + thumbEffects: MixOps.lerpSnap(thumbEffects, other?.thumbEffects, t), ); } @override - List get props => [container, thumb]; + List get props => [container, thumb, trackEffects, thumbEffects]; @override bool operator ==(Object other) { @@ -76,7 +84,9 @@ mixin _$RemixSwitchSpec implements Spec, Diagnosticable { void debugFillProperties(DiagnosticPropertiesBuilder properties) { properties ..add(DiagnosticsProperty('container', container)) - ..add(DiagnosticsProperty('thumb', thumb)); + ..add(DiagnosticsProperty('thumb', thumb)) + ..add(DiagnosticsProperty('trackEffects', trackEffects)) + ..add(DiagnosticsProperty('thumbEffects', thumbEffects)); } } @@ -105,6 +115,20 @@ class FortalSwitch extends StatelessWidget { this.mouseCursor = SystemMouseCursors.click, }); + /// Raised treatment with Radix's classic shadows. + const FortalSwitch.classic({ + super.key, + this.size = .size2, + required this.selected, + this.onChanged, + this.enabled = true, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.semanticLabel, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalSwitchVariant.classic; + /// Surface treatment with a visible border. const FortalSwitch.surface({ super.key, @@ -178,25 +202,35 @@ class RemixSwitchStyler extends MixStyler with RemixBoxStylerMixin { final Prop>? $container; final Prop>? $thumb; + final Prop? $trackEffects; + final Prop? $thumbEffects; const RemixSwitchStyler.create({ Prop>? container, Prop>? thumb, + Prop? trackEffects, + Prop? thumbEffects, super.variants, super.modifier, super.animation, }) : $container = container, - $thumb = thumb; + $thumb = thumb, + $trackEffects = trackEffects, + $thumbEffects = thumbEffects; RemixSwitchStyler({ BoxStyler? container, BoxStyler? thumb, + RemixBoxEffectsMix? trackEffects, + RemixBoxEffectsMix? thumbEffects, AnimationConfig? animation, WidgetModifierConfig? modifier, List>? variants, }) : this.create( container: Prop.maybeMix(container), thumb: Prop.maybeMix(thumb), + trackEffects: Prop.maybeMix(trackEffects), + thumbEffects: Prop.maybeMix(thumbEffects), variants: variants, modifier: modifier, animation: animation, @@ -206,6 +240,10 @@ class RemixSwitchStyler extends MixStyler RemixSwitchStyler().container(value); factory RemixSwitchStyler.thumb(BoxStyler value) => RemixSwitchStyler().thumb(value); + factory RemixSwitchStyler.trackEffects(RemixBoxEffectsMix value) => + RemixSwitchStyler().trackEffects(value); + factory RemixSwitchStyler.thumbEffects(RemixBoxEffectsMix value) => + RemixSwitchStyler().thumbEffects(value); factory RemixSwitchStyler.alignment(AlignmentGeometry value) => RemixSwitchStyler().alignment(value); factory RemixSwitchStyler.padding(EdgeInsetsGeometryMix value) => @@ -688,6 +726,16 @@ class RemixSwitchStyler extends MixStyler return merge(RemixSwitchStyler(thumb: value)); } + /// Sets the trackEffects. + RemixSwitchStyler trackEffects(RemixBoxEffectsMix value) { + return merge(RemixSwitchStyler(trackEffects: value)); + } + + /// Sets the thumbEffects. + RemixSwitchStyler thumbEffects(RemixBoxEffectsMix value) { + return merge(RemixSwitchStyler(thumbEffects: value)); + } + /// Sets the animation configuration. @override RemixSwitchStyler animate(AnimationConfig value) { @@ -717,6 +765,8 @@ class RemixSwitchStyler extends MixStyler return RemixSwitchStyler.create( container: MixOps.merge($container, other?.$container), thumb: MixOps.merge($thumb, other?.$thumb), + trackEffects: MixOps.merge($trackEffects, other?.$trackEffects), + thumbEffects: MixOps.merge($thumbEffects, other?.$thumbEffects), variants: MixOps.mergeVariants($variants, other?.$variants), modifier: MixOps.mergeModifier($modifier, other?.$modifier), animation: MixOps.mergeAnimation($animation, other?.$animation), @@ -729,6 +779,8 @@ class RemixSwitchStyler extends MixStyler final spec = RemixSwitchSpec( container: MixOps.resolve(context, $container), thumb: MixOps.resolve(context, $thumb), + trackEffects: MixOps.resolve(context, $trackEffects), + thumbEffects: MixOps.resolve(context, $thumbEffects), ); return StyleSpec( @@ -743,13 +795,17 @@ class RemixSwitchStyler extends MixStyler super.debugFillProperties(properties); properties ..add(DiagnosticsProperty('container', $container)) - ..add(DiagnosticsProperty('thumb', $thumb)); + ..add(DiagnosticsProperty('thumb', $thumb)) + ..add(DiagnosticsProperty('trackEffects', $trackEffects)) + ..add(DiagnosticsProperty('thumbEffects', $thumbEffects)); } @override List get props => [ $container, $thumb, + $trackEffects, + $thumbEffects, $animation, $modifier, $variants, diff --git a/packages/remix/lib/src/components/switch/switch_spec.dart b/packages/remix/lib/src/components/switch/switch_spec.dart index 536abfa2..fe92ce81 100644 --- a/packages/remix/lib/src/components/switch/switch_spec.dart +++ b/packages/remix/lib/src/components/switch/switch_spec.dart @@ -9,9 +9,37 @@ class RemixSwitchSpec with _$RemixSwitchSpec { @override final StyleSpec thumb; + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? trackEffects; + + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? thumbEffects; + const RemixSwitchSpec({ StyleSpec? container, StyleSpec? thumb, + this.trackEffects, + this.thumbEffects, }) : container = container ?? const StyleSpec(spec: BoxSpec()), thumb = thumb ?? const StyleSpec(spec: BoxSpec()); + + @override + RemixSwitchSpec lerp(RemixSwitchSpec? other, double t) { + final generated = super.lerp(other, t); + if (other == null) return generated; + return generated.copyWith( + trackEffects: RemixBoxEffectsSpec.lerpNullable( + trackEffects, + other.trackEffects, + t, + ), + thumbEffects: RemixBoxEffectsSpec.lerpNullable( + thumbEffects, + other.thumbEffects, + t, + ), + ); + } } diff --git a/packages/remix/lib/src/components/switch/switch_widget.dart b/packages/remix/lib/src/components/switch/switch_widget.dart index e4f6d2c4..cb6c9fd4 100644 --- a/packages/remix/lib/src/components/switch/switch_widget.dart +++ b/packages/remix/lib/src/components/switch/switch_widget.dart @@ -92,9 +92,13 @@ class RemixSwitch extends StatelessWidget { styleSpec: styleSpec, controller: NakedToggleState.controllerOf(context), builder: (context, spec) { - return Box( + return RemixBoxWithEffects( styleSpec: spec.container, - child: Box(styleSpec: spec.thumb), + containerEffects: spec.trackEffects, + child: RemixBoxWithEffects( + styleSpec: spec.thumb, + containerEffects: spec.thumbEffects, + ), ); }, ); diff --git a/packages/remix/lib/src/components/tabs/fortal_tabs_styles.dart b/packages/remix/lib/src/components/tabs/fortal_tabs_styles.dart index eb3bbc09..f8ad58a7 100644 --- a/packages/remix/lib/src/components/tabs/fortal_tabs_styles.dart +++ b/packages/remix/lib/src/components/tabs/fortal_tabs_styles.dart @@ -1,17 +1,21 @@ part of 'tabs.dart'; +/// Fortal tab-list size presets matching Radix Themes 3.3.0. +enum FortalTabsSize { size1, size2 } + /// Fortal-themed preset for [RemixTabBar]. +/// +/// The tab-list bottom border is a single hairline at every Radix size, so this +/// preset takes no `size` — unlike [fortalTabStyler], whose per-tab metrics vary. @MixWidget( name: 'FortalTabBar', target: RemixTabBar.new, factoryParameters: .only({}), ) RemixTabBarStyler fortalTabBarStyler() { - return RemixTabBarStyler().container( - FlexBoxStyler() - .direction(.horizontal) - .spacing(FortalTokens.space1()) - .mainAxisSize(.min), + return RemixTabBarStyler().borderBottom( + color: FortalTokens.grayA5(), + width: FortalTokens.borderWidth1(), ); } @@ -21,25 +25,49 @@ RemixTabBarStyler fortalTabBarStyler() { target: RemixTabView.new, factoryParameters: .only({}), ) -RemixTabViewStyler fortalTabViewStyler() { - return RemixTabViewStyler().paddingAll(FortalTokens.space3()); -} +RemixTabViewStyler fortalTabViewStyler() => RemixTabViewStyler(); /// Fortal-themed preset for [RemixTab]. @MixWidget( name: 'FortalTab', target: RemixTab.new, - factoryParameters: .only({}), + factoryParameters: .only({'size'}), ) -RemixTabStyler fortalTabStyler() { +RemixTabStyler fortalTabStyler({ + FortalTabsSize size = FortalTabsSize.size2, + bool highContrast = false, +}) { + final metrics = switch (size) { + FortalTabsSize.size1 => ( + height: FortalTokens.space6(), + outerPaddingX: FortalTokens.space1(), + innerPaddingX: FortalTokens.space1(), + innerPaddingY: FortalTokens.tabInnerPaddingY1(), + radius: FortalTokens.radius1(), + text: FortalTokens.text1.mix(), + activeLetterSpacing: FortalTokens.tabActiveLetterSpacing1(), + ), + FortalTabsSize.size2 => ( + height: FortalTokens.space7(), + outerPaddingX: FortalTokens.space2(), + innerPaddingX: FortalTokens.space2(), + innerPaddingY: FortalTokens.space1(), + radius: FortalTokens.radius2(), + text: FortalTokens.text2.mix(), + activeLetterSpacing: FortalTokens.tabActiveLetterSpacing2(), + ), + }; + return RemixTabStyler() - .label(TextStyler().color(FortalTokens.gray12())) - .icon(IconStyler(color: FortalTokens.gray12(), size: 16.0)) + .label( + .style(metrics.text).letterSpacing(0.0).color(FortalTokens.grayA11()), + ) + .icon(.color(FortalTokens.grayA11()).size(FortalTokens.space4())) .wrap( .box( BoxStyler() - .height(40) - .paddingX(4) + .height(metrics.height) + .paddingX(metrics.outerPaddingX) .alignment(.center) .borderBottom( color: Colors.transparent, @@ -48,22 +76,45 @@ RemixTabStyler fortalTabStyler() { ), ) .container( - FlexBoxStyler() - .direction(.horizontal) - .borderRadiusAll(FortalTokens.radius2()) + .direction(.horizontal) + .paddingX(metrics.innerPaddingX) + .paddingY(metrics.innerPaddingY) + .borderRadiusAll(metrics.radius) .mainAxisAlignment(.center) .crossAxisAlignment(.center) - .spacing(8.0), + .spacing(FortalTokens.space2()), ) - .onHovered(RemixTabStyler().color(FortalTokens.gray3())) - .onSelected( + .onHovered( + .label( + .color(FortalTokens.gray12()), + ).icon(.color(FortalTokens.gray12())).color(FortalTokens.grayA3()), + ) + .onFocused( RemixTabStyler() - .label(TextStyler().fontWeight(FortalTokens.fontWeightMedium())) + .borderAll( + color: FortalTokens.focus8(), + width: FortalTokens.focusRingWidth(), + ) + .onHovered(.color(FortalTokens.accentA3())), + ) + .onSelected( + .label( + .color(FortalTokens.gray12()) + .fontWeight(FortalTokens.fontWeightMedium()) + .letterSpacing(metrics.activeLetterSpacing), + ) + .icon(.color(FortalTokens.gray12())) .wrap( - .box(BoxStyler().borderBottom(color: FortalTokens.accent9())), + .box( + BoxStyler().borderBottom( + color: highContrast + ? FortalTokens.accent12() + : FortalTokens.accentIndicator(), + width: FortalTokens.borderWidth2(), + ), + ), ), - ) - .padding(EdgeInsetsMix.symmetric(vertical: 6.0, horizontal: 12.0)); + ); } /// Fortal-themed preset for [RemixTabBar]. diff --git a/packages/remix/lib/src/components/tabs/tabs.g.dart b/packages/remix/lib/src/components/tabs/tabs.g.dart index 70173da2..a50d3377 100644 --- a/packages/remix/lib/src/components/tabs/tabs.g.dart +++ b/packages/remix/lib/src/components/tabs/tabs.g.dart @@ -229,6 +229,9 @@ typedef _$RemixTabViewSpecMethods = _$RemixTabViewSpec; // ignore: unused_elemen // ************************************************************************** /// Fortal-themed preset for [RemixTabBar]. +/// +/// The tab-list bottom border is a single hairline at every Radix size, so this +/// preset takes no `size` — unlike [fortalTabStyler], whose per-tab metrics vary. class FortalTabBar extends StatelessWidget { const FortalTabBar({super.key, required this.child}); @@ -267,6 +270,7 @@ class FortalTabView extends StatelessWidget { class FortalTab extends StatelessWidget { const FortalTab({ super.key, + this.size = FortalTabsSize.size2, required this.tabId, this.child, this.label, @@ -283,6 +287,8 @@ class FortalTab extends StatelessWidget { this.semanticLabel, }); + final FortalTabsSize size; + final String tabId; final Widget? child; @@ -315,7 +321,7 @@ class FortalTab extends StatelessWidget { Widget build(BuildContext context) { return RemixTab( key: this.key, - style: fortalTabStyler(), + style: fortalTabStyler(size: this.size), tabId: this.tabId, child: this.child, label: this.label, diff --git a/packages/remix/lib/src/components/textfield/fortal_textfield_styles.dart b/packages/remix/lib/src/components/textfield/fortal_textfield_styles.dart index ce863d42..4e8cfad3 100644 --- a/packages/remix/lib/src/components/textfield/fortal_textfield_styles.dart +++ b/packages/remix/lib/src/components/textfield/fortal_textfield_styles.dart @@ -14,6 +14,9 @@ enum FortalTextFieldSize { /// Fortal text field color variants. enum FortalTextFieldVariant { + /// Raised treatment with Radix's level-one shadow. + classic, + /// Surface treatment with neutral border and text colors. surface, @@ -31,75 +34,121 @@ RemixTextFieldStyler fortalTextFieldStyler({ FortalTextFieldVariant variant = .surface, FortalTextFieldSize size = .size2, }) { - return switch (variant) { + final style = switch (variant) { + .classic => _fortalTextFieldClassicStyler(size), .surface => _fortalTextFieldSurfaceStyler(size), .soft => _fortalTextFieldSoftStyler(size), }; + return style.variant( + ContextVariant.widgetState(.error), + _fortalTextFieldErrorStyler(), + ); } -RemixTextFieldStyler _fortalTextFieldBaseStyler(FortalTextFieldSize size) { +RemixTextFieldStyler _fortalTextFieldBaseStyler( + FortalTextFieldSize size, { + required bool bordered, +}) { + final metrics = _fortalTextFieldMetrics(size, bordered: bordered); return RemixTextFieldStyler( - hintText: TextStyler().textHeightBehavior( + container: .height(metrics.height) + .paddingX(metrics.paddingX) + .spacing(metrics.spacing) + .crossAxisAlignment(.center) + .borderRadiusAll(metrics.radius) + .clipBehavior(.antiAlias), + text: .style(metrics.text.mix()), + hintText: .style(metrics.text.mix()).textHeightBehavior( TextHeightBehaviorMix() .applyHeightToFirstAscent(false) .applyHeightToLastDescent(true), ), + helperText: .style(FortalTokens.text1.mix()), + label: .style(FortalTokens.text2.mix()), cursorWidth: 1.5, + containerEffects: RemixBoxEffectsMix( + behindContent: _fortalTextFieldLayer(), + overContent: _fortalTextFieldLayer(), + ), ) - .spacing(8) - .wrap(.iconTheme(color: FortalTokens.gray10(), size: 16.0)) + .wrap(.iconTheme(color: FortalTokens.gray11(), size: 16.0)) .onFocused( - RemixTextFieldStyler().borderAll( - color: FortalTokens.accent8(), - width: FortalTokens.borderWidth1(), - strokeAlign: BorderSide.strokeAlignOutside, + .containerEffects( + RemixBoxEffectsMix( + outline: BorderSideMix( + color: FortalTokens.focus8(), + width: 2, + strokeAlign: BorderSide.strokeAlignInside, + ), + outlineOffset: -1, + ), + ), + ); +} + +RemixTextFieldStyler _fortalTextFieldClassicStyler(FortalTextFieldSize size) { + return _fortalTextFieldNeutralText( + _fortalTextFieldBaseStyler(size, bordered: true), + ) + .color(FortalTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix( + behindContent: _fortalTextFieldLayer( + shadowToken: FortalTokens.shadow1Layers, + ), ), ) - .merge(_fortalTextFieldSizeStyler(size)); + .onDisabled( + _fortalTextFieldDisabledText() + .color(FortalTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix( + behindContent: _fortalTextFieldLayer( + gradients: [ + RemixLinearGradientMix( + colors: [FortalTokens.grayA2(), FortalTokens.grayA2()], + ), + ], + shadowToken: FortalTokens.shadow1Layers, + ), + ), + ), + ); } RemixTextFieldStyler _fortalTextFieldSurfaceStyler([ FortalTextFieldSize size = .size2, ]) { - return _fortalTextFieldBaseStyler(size) - .merge( - RemixTextFieldStyler( - text: TextStyler() - .color(FortalTokens.gray12()) - .fontWeight(FortalTokens.fontWeightRegular()), - hintText: TextStyler() - .color(FortalTokens.gray10()) - .fontWeight(FortalTokens.fontWeightRegular()), - cursorColor: FortalTokens.gray12(), - helperText: TextStyler() - .color(FortalTokens.gray11()) - .fontWeight(FortalTokens.fontWeightRegular()), - label: TextStyler() - .color(FortalTokens.gray12()) - .fontWeight(FortalTokens.fontWeightMedium()), - ), + return _fortalTextFieldNeutralText( + _fortalTextFieldBaseStyler(size, bordered: true), ) - .borderAll( - color: FortalTokens.gray7(), - width: FortalTokens.borderWidth1(), - strokeAlign: BorderSide.strokeAlignOutside, + .color(FortalTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix(behindContent: _fortalTextFieldLayer()), ) - .textColor(FortalTokens.gray12()) - .onFocused( - RemixTextFieldStyler().borderAll( - color: FortalTokens.accent7(), - width: FortalTokens.borderWidth1(), + .containerEffects( + RemixBoxEffectsMix( + overContent: _fortalTextFieldInsetRing(FortalTokens.grayA7()), ), ) .onDisabled( - RemixTextFieldStyler( - text: TextStyler().color(FortalTokens.gray10()), - hintText: TextStyler().color(FortalTokens.gray8()), + _fortalTextFieldDisabledText() + .color(FortalTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix( + behindContent: _fortalTextFieldLayer( + gradients: [ + RemixLinearGradientMix( + colors: [FortalTokens.grayA2(), FortalTokens.grayA2()], + ), + ], + ), + ), ) - .backgroundColor(FortalTokens.colorSurface()) - .borderAll( - color: FortalTokens.gray6(), - width: FortalTokens.borderWidth1(), + .containerEffects( + RemixBoxEffectsMix( + overContent: _fortalTextFieldInsetRing(FortalTokens.grayA6()), + ), ), ); } @@ -107,64 +156,144 @@ RemixTextFieldStyler _fortalTextFieldSurfaceStyler([ RemixTextFieldStyler _fortalTextFieldSoftStyler([ FortalTextFieldSize size = .size2, ]) { - return _fortalTextFieldBaseStyler(size) + return _fortalTextFieldBaseStyler(size, bordered: false) .merge( RemixTextFieldStyler( - text: TextStyler().fontWeight(FortalTokens.fontWeightRegular()), - hintText: TextStyler() - .color(FortalTokens.accentA11()) - .fontWeight(FortalTokens.fontWeightRegular()), + text: .fontWeight(FortalTokens.fontWeightRegular()), + hintText: .color( + FortalTokens.accentA11(), + ).fontWeight(FortalTokens.fontWeightRegular()), cursorColor: FortalTokens.accent12(), - helperText: TextStyler() - .color(FortalTokens.gray11()) - .fontWeight(FortalTokens.fontWeightRegular()), - label: TextStyler() - .color(FortalTokens.gray12()) - .fontWeight(FortalTokens.fontWeightMedium()), + helperText: .color( + FortalTokens.gray11(), + ).fontWeight(FortalTokens.fontWeightRegular()), + label: .color( + FortalTokens.gray12(), + ).fontWeight(FortalTokens.fontWeightMedium()), ), ) .textColor(FortalTokens.accent12()) .wrap(.iconTheme(color: FortalTokens.accent10())) - .backgroundColor(FortalTokens.accent3()) - .borderAll( - color: FortalTokens.accent3(), - width: FortalTokens.borderWidth1(), - strokeAlign: BorderSide.strokeAlignOutside, + .color(FortalTokens.accentA3()) + .containerEffects( + RemixBoxEffectsMix(behindContent: _fortalTextFieldLayer()), ) .onDisabled( - RemixTextFieldStyler( - text: TextStyler().color(FortalTokens.accentA8()), - hintText: TextStyler().color(FortalTokens.accentA7()), - ) - .backgroundColor(FortalTokens.accentA3()) - .borderAll( - color: FortalTokens.accentA4(), - width: FortalTokens.borderWidth1(), + _fortalTextFieldDisabledText() + .color(FortalTokens.grayA3()) + .containerEffects( + RemixBoxEffectsMix(behindContent: _fortalTextFieldLayer()), ), ); } -RemixTextFieldStyler _fortalTextFieldSizeStyler(FortalTextFieldSize size) { - return switch (size) { - .size1 => RemixTextFieldStyler( - text: TextStyler().fontSize(12.0), - hintText: TextStyler().fontSize(12.0), - helperText: TextStyler().fontSize(11.0), - label: TextStyler().fontSize(11.0), - ).borderRadiusAll(FortalTokens.radius2()).paddingAll(8.0), - .size2 => RemixTextFieldStyler( - text: TextStyler().fontSize(14.0), - hintText: TextStyler().fontSize(14.0), - helperText: TextStyler().fontSize(12.0), - label: TextStyler().fontSize(13.0), - ).borderRadiusAll(FortalTokens.radius3()).paddingAll(12.0), - .size3 => RemixTextFieldStyler( - text: TextStyler().fontSize(15.0), - hintText: TextStyler().fontSize(15.0), - helperText: TextStyler().fontSize(14.0), - label: TextStyler().fontSize(15.0), - ).borderRadiusAll(FortalTokens.radius4()).paddingAll(16.0), - }; +RemixTextFieldStyler _fortalTextFieldNeutralText(RemixTextFieldStyler base) => + base.merge( + RemixTextFieldStyler( + text: .color(FortalTokens.gray12()), + hintText: .color(FortalTokens.grayA10()), + cursorColor: FortalTokens.gray12(), + helperText: .color(FortalTokens.gray11()), + label: .color( + FortalTokens.gray12(), + ).fontWeight(FortalTokens.fontWeightMedium()), + ), + ); + +RemixTextFieldStyler _fortalTextFieldDisabledText() => + RemixTextFieldStyler( + text: .color(FortalTokens.gray11()), + hintText: .color(FortalTokens.grayA8()), + cursorColor: FortalTokens.gray8(), + ).onFocused( + .containerEffects( + RemixBoxEffectsMix( + outline: BorderSideMix( + color: FortalTokens.gray8(), + width: 2, + strokeAlign: BorderSide.strokeAlignInside, + ), + outlineOffset: -1, + ), + ), + ); + +RemixTextFieldStyler _fortalTextFieldErrorStyler() { + return RemixTextFieldStyler( + helperText: .color(FortalTokens.error11()), + label: .color(FortalTokens.error11()), + cursorColor: FortalTokens.error9(), + containerEffects: RemixBoxEffectsMix( + overContent: _fortalTextFieldLayer( + shadows: [ + RemixBoxShadowMix( + kind: .inset, + color: FortalTokens.errorA7(), + spreadRadius: 1, + ), + ], + ), + outline: BorderSideMix( + color: FortalTokens.error8(), + width: 2, + strokeAlign: BorderSide.strokeAlignInside, + ), + outlineOffset: -1, + ), + ); } +({ + double height, + double paddingX, + double spacing, + Radius radius, + TextStyleToken text, +}) +_fortalTextFieldMetrics(FortalTextFieldSize size, {required bool bordered}) => + switch (size) { + .size1 => ( + height: FortalTokens.space5(), + paddingX: bordered + ? FortalTokens.textFieldPadding1() + : FortalTokens.selectSpace1Half(), + spacing: FortalTokens.space2(), + radius: FortalTokens.radius2OrFull(), + text: FortalTokens.text1, + ), + .size2 => ( + height: FortalTokens.space6(), + paddingX: bordered + ? FortalTokens.textFieldPadding2() + : FortalTokens.space2(), + spacing: FortalTokens.space2(), + radius: FortalTokens.radius2OrFull(), + text: FortalTokens.text2, + ), + .size3 => ( + height: FortalTokens.space7(), + paddingX: bordered + ? FortalTokens.textFieldPadding3() + : FortalTokens.space3(), + spacing: FortalTokens.space3(), + radius: FortalTokens.radius3OrFull(), + text: FortalTokens.text3, + ), + }; + +RemixBoxEffectLayerMix _fortalTextFieldInsetRing(Color color) => + _fortalTextFieldLayer( + shadows: [RemixBoxShadowMix(kind: .inset, color: color, spreadRadius: 1)], + ); + +RemixBoxEffectLayerMix _fortalTextFieldLayer({ + List? gradients, + List? shadows, + RemixBoxShadowListToken? shadowToken, +}) => RemixBoxEffectLayerMix( + gradients: gradients, + shadows: shadows, + shadowToken: shadowToken, +); + /// Fortal-themed preset for [RemixTextField]. diff --git a/packages/remix/lib/src/components/textfield/textfield.dart b/packages/remix/lib/src/components/textfield/textfield.dart index d52c6bb6..fae3fd0e 100644 --- a/packages/remix/lib/src/components/textfield/textfield.dart +++ b/packages/remix/lib/src/components/textfield/textfield.dart @@ -13,6 +13,7 @@ import 'package:naked_ui/naked_ui.dart'; import '../../style/style.dart'; import '../../utilities/remix_style.dart'; import '../../fortal/fortal.dart'; +import '../../rendering/remix_box_effects.dart'; part 'textfield_spec.dart'; part 'textfield_style.dart'; diff --git a/packages/remix/lib/src/components/textfield/textfield.g.dart b/packages/remix/lib/src/components/textfield/textfield.g.dart index 643a113a..017f1c33 100644 --- a/packages/remix/lib/src/components/textfield/textfield.g.dart +++ b/packages/remix/lib/src/components/textfield/textfield.g.dart @@ -23,6 +23,7 @@ mixin _$RemixTextFieldSpec implements Spec, Diagnosticable { StyleSpec get layout; StyleSpec get helperText; StyleSpec get label; + RemixBoxEffectsSpec? get containerEffects; @override Type get type => RemixTextFieldSpec; @@ -45,6 +46,7 @@ mixin _$RemixTextFieldSpec implements Spec, Diagnosticable { StyleSpec? layout, StyleSpec? helperText, StyleSpec? label, + RemixBoxEffectsSpec? containerEffects, }) { return RemixTextFieldSpec( text: text ?? this.text, @@ -64,6 +66,7 @@ mixin _$RemixTextFieldSpec implements Spec, Diagnosticable { layout: layout ?? this.layout, helperText: helperText ?? this.helperText, label: label ?? this.label, + containerEffects: containerEffects ?? this.containerEffects, ); } @@ -102,6 +105,11 @@ mixin _$RemixTextFieldSpec implements Spec, Diagnosticable { layout: layout.lerp(other?.layout, t), helperText: helperText.lerp(other?.helperText, t), label: label.lerp(other?.label, t), + containerEffects: MixOps.lerpSnap( + containerEffects, + other?.containerEffects, + t, + ), ); } @@ -123,6 +131,7 @@ mixin _$RemixTextFieldSpec implements Spec, Diagnosticable { layout, helperText, label, + containerEffects, ]; @override @@ -180,7 +189,8 @@ mixin _$RemixTextFieldSpec implements Spec, Diagnosticable { ..add(DiagnosticsProperty('container', container)) ..add(DiagnosticsProperty('layout', layout)) ..add(DiagnosticsProperty('helperText', helperText)) - ..add(DiagnosticsProperty('label', label)); + ..add(DiagnosticsProperty('label', label)) + ..add(DiagnosticsProperty('containerEffects', containerEffects)); } } @@ -258,6 +268,69 @@ class FortalTextField extends StatelessWidget { this.excludeSemantics = false, }); + /// Raised treatment with Radix's level-one shadow. + const FortalTextField.classic({ + super.key, + this.size = .size2, + this.controller, + this.focusNode, + this.label, + this.hintText, + this.helperText, + this.error = false, + this.keyboardType, + this.textInputAction, + this.textCapitalization = .none, + this.textDirection, + this.obscureText = false, + this.enabled = true, + this.readOnly = false, + this.autofocus = false, + this.maxLines = 1, + this.minLines, + this.expands = false, + this.maxLength, + this.maxLengthEnforcement, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onAppPrivateCommand, + this.inputFormatters, + this.showCursor, + this.obscuringCharacter = '•', + this.autocorrect = true, + this.enableSuggestions = true, + this.smartDashesType, + this.smartQuotesType, + this.dragStartBehavior = .start, + this.enableInteractiveSelection = true, + this.selectionControls, + this.onTap, + this.onTapOutside, + this.onPressUpOutside, + this.onTapAlwaysCalled = false, + this.scrollController, + this.scrollPhysics, + this.autofillHints, + this.contentInsertionConfiguration, + this.clipBehavior = .hardEdge, + this.restorationId, + this.stylusHandwritingEnabled = true, + this.enableIMEPersonalizedLearning = true, + this.contextMenuBuilder, + this.spellCheckConfiguration, + this.magnifierConfiguration, + this.canRequestFocus = true, + this.ignorePointers, + this.undoController, + this.groupId = EditableText, + this.leading, + this.trailing, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + }) : variant = FortalTextFieldVariant.classic; + /// Surface treatment with neutral border and text colors. const FortalTextField.surface({ super.key, @@ -593,6 +666,7 @@ class RemixTextFieldStyler final Prop>? $layout; final Prop>? $helperText; final Prop>? $label; + final Prop? $containerEffects; const RemixTextFieldStyler.create({ Prop>? text, @@ -611,6 +685,7 @@ class RemixTextFieldStyler Prop>? layout, Prop>? helperText, Prop>? label, + Prop? containerEffects, super.variants, super.modifier, super.animation, @@ -629,7 +704,8 @@ class RemixTextFieldStyler $container = container, $layout = layout, $helperText = helperText, - $label = label; + $label = label, + $containerEffects = containerEffects; RemixTextFieldStyler({ TextStyler? text, @@ -648,6 +724,7 @@ class RemixTextFieldStyler FlexBoxStyler? layout, TextStyler? helperText, TextStyler? label, + RemixBoxEffectsMix? containerEffects, AnimationConfig? animation, WidgetModifierConfig? modifier, List>? variants, @@ -668,6 +745,7 @@ class RemixTextFieldStyler layout: Prop.maybeMix(layout), helperText: Prop.maybeMix(helperText), label: Prop.maybeMix(label), + containerEffects: Prop.maybeMix(containerEffects), variants: variants, modifier: modifier, animation: animation, @@ -705,6 +783,8 @@ class RemixTextFieldStyler RemixTextFieldStyler().helperText(value); factory RemixTextFieldStyler.label(TextStyler value) => RemixTextFieldStyler().label(value); + factory RemixTextFieldStyler.containerEffects(RemixBoxEffectsMix value) => + RemixTextFieldStyler().containerEffects(value); factory RemixTextFieldStyler.color(Color value) => RemixTextFieldStyler().color(value); factory RemixTextFieldStyler.gradient(GradientMix value) => @@ -1322,6 +1402,11 @@ class RemixTextFieldStyler return merge(RemixTextFieldStyler(label: value)); } + /// Sets the containerEffects. + RemixTextFieldStyler containerEffects(RemixBoxEffectsMix value) { + return merge(RemixTextFieldStyler(containerEffects: value)); + } + /// Sets the animation configuration. @override RemixTextFieldStyler animate(AnimationConfig value) { @@ -1377,6 +1462,10 @@ class RemixTextFieldStyler layout: MixOps.merge($layout, other?.$layout), helperText: MixOps.merge($helperText, other?.$helperText), label: MixOps.merge($label, other?.$label), + containerEffects: MixOps.merge( + $containerEffects, + other?.$containerEffects, + ), variants: MixOps.mergeVariants($variants, other?.$variants), modifier: MixOps.mergeModifier($modifier, other?.$modifier), animation: MixOps.mergeAnimation($animation, other?.$animation), @@ -1403,6 +1492,7 @@ class RemixTextFieldStyler layout: MixOps.resolve(context, $layout), helperText: MixOps.resolve(context, $helperText), label: MixOps.resolve(context, $label), + containerEffects: MixOps.resolve(context, $containerEffects), ); return StyleSpec( @@ -1433,7 +1523,8 @@ class RemixTextFieldStyler ..add(DiagnosticsProperty('container', $container)) ..add(DiagnosticsProperty('layout', $layout)) ..add(DiagnosticsProperty('helperText', $helperText)) - ..add(DiagnosticsProperty('label', $label)); + ..add(DiagnosticsProperty('label', $label)) + ..add(DiagnosticsProperty('containerEffects', $containerEffects)); } @override @@ -1454,6 +1545,7 @@ class RemixTextFieldStyler $layout, $helperText, $label, + $containerEffects, $animation, $modifier, $variants, diff --git a/packages/remix/lib/src/components/textfield/textfield_spec.dart b/packages/remix/lib/src/components/textfield/textfield_spec.dart index 7c82f5b9..d045e551 100644 --- a/packages/remix/lib/src/components/textfield/textfield_spec.dart +++ b/packages/remix/lib/src/components/textfield/textfield_spec.dart @@ -166,6 +166,10 @@ class RemixTextFieldSpec with _$RemixTextFieldSpec { @override final StyleSpec label; + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? containerEffects; + /// Creates a RemixTextFieldSpec with optional styling and configuration. /// /// Provides sensible defaults for all properties to ensure the text field @@ -202,10 +206,24 @@ class RemixTextFieldSpec with _$RemixTextFieldSpec { StyleSpec? layout, StyleSpec? helperText, StyleSpec? label, + this.containerEffects, }) : text = text ?? const StyleSpec(spec: TextSpec()), hintText = hintText ?? const StyleSpec(spec: TextSpec()), helperText = helperText ?? const StyleSpec(spec: TextSpec()), label = label ?? const StyleSpec(spec: TextSpec()), container = container ?? const StyleSpec(spec: FlexBoxSpec()), layout = layout ?? const StyleSpec(spec: FlexBoxSpec()); + + @override + RemixTextFieldSpec lerp(RemixTextFieldSpec? other, double t) { + final generated = super.lerp(other, t); + if (other == null) return generated; + return generated.copyWith( + containerEffects: RemixBoxEffectsSpec.lerpNullable( + containerEffects, + other.containerEffects, + t, + ), + ); + } } diff --git a/packages/remix/lib/src/components/textfield/textfield_widget.dart b/packages/remix/lib/src/components/textfield/textfield_widget.dart index 244cf10e..7ed3ca4d 100644 --- a/packages/remix/lib/src/components/textfield/textfield_widget.dart +++ b/packages/remix/lib/src/components/textfield/textfield_widget.dart @@ -356,8 +356,10 @@ class RemixTextField extends StatelessWidget { ) : styledEditableText; - final withAccessories = RowBox( + final withAccessories = RemixFlexBoxWithEffects( styleSpec: spec.container, + direction: Axis.horizontal, + containerEffects: spec.containerEffects, children: [ ?leading, // ignore: avoid-flexible-outside-flex @@ -403,7 +405,7 @@ class _RemixTextFieldBodyState extends State<_RemixTextFieldBody> { void initState() { super.initState(); _styleController = WidgetStatesController({ - if (!widget.config.enabled) .disabled, + if (!widget.config.enabled || widget.config.readOnly) .disabled, if (widget.config.error) .error, }); } @@ -412,7 +414,7 @@ class _RemixTextFieldBodyState extends State<_RemixTextFieldBody> { void didUpdateWidget(_RemixTextFieldBody oldWidget) { super.didUpdateWidget(oldWidget); _styleController - ..update(.disabled, !widget.config.enabled) + ..update(.disabled, !widget.config.enabled || widget.config.readOnly) ..update(.error, widget.config.error); } @@ -440,7 +442,7 @@ class _RemixTextFieldBodyState extends State<_RemixTextFieldBody> { /// It seeds the vertical [ColumnBox] wrapper (the [RemixTextFieldSpec.layout]) /// with the default min-size / start-alignment layout and an 8px vertical /// spacing. Merging it underneath the caller's style means customizing a -/// single layout property (e.g. `.layout(FlexBoxStyler().spacing(12))`) keeps +/// single layout property (e.g. `.layout(.spacing(12))`) keeps /// the remaining defaults instead of falling back to `ColumnBox`'s /// `mainAxisSize: max` / `crossAxisAlignment: center`. final RemixTextFieldStyler _baseStyle = RemixTextFieldStyler( diff --git a/packages/remix/lib/src/components/toggle/fortal_toggle_styles.dart b/packages/remix/lib/src/components/toggle/fortal_toggle_styles.dart index cbe2c21f..5fd59ff8 100644 --- a/packages/remix/lib/src/components/toggle/fortal_toggle_styles.dart +++ b/packages/remix/lib/src/components/toggle/fortal_toggle_styles.dart @@ -15,44 +15,72 @@ enum FortalToggleVariant { ghost, outline } RemixToggleStyler fortalToggleStyler({ FortalToggleVariant variant = .ghost, FortalToggleSize size = .size2, + bool highContrast = false, }) { return switch (variant) { - .ghost => _fortalToggleGhostStyler(size), - .outline => _fortalToggleOutlineStyler(size), + .ghost => _fortalToggleGhostStyler(size, highContrast: highContrast), + .outline => _fortalToggleOutlineStyler(size, highContrast: highContrast), }; } RemixToggleStyler _fortalToggleBaseStyler(FortalToggleSize size) { return RemixToggleStyler() - .container(FlexBoxStyler().mainAxisSize(.min)) + .container(.mainAxisSize(.min)) .foregroundColor(FortalTokens.gray12()) - .labelFontWeight(.w500) - .onFocused( - RemixToggleStyler().borderAll( - color: FortalTokens.focusA8(), - width: FortalTokens.focusRingWidth(), - ), - ) - .onDisabled( - RemixToggleStyler() - .backgroundColor(FortalTokens.grayA3()) - .foregroundColor(FortalTokens.gray8()), - ) + .labelFontWeight(FortalTokens.fontWeightMedium()) .merge(_fortalToggleSizeStyler(size)); } -RemixToggleStyler _fortalToggleGhostStyler([FortalToggleSize size = .size2]) { +RemixToggleStyler _fortalToggleFocusStyler() { + return RemixToggleStyler().borderAll( + color: FortalTokens.focusA8(), + width: FortalTokens.focusRingWidth(), + strokeAlign: BorderSide.strokeAlignInside, + ); +} + +RemixToggleStyler _fortalToggleDisabledStyler({bool outlined = false}) { + final style = RemixToggleStyler() + .backgroundColor(FortalTokens.grayA3()) + .foregroundColor(FortalTokens.gray8()); + return outlined + ? style.borderAll( + color: FortalTokens.grayA6(), + width: FortalTokens.borderWidth1(), + strokeAlign: BorderSide.strokeAlignInside, + ) + : style; +} + +RemixToggleStyler _fortalToggleGhostStyler( + FortalToggleSize size, { + required bool highContrast, +}) { return _fortalToggleBaseStyler(size) .backgroundColor(Colors.transparent) .onHovered(RemixToggleStyler().backgroundColor(FortalTokens.grayA3())) + .onPressed(RemixToggleStyler().backgroundColor(FortalTokens.grayA4())) .onSelected( RemixToggleStyler() .backgroundColor(FortalTokens.accent3()) - .foregroundColor(FortalTokens.accent11()), - ); + .foregroundColor( + highContrast ? FortalTokens.accent12() : FortalTokens.accent11(), + ) + .onHovered( + RemixToggleStyler().backgroundColor(FortalTokens.accent4()), + ) + .onPressed( + RemixToggleStyler().backgroundColor(FortalTokens.accent5()), + ), + ) + .onFocused(_fortalToggleFocusStyler()) + .onDisabled(_fortalToggleDisabledStyler()); } -RemixToggleStyler _fortalToggleOutlineStyler([FortalToggleSize size = .size2]) { +RemixToggleStyler _fortalToggleOutlineStyler( + FortalToggleSize size, { + required bool highContrast, +}) { return _fortalToggleBaseStyler(size) .backgroundColor(Colors.transparent) .borderAll( @@ -61,12 +89,23 @@ RemixToggleStyler _fortalToggleOutlineStyler([FortalToggleSize size = .size2]) { strokeAlign: BorderSide.strokeAlignInside, ) .onHovered(RemixToggleStyler().backgroundColor(FortalTokens.grayA3())) + .onPressed(RemixToggleStyler().backgroundColor(FortalTokens.grayA4())) .onSelected( RemixToggleStyler() .backgroundColor(FortalTokens.accentA3()) - .foregroundColor(FortalTokens.accent10()) - .borderAll(color: FortalTokens.accentA5()), - ); + .foregroundColor( + highContrast ? FortalTokens.accent12() : FortalTokens.accent11(), + ) + .borderAll(color: FortalTokens.accentA5()) + .onHovered( + RemixToggleStyler().backgroundColor(FortalTokens.accentA4()), + ) + .onPressed( + RemixToggleStyler().backgroundColor(FortalTokens.accentA5()), + ), + ) + .onFocused(_fortalToggleFocusStyler()) + .onDisabled(_fortalToggleDisabledStyler(outlined: true)); } RemixToggleStyler _fortalToggleSizeStyler(FortalToggleSize size) { @@ -76,30 +115,27 @@ RemixToggleStyler _fortalToggleSizeStyler(FortalToggleSize size) { .paddingX(FortalTokens.space2()) .paddingY(FortalTokens.space1()) .borderRadiusAll(FortalTokens.radius2()) - .spacing(2), - label: TextStyler() - .fontSize(12.0) - .height(16.0 / 12.0) - .letterSpacing(0.0025), - icon: IconStyler(size: 12), + .spacing(FortalTokens.toggleGap1()), + label: .style(FortalTokens.text1.mix()), + icon: .size(FortalTokens.space3()), ), .size2 => RemixToggleStyler( container: FlexBoxStyler() .paddingX(FortalTokens.space3()) .paddingY(FortalTokens.space2()) .borderRadiusAll(FortalTokens.radius2()) - .spacing(4), - label: TextStyler().fontSize(14.0).height(20.0 / 14.0).letterSpacing(0.0), - icon: IconStyler(size: 16), + .spacing(FortalTokens.space1()), + label: .style(FortalTokens.text2.mix()), + icon: .size(FortalTokens.space4()), ), .size3 => RemixToggleStyler( container: FlexBoxStyler() .paddingX(FortalTokens.space4()) .paddingY(FortalTokens.space2()) .borderRadiusAll(FortalTokens.radius3()) - .spacing(6), - label: TextStyler().fontSize(16.0).height(24.0 / 16.0).letterSpacing(0.0), - icon: IconStyler(size: 20), + .spacing(FortalTokens.toggleGap3()), + label: .style(FortalTokens.text3.mix()), + icon: .size(FortalTokens.spinnerSize3()), ), }; } diff --git a/packages/remix/lib/src/components/toggle_group/fortal_toggle_group_styles.dart b/packages/remix/lib/src/components/toggle_group/fortal_toggle_group_styles.dart index a8a62a60..6beb4567 100644 --- a/packages/remix/lib/src/components/toggle_group/fortal_toggle_group_styles.dart +++ b/packages/remix/lib/src/components/toggle_group/fortal_toggle_group_styles.dart @@ -15,11 +15,27 @@ enum FortalToggleGroupVariant { soft, surface } RemixToggleGroupStyler fortalToggleGroupStyler({ FortalToggleGroupVariant variant = .soft, FortalToggleGroupSize size = .size2, + bool highContrast = false, }) { - final selectedColor = switch (variant) { - .soft => FortalTokens.accent3(), - .surface => FortalTokens.accentSurface(), + final ( + selectedColor, + selectedHoverColor, + selectedPressedColor, + ) = switch (variant) { + .soft => ( + FortalTokens.accent3(), + FortalTokens.accent4(), + FortalTokens.accent5(), + ), + .surface => ( + FortalTokens.accentSurface(), + FortalTokens.accentA4(), + FortalTokens.accentA5(), + ), }; + final selectedForeground = highContrast + ? FortalTokens.accent12() + : FortalTokens.accent11(); return RemixToggleGroupStyler( container: FlexBoxStyler( @@ -36,13 +52,30 @@ RemixToggleGroupStyler fortalToggleGroupStyler({ mainAxisSize: .min, spacing: 0, ), - item: RemixToggleGroupItemStyler() - .alignment(.center) + item: .alignment(.center) .foregroundColor(FortalTokens.gray11()) - .labelFontWeight(.w500) + .labelFontWeight(FortalTokens.fontWeightMedium()) .onHovered( RemixToggleGroupItemStyler().backgroundColor(FortalTokens.grayA3()), ) + .onPressed( + RemixToggleGroupItemStyler().backgroundColor(FortalTokens.grayA4()), + ) + .onSelected( + RemixToggleGroupItemStyler() + .backgroundColor(selectedColor) + .foregroundColor(selectedForeground) + .onHovered( + RemixToggleGroupItemStyler().backgroundColor( + selectedHoverColor, + ), + ) + .onPressed( + RemixToggleGroupItemStyler().backgroundColor( + selectedPressedColor, + ), + ), + ) .onFocused( RemixToggleGroupItemStyler().borderAll( color: FortalTokens.focusA8(), @@ -50,11 +83,6 @@ RemixToggleGroupStyler fortalToggleGroupStyler({ strokeAlign: BorderSide.strokeAlignInside, ), ) - .onSelected( - RemixToggleGroupItemStyler() - .backgroundColor(selectedColor) - .foregroundColor(FortalTokens.accent11()), - ) .onDisabled( RemixToggleGroupItemStyler() .backgroundColor(FortalTokens.grayA3()) @@ -69,36 +97,39 @@ RemixToggleGroupStyler _fortalToggleGroupSizeStyler( return switch (size) { .size1 => RemixToggleGroupStyler( container: FlexBoxStyler().borderRadiusAll(FortalTokens.radius2()), - item: RemixToggleGroupItemStyler( - container: FlexBoxStyler() - .paddingX(FortalTokens.space2()) - .paddingY(FortalTokens.space1()) - .spacing(2), - label: TextStyler().fontSize(12), - icon: IconStyler(size: 12), - ), + item: + .container( + FlexBoxStyler() + .paddingX(FortalTokens.space2()) + .paddingY(FortalTokens.space1()) + .spacing(FortalTokens.toggleGap1()), + ) + .label(.style(FortalTokens.text1.mix())) + .icon(.size(FortalTokens.space3())), ), .size2 => RemixToggleGroupStyler( container: FlexBoxStyler().borderRadiusAll(FortalTokens.radius2()), - item: RemixToggleGroupItemStyler( - container: FlexBoxStyler() - .paddingX(FortalTokens.space3()) - .paddingY(FortalTokens.space2()) - .spacing(4), - label: TextStyler().fontSize(14), - icon: IconStyler(size: 16), - ), + item: + .container( + FlexBoxStyler() + .paddingX(FortalTokens.space3()) + .paddingY(FortalTokens.space2()) + .spacing(FortalTokens.space1()), + ) + .label(.style(FortalTokens.text2.mix())) + .icon(.size(FortalTokens.space4())), ), .size3 => RemixToggleGroupStyler( container: FlexBoxStyler().borderRadiusAll(FortalTokens.radius3()), - item: RemixToggleGroupItemStyler( - container: FlexBoxStyler() - .paddingX(FortalTokens.space4()) - .paddingY(FortalTokens.space2()) - .spacing(6), - label: TextStyler().fontSize(16), - icon: IconStyler(size: 20), - ), + item: + .container( + FlexBoxStyler() + .paddingX(FortalTokens.space4()) + .paddingY(FortalTokens.space2()) + .spacing(FortalTokens.toggleGap3()), + ) + .label(.style(FortalTokens.text3.mix())) + .icon(.size(FortalTokens.spinnerSize3())), ), }; } diff --git a/packages/remix/lib/src/components/tooltip/fortal_tooltip_styles.dart b/packages/remix/lib/src/components/tooltip/fortal_tooltip_styles.dart index 02f70190..a24f9081 100644 --- a/packages/remix/lib/src/components/tooltip/fortal_tooltip_styles.dart +++ b/packages/remix/lib/src/components/tooltip/fortal_tooltip_styles.dart @@ -7,15 +7,16 @@ part of 'tooltip.dart'; factoryParameters: .only({}), ) RemixTooltipStyler fortalTooltipStyler() { - return RemixTooltipStyler() + return RemixTooltipStyler( + label: .style(FortalTokens.text1.mix()), + waitDuration: const Duration(milliseconds: 200), + arrowColor: FortalTokens.gray12(), + ) .borderRadiusAll(FortalTokens.radius2()) - .paddingY(FortalTokens.space2()) + .paddingY(FortalTokens.space1()) .paddingX(FortalTokens.space2()) - .marginY(FortalTokens.space1()) - .wrap( - .defaultTextStyle(style: TextStyleMix().color(FortalTokens.gray1())), - ) - .backgroundColor(FortalTokens.gray11()); + .label(.color(FortalTokens.gray1())) + .backgroundColor(FortalTokens.gray12()); } /// Fortal-themed preset for [RemixTooltip]. diff --git a/packages/remix/lib/src/components/tooltip/tooltip.g.dart b/packages/remix/lib/src/components/tooltip/tooltip.g.dart index 89fbc734..1b702a6b 100644 --- a/packages/remix/lib/src/components/tooltip/tooltip.g.dart +++ b/packages/remix/lib/src/components/tooltip/tooltip.g.dart @@ -12,6 +12,7 @@ mixin _$RemixTooltipSpec implements Spec, Diagnosticable { Duration? get waitDuration; Duration? get showDuration; Duration? get dismissDuration; + Color? get arrowColor; @override Type get type => RemixTooltipSpec; @@ -23,6 +24,7 @@ mixin _$RemixTooltipSpec implements Spec, Diagnosticable { Duration? waitDuration, Duration? showDuration, Duration? dismissDuration, + Color? arrowColor, }) { return RemixTooltipSpec( container: container ?? this.container, @@ -30,6 +32,7 @@ mixin _$RemixTooltipSpec implements Spec, Diagnosticable { waitDuration: waitDuration ?? this.waitDuration, showDuration: showDuration ?? this.showDuration, dismissDuration: dismissDuration ?? this.dismissDuration, + arrowColor: arrowColor ?? this.arrowColor, ); } @@ -45,6 +48,7 @@ mixin _$RemixTooltipSpec implements Spec, Diagnosticable { other?.dismissDuration, t, ), + arrowColor: MixOps.lerp(arrowColor, other?.arrowColor, t), ); } @@ -55,6 +59,7 @@ mixin _$RemixTooltipSpec implements Spec, Diagnosticable { waitDuration, showDuration, dismissDuration, + arrowColor, ]; @override @@ -101,7 +106,8 @@ mixin _$RemixTooltipSpec implements Spec, Diagnosticable { ..add(DiagnosticsProperty('label', label)) ..add(DiagnosticsProperty('waitDuration', waitDuration)) ..add(DiagnosticsProperty('showDuration', showDuration)) - ..add(DiagnosticsProperty('dismissDuration', dismissDuration)); + ..add(DiagnosticsProperty('dismissDuration', dismissDuration)) + ..add(ColorProperty('arrowColor', arrowColor)); } } @@ -156,6 +162,7 @@ class RemixTooltipStyler extends MixStyler final Prop? $waitDuration; final Prop? $showDuration; final Prop? $dismissDuration; + final Prop? $arrowColor; const RemixTooltipStyler.create({ Prop>? container, @@ -163,6 +170,7 @@ class RemixTooltipStyler extends MixStyler Prop? waitDuration, Prop? showDuration, Prop? dismissDuration, + Prop? arrowColor, super.variants, super.modifier, super.animation, @@ -170,7 +178,8 @@ class RemixTooltipStyler extends MixStyler $label = label, $waitDuration = waitDuration, $showDuration = showDuration, - $dismissDuration = dismissDuration; + $dismissDuration = dismissDuration, + $arrowColor = arrowColor; RemixTooltipStyler({ BoxStyler? container, @@ -178,6 +187,7 @@ class RemixTooltipStyler extends MixStyler Duration? waitDuration, Duration? showDuration, Duration? dismissDuration, + Color? arrowColor, AnimationConfig? animation, WidgetModifierConfig? modifier, List>? variants, @@ -187,6 +197,7 @@ class RemixTooltipStyler extends MixStyler waitDuration: Prop.maybe(waitDuration), showDuration: Prop.maybe(showDuration), dismissDuration: Prop.maybe(dismissDuration), + arrowColor: Prop.maybe(arrowColor), variants: variants, modifier: modifier, animation: animation, @@ -202,6 +213,8 @@ class RemixTooltipStyler extends MixStyler RemixTooltipStyler().showDuration(value); factory RemixTooltipStyler.dismissDuration(Duration value) => RemixTooltipStyler().dismissDuration(value); + factory RemixTooltipStyler.arrowColor(Color value) => + RemixTooltipStyler().arrowColor(value); factory RemixTooltipStyler.alignment(AlignmentGeometry value) => RemixTooltipStyler().alignment(value); factory RemixTooltipStyler.padding(EdgeInsetsGeometryMix value) => @@ -699,6 +712,11 @@ class RemixTooltipStyler extends MixStyler return merge(RemixTooltipStyler(dismissDuration: value)); } + /// Sets the arrowColor. + RemixTooltipStyler arrowColor(Color value) { + return merge(RemixTooltipStyler(arrowColor: value)); + } + /// Sets the animation configuration. @override RemixTooltipStyler animate(AnimationConfig value) { @@ -731,6 +749,7 @@ class RemixTooltipStyler extends MixStyler waitDuration: MixOps.merge($waitDuration, other?.$waitDuration), showDuration: MixOps.merge($showDuration, other?.$showDuration), dismissDuration: MixOps.merge($dismissDuration, other?.$dismissDuration), + arrowColor: MixOps.merge($arrowColor, other?.$arrowColor), variants: MixOps.mergeVariants($variants, other?.$variants), modifier: MixOps.mergeModifier($modifier, other?.$modifier), animation: MixOps.mergeAnimation($animation, other?.$animation), @@ -746,6 +765,7 @@ class RemixTooltipStyler extends MixStyler waitDuration: MixOps.resolve(context, $waitDuration), showDuration: MixOps.resolve(context, $showDuration), dismissDuration: MixOps.resolve(context, $dismissDuration), + arrowColor: MixOps.resolve(context, $arrowColor), ); return StyleSpec( @@ -763,7 +783,8 @@ class RemixTooltipStyler extends MixStyler ..add(DiagnosticsProperty('label', $label)) ..add(DiagnosticsProperty('waitDuration', $waitDuration)) ..add(DiagnosticsProperty('showDuration', $showDuration)) - ..add(DiagnosticsProperty('dismissDuration', $dismissDuration)); + ..add(DiagnosticsProperty('dismissDuration', $dismissDuration)) + ..add(DiagnosticsProperty('arrowColor', $arrowColor)); } @override @@ -773,6 +794,7 @@ class RemixTooltipStyler extends MixStyler $waitDuration, $showDuration, $dismissDuration, + $arrowColor, $animation, $modifier, $variants, diff --git a/packages/remix/lib/src/components/tooltip/tooltip_spec.dart b/packages/remix/lib/src/components/tooltip/tooltip_spec.dart index 136a7bba..db164da0 100644 --- a/packages/remix/lib/src/components/tooltip/tooltip_spec.dart +++ b/packages/remix/lib/src/components/tooltip/tooltip_spec.dart @@ -14,6 +14,8 @@ class RemixTooltipSpec with _$RemixTooltipSpec { final Duration? showDuration; @override final Duration? dismissDuration; + @override + final Color? arrowColor; const RemixTooltipSpec({ StyleSpec? container, @@ -21,6 +23,7 @@ class RemixTooltipSpec with _$RemixTooltipSpec { this.waitDuration = const Duration(milliseconds: 300), this.showDuration = const Duration(milliseconds: 1500), this.dismissDuration = const Duration(milliseconds: 100), + this.arrowColor, }) : container = container ?? const StyleSpec(spec: BoxSpec()), label = label ?? const StyleSpec(spec: TextSpec()); } diff --git a/packages/remix/lib/src/fortal/base_button_recipe.dart b/packages/remix/lib/src/fortal/base_button_recipe.dart new file mode 100644 index 00000000..ba1c3f68 --- /dev/null +++ b/packages/remix/lib/src/fortal/base_button_recipe.dart @@ -0,0 +1,262 @@ +import 'package:flutter/material.dart'; +import 'package:mix/mix.dart'; + +import '../rendering/remix_ordered_color_filter.dart'; +import '../rendering/remix_box_effects.dart'; +import 'fortal_theme.dart'; + +/// Shared Radix BaseButton metrics used by Button and IconButton recipes. +({ + double height, + double paddingX, + double gap, + Radius radius, + TextStyleToken text, + double spinnerSize, +}) +fortalBaseButtonMetrics(int size) => switch (size) { + 1 => ( + height: FortalTokens.space5(), + paddingX: FortalTokens.space2(), + gap: FortalTokens.space1(), + radius: FortalTokens.radius1OrFull(), + text: FortalTokens.text1, + spinnerSize: FortalTokens.space3(), + ), + 2 => ( + height: FortalTokens.space6(), + paddingX: FortalTokens.space3(), + gap: FortalTokens.space2(), + radius: FortalTokens.radius2OrFull(), + text: FortalTokens.text2, + spinnerSize: FortalTokens.space4(), + ), + 3 => ( + height: FortalTokens.space7(), + paddingX: FortalTokens.space4(), + gap: FortalTokens.space3(), + radius: FortalTokens.radius3OrFull(), + text: FortalTokens.text3, + spinnerSize: FortalTokens.space4(), + ), + 4 => ( + height: FortalTokens.space8(), + paddingX: FortalTokens.space5(), + gap: FortalTokens.space3(), + radius: FortalTokens.radius4OrFull(), + text: FortalTokens.text4, + spinnerSize: FortalTokens.spinnerSize3(), + ), + _ => throw ArgumentError.value(size, 'size', 'Expected a size from 1 to 4.'), +}; + +/// Content-box metrics for the ghost BaseButton variant. +({double paddingX, double paddingY, double marginX, double marginY, double gap}) +fortalBaseButtonGhostMetrics(int size) => switch (size) { + 1 => ( + paddingX: FortalTokens.space2(), + paddingY: FortalTokens.space1(), + marginX: FortalTokens.baseButtonGhostMarginX12(), + marginY: FortalTokens.baseButtonGhostMarginY12(), + gap: FortalTokens.space1(), + ), + 2 => ( + paddingX: FortalTokens.space2(), + paddingY: FortalTokens.space1(), + marginX: FortalTokens.baseButtonGhostMarginX12(), + marginY: FortalTokens.baseButtonGhostMarginY12(), + gap: FortalTokens.space1(), + ), + 3 => ( + paddingX: FortalTokens.space3(), + paddingY: FortalTokens.baseButtonGhostPaddingY3(), + marginX: FortalTokens.baseButtonGhostMarginX3(), + marginY: FortalTokens.baseButtonGhostMarginY3(), + gap: FortalTokens.space2(), + ), + 4 => ( + paddingX: FortalTokens.space4(), + paddingY: FortalTokens.space2(), + marginX: FortalTokens.baseButtonGhostMarginX4(), + marginY: FortalTokens.baseButtonGhostMarginY4(), + gap: FortalTokens.space2(), + ), + _ => throw ArgumentError.value(size, 'size', 'Expected a size from 1 to 4.'), +}; + +/// Content-box metrics for the ghost IconButton variant. +({double padding, double margin}) fortalIconButtonGhostMetrics(int size) => + switch (size) { + 1 => ( + padding: FortalTokens.space1(), + margin: FortalTokens.iconButtonGhostMargin1(), + ), + 2 => ( + padding: FortalTokens.iconButtonGhostPadding2(), + margin: FortalTokens.iconButtonGhostMargin2(), + ), + 3 => ( + padding: FortalTokens.space2(), + margin: FortalTokens.iconButtonGhostMargin3(), + ), + 4 => ( + padding: FortalTokens.space3(), + margin: FortalTokens.iconButtonGhostMargin4(), + ), + _ => throw ArgumentError.value( + size, + 'size', + 'Expected a size from 1 to 4.', + ), + }; + +/// A CSS outline that does not affect layout. +RemixBoxEffectsMix fortalFocusOutline(Color color, {required double offset}) => + RemixBoxEffectsMix( + outline: BorderSideMix( + color: color, + width: 2, + strokeAlign: BorderSide.strokeAlignInside, + ), + outlineOffset: offset, + ); + +/// A one-pixel inset stroke, optionally layered over a fill. +RemixBoxEffectLayerMix fortalInsetSurface({required List strokes}) => + RemixBoxEffectLayerMix( + shadows: [ + for (final stroke in strokes) + RemixBoxShadowMix( + kind: RemixBoxShadowKind.inset, + color: stroke, + spreadRadius: 1, + ), + ], + ); + +/// Resolves a mode-aware ordered CSS filter at the component build context. +WidgetModifierConfig fortalModeAwareFilter({ + required List light, + required List dark, +}) => WidgetModifierConfig.modifier( + _FortalModeAwareFilterMix(light: light, dark: dark), +); + +/// Explicit identity filter used to clear a higher-priority state filter. +WidgetModifierConfig fortalClearFilter() => + fortalModeAwareFilter(light: const [], dark: const []); + +/// Exact classic BaseButton surface for a visual state. +RemixBoxEffectLayerMix fortalClassicBaseButtonSurface({ + required bool highContrast, + bool hovered = false, + bool pressed = false, + bool disabled = false, +}) { + final inset = FortalTokens.baseButtonClassicAfterInset(); + if (disabled) { + return RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix( + colors: [ + FortalTokens.blackA1(), + Colors.transparent, + FortalTokens.whiteA1(), + ], + stops: const [-0.2, 0.4, 1], + ), + RemixLinearGradientMix( + colors: [FortalTokens.grayA2(), FortalTokens.grayA2()], + ), + ], + gradientInsets: [inset, inset], + shadowToken: FortalTokens.baseButtonClassicDisabledShadows, + ); + } + + final baseColor = highContrast + ? FortalTokens.accent12() + : FortalTokens.accent9(); + final afterColor = hovered && !highContrast + ? FortalTokens.accent10() + : baseColor; + final pseudoGradient = RemixLinearGradientMix( + colors: [ + highContrast + ? hovered || pressed + ? FortalTokens.blackA5() + : FortalTokens.blackA3() + : hovered + ? FortalTokens.blackA2() + : pressed + ? FortalTokens.blackA2() + : FortalTokens.blackA1(), + Colors.transparent, + highContrast + ? pressed + ? FortalTokens.whiteA3() + : FortalTokens.whiteA2() + : hovered || pressed + ? FortalTokens.whiteA3() + : FortalTokens.whiteA2(), + ], + stops: hovered && !highContrast + ? const [-0.15, 0.425, 1] + : const [0, 0.5, 1], + ); + final gradients = [ + pseudoGradient, + RemixLinearGradientMix(colors: [afterColor, afterColor]), + if (pressed) + RemixLinearGradientMix( + colors: [FortalTokens.blackA1(), Colors.transparent], + ) + else ...[ + RemixLinearGradientMix( + colors: [ + Colors.transparent, + Colors.transparent, + FortalTokens.grayA4(), + FortalTokens.grayA4(), + ], + stops: const [0, 0.5, 0.5, 1], + ), + RemixLinearGradientMix( + colors: [Colors.transparent, Colors.transparent, baseColor, baseColor], + stops: const [0, 0.5, 0.8, 1], + ), + ], + ]; + return RemixBoxEffectLayerMix( + gradients: gradients, + gradientInsets: [inset, inset, ...List.filled(gradients.length - 2, 0)], + shadowToken: pressed + ? highContrast + ? FortalTokens.baseButtonClassicActiveHighContrastShadows + : FortalTokens.baseButtonClassicActiveShadows + : highContrast + ? FortalTokens.baseButtonClassicHighContrastShadows + : FortalTokens.baseButtonClassicShadows, + ); +} + +final class _FortalModeAwareFilterMix + extends ModifierMix { + const _FortalModeAwareFilterMix({required this.light, required this.dark}); + + final List light; + final List dark; + + @override + RemixOrderedColorFilterModifier resolve(BuildContext context) => + RemixOrderedColorFilterModifier( + FortalTheme.of(context).isDark ? dark : light, + ); + + @override + _FortalModeAwareFilterMix merge(_FortalModeAwareFilterMix? other) => + other ?? this; + + @override + List get props => [light, dark]; +} diff --git a/packages/remix/lib/src/fortal/computed.dart b/packages/remix/lib/src/fortal/computed.dart index c7c845f7..4a1df8f7 100644 --- a/packages/remix/lib/src/fortal/computed.dart +++ b/packages/remix/lib/src/fortal/computed.dart @@ -8,11 +8,14 @@ // Documentation for all public APIs is provided below -import 'dart:ui' show Color; +import 'dart:math' as math; +import 'dart:ui' show Color, Offset; -import 'package:flutter/painting.dart' show ColorSwatch; +import 'package:flutter/painting.dart' show BoxShadow, ColorSwatch; +import 'package:mix/mix.dart' show MixToken; import '../radix/colors/colors.dart'; +import '../rendering/remix_box_effects.dart'; import 'fortal_theme.dart'; // ============================================================================ @@ -32,14 +35,32 @@ Color computeFocusA8(RadixColorScale accent) => accent.alphaStep(8); // BACKGROUND / PANEL / OVERLAY // ============================================================================ -/// Computes primary page background color (gray step 1). -Color computeColorBackground(RadixColorScale gray) => gray.step(1); +/// Computes primary page background color. +/// +/// Radix Themes uses white in light mode and gray step 1 in dark mode. +Color computeColorBackground(RadixColorScale gray, {required bool isDark}) => + isDark ? gray.step(1) : const Color(0xFFFFFFFF); + +/// Computes solid background for panels and input surfaces. +/// +/// Radix Themes uses white in light mode and gray step 2 in dark mode. +Color computeColorPanelSolid(RadixColorScale gray, {required bool isDark}) => + isDark ? gray.step(2) : const Color(0xFFFFFFFF); -/// Computes solid background for panels and input surfaces (gray step 2). -Color computeColorPanelSolid(RadixColorScale gray) => gray.step(2); +/// Computes translucent background for floating panels. +/// +/// Radix Themes uses 70% white in light mode and gray alpha step 2 in dark +/// mode. +Color computeColorPanelTranslucent( + RadixColorScale gray, { + required bool isDark, +}) => isDark ? gray.alphaStep(2) : const Color(0xB3FFFFFF); -/// Computes translucent background for floating panels (gray alpha step 2). -Color computeColorPanelTranslucent(RadixColorScale gray) => gray.alphaStep(2); +/// Computes the neutral control surface color. +/// +/// Radix Themes uses 85% white in light mode and 25% black in dark mode. +Color computeColorSurface({required bool isDark}) => + isDark ? const Color(0x40000000) : const Color(0xD9FFFFFF); /// Computes modal backdrop overlay color. /// @@ -47,8 +68,331 @@ Color computeColorPanelTranslucent(RadixColorScale gray) => gray.alphaStep(2); Color computeColorOverlay({required bool isDark}) => isDark ? blackAlpha[8]! : blackAlpha[6]!; -Color computeShadowStroke(RadixColorScale gray) => - Color.lerp(gray.alphaStep(6), gray.step(6), 0.25)!; +/// Computes the mode-aware stroke used by elevation shadows. +Color computeShadowStroke(RadixColorScale gray, {required bool isDark}) => + mixOklabPremultiplied( + gray.alphaStep(isDark ? 6 : 3), + gray.step(isDark ? 6 : 3), + 0.25, + ); + +/// Mixes two sRGB colors in OKLab after premultiplying their channels by +/// alpha, matching CSS `color-mix(in oklab, ...)` for translucent colors. +Color mixOklabPremultiplied(Color first, Color second, double amount) { + if (!amount.isFinite || amount < 0 || amount > 1) { + throw ArgumentError.value( + amount, + 'amount', + 'Expected a value from 0 to 1.', + ); + } + final firstWeight = 1 - amount; + final alpha = first.a * firstWeight + second.a * amount; + if (alpha == 0) return const Color(0x00000000); + + final firstLab = _srgbToOklab(first); + final secondLab = _srgbToOklab(second); + final mixedLab = ( + lightness: + (firstLab.lightness * first.a * firstWeight + + secondLab.lightness * second.a * amount) / + alpha, + a: + (firstLab.a * first.a * firstWeight + secondLab.a * second.a * amount) / + alpha, + b: + (firstLab.b * first.a * firstWeight + secondLab.b * second.a * amount) / + alpha, + ); + final rgb = _oklabToSrgb(mixedLab); + + return Color.from( + alpha: alpha, + red: rgb.red.clamp(0, 1), + green: rgb.green.clamp(0, 1), + blue: rgb.blue.clamp(0, 1), + ); +} + +({double lightness, double a, double b}) _srgbToOklab(Color color) { + final red = _linearizeSrgb(color.r); + final green = _linearizeSrgb(color.g); + final blue = _linearizeSrgb(color.b); + final l = math + .pow( + 0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue, + 1 / 3, + ) + .toDouble(); + final m = math + .pow( + 0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue, + 1 / 3, + ) + .toDouble(); + final s = math + .pow( + 0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue, + 1 / 3, + ) + .toDouble(); + + return ( + lightness: 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, + a: 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, + b: 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s, + ); +} + +({double red, double green, double blue}) _oklabToSrgb( + ({double lightness, double a, double b}) color, +) { + final l = math + .pow(color.lightness + 0.3963377774 * color.a + 0.2158037573 * color.b, 3) + .toDouble(); + final m = math + .pow(color.lightness - 0.1055613458 * color.a - 0.0638541728 * color.b, 3) + .toDouble(); + final s = math + .pow(color.lightness - 0.0894841775 * color.a - 1.2914855480 * color.b, 3) + .toDouble(); + + return ( + red: _encodeSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), + green: _encodeSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), + blue: _encodeSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s), + ); +} + +double _linearizeSrgb(double channel) => channel <= 0.04045 + ? channel / 12.92 + : math.pow((channel + 0.055) / 1.055, 2.4).toDouble(); + +double _encodeSrgb(double channel) => channel <= 0.0031308 + ? 12.92 * channel + : 1.055 * math.pow(channel, 1 / 2.4).toDouble() - 0.055; + +/// Builds Radix Themes elevation shadows for the active brightness. +Map buildFortalShadows({ + required bool isDark, + required FortalThemeColors colors, +}) { + if (isDark) { + final shadows = >{ + 'shadow1': [ + _shadow( + colors.gray.scale.alphaStep(3), + kind: .inset, + offset: const Offset(0, -1), + blur: 1, + ), + _shadow(colors.gray.scale.alphaStep(3), kind: .inset, spread: 1), + _shadow( + colors.blackAlpha[5]!, + kind: .inset, + offset: const Offset(0, 3), + blur: 4, + ), + _shadow(colors.gray.scale.alphaStep(4), kind: .inset, spread: 1), + ], + 'shadow2': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow(colors.blackAlpha[3]!, blur: 0.5), + _shadow(colors.blackAlpha[6]!, offset: const Offset(0, 1), blur: 1), + _shadow( + colors.blackAlpha[6]!, + offset: const Offset(0, 2), + blur: 1, + spread: -1, + ), + _shadow(colors.blackAlpha[5]!, offset: const Offset(0, 1), blur: 3), + ], + 'shadow3': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow( + colors.blackAlpha[3]!, + offset: const Offset(0, 2), + blur: 3, + spread: -2, + ), + _shadow( + colors.blackAlpha[6]!, + offset: const Offset(0, 3), + blur: 8, + spread: -2, + ), + _shadow( + colors.blackAlpha[7]!, + offset: const Offset(0, 4), + blur: 12, + spread: -4, + ), + ], + 'shadow4': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow(colors.blackAlpha[3]!, offset: const Offset(0, 8), blur: 40), + _shadow( + colors.blackAlpha[5]!, + offset: const Offset(0, 12), + blur: 32, + spread: -16, + ), + ], + 'shadow5': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow(colors.blackAlpha[5]!, offset: const Offset(0, 12), blur: 60), + _shadow( + colors.blackAlpha[7]!, + offset: const Offset(0, 12), + blur: 32, + spread: -16, + ), + ], + 'shadow6': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow(colors.blackAlpha[4]!, offset: const Offset(0, 12), blur: 60), + _shadow(colors.blackAlpha[6]!, offset: const Offset(0, 16), blur: 64), + _shadow( + colors.blackAlpha[11]!, + offset: const Offset(0, 16), + blur: 36, + spread: -20, + ), + ], + }; + return _fortalShadowTokens(shadows); + } + + final shadows = >{ + 'shadow1': [ + _shadow(colors.gray.scale.alphaStep(5), kind: .inset, spread: 1), + _shadow( + colors.gray.scale.alphaStep(2), + kind: .inset, + offset: const Offset(0, 1.5), + blur: 2, + ), + _shadow( + colors.blackAlpha[2]!, + kind: .inset, + offset: const Offset(0, 1.5), + blur: 2, + ), + ], + 'shadow2': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow(colors.blackAlpha[1]!, blur: 0.5), + _shadow( + colors.gray.scale.alphaStep(2), + offset: const Offset(0, 1), + blur: 1, + ), + _shadow( + colors.blackAlpha[1]!, + offset: const Offset(0, 2), + blur: 1, + spread: -1, + ), + _shadow(colors.blackAlpha[1]!, offset: const Offset(0, 1), blur: 3), + ], + 'shadow3': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow( + colors.gray.scale.alphaStep(3), + offset: const Offset(0, 2), + blur: 3, + spread: -2, + ), + _shadow( + colors.blackAlpha[2]!, + offset: const Offset(0, 3), + blur: 12, + spread: -4, + ), + _shadow( + colors.blackAlpha[2]!, + offset: const Offset(0, 4), + blur: 16, + spread: -8, + ), + ], + 'shadow4': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow(colors.blackAlpha[1]!, offset: const Offset(0, 8), blur: 40), + _shadow( + colors.gray.scale.alphaStep(3), + offset: const Offset(0, 12), + blur: 32, + spread: -16, + ), + ], + 'shadow5': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow(colors.blackAlpha[3]!, offset: const Offset(0, 12), blur: 60), + _shadow( + colors.gray.scale.alphaStep(5), + offset: const Offset(0, 12), + blur: 32, + spread: -16, + ), + ], + 'shadow6': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow(colors.blackAlpha[3]!, offset: const Offset(0, 12), blur: 60), + _shadow( + colors.gray.scale.alphaStep(2), + offset: const Offset(0, 16), + blur: 64, + ), + _shadow( + colors.gray.scale.alphaStep(7), + offset: const Offset(0, 16), + blur: 36, + spread: -20, + ), + ], + }; + return _fortalShadowTokens(shadows); +} + +Map _fortalShadowTokens( + Map> shadows, +) { + final shadow1 = shadows['shadow1']!; + return { + FortalTokens.shadow1: _ordinaryShadows(shadow1), + FortalTokens.shadow1Layers: shadow1, + FortalTokens.shadow2: _ordinaryShadows(shadows['shadow2']!), + FortalTokens.shadow3: _ordinaryShadows(shadows['shadow3']!), + FortalTokens.shadow4: _ordinaryShadows(shadows['shadow4']!), + FortalTokens.shadow5: _ordinaryShadows(shadows['shadow5']!), + FortalTokens.shadow6: _ordinaryShadows(shadows['shadow6']!), + }; +} + +List _ordinaryShadows(List shadows) => [ + for (final shadow in shadows) + BoxShadow( + color: shadow.color, + offset: shadow.offset, + blurRadius: shadow.blurRadius, + spreadRadius: shadow.spreadRadius, + ), +]; + +RemixBoxShadow _shadow( + Color color, { + RemixBoxShadowKind kind = RemixBoxShadowKind.outer, + Offset offset = Offset.zero, + double blur = 0, + double spread = 0, +}) => RemixBoxShadow( + kind: kind, + color: color, + offset: offset, + blurRadius: blur, + spreadRadius: spread, +); // ============================================================================ // RESOLVER (merged from resolver.dart) @@ -70,6 +414,7 @@ class FortalThemeColors { final Color colorPanelSolid; final Color colorPanelTranslucent; final Color colorOverlay; + final Color shadowStroke; // Focus final Color focus8; @@ -85,6 +430,7 @@ class FortalThemeColors { required this.colorPanelSolid, required this.colorPanelTranslucent, required this.colorOverlay, + required this.shadowStroke, required this.focus8, required this.focusA8, }); @@ -92,6 +438,12 @@ class FortalThemeColors { // Map by enum .name to generated RadixColorTheme instances (light/dark contained). const Map _accentThemesByName = { + 'gray': gray, + 'mauve': mauve, + 'slate': slate, + 'sage': sage, + 'olive': olive, + 'sand': sand, 'amber': amber, 'blue': blue, 'bronze': bronze, @@ -117,13 +469,6 @@ const Map _accentThemesByName = { 'tomato': tomato, 'violet': violet, 'yellow': yellow, - // neutrals also exist as accent enums for convenience - 'gray': gray, - 'mauve': mauve, - 'slate': slate, - 'sage': sage, - 'olive': olive, - 'sand': sand, }; const Map _grayThemesByName = { @@ -138,10 +483,14 @@ const Map _grayThemesByName = { /// Resolves all computed tokens for a theme configuration. FortalThemeColors resolveFortalTokens(FortalThemeConfig theme) { // Pick light/dark RadixColor for accent and neutral using enum .name keys - final String accentName = theme.accent.name; - final String grayName = theme.gray.name; - final RadixColorTheme accentTheme = _accentThemesByName[accentName]!; + final accentColor = theme.accent ?? FortalAccentColor.indigo; + final grayColor = theme.gray ?? FortalGrayColor.slate; + final String accentName = accentColor.name; + final String grayName = grayColor.name; final RadixColorTheme grayTheme = _grayThemesByName[grayName]!; + final RadixColorTheme accentTheme = accentColor == .gray + ? grayTheme + : _accentThemesByName[accentName]!; final RadixColor accentRC = theme.isDark ? accentTheme.dark : accentTheme.light; @@ -156,11 +505,21 @@ FortalThemeColors resolveFortalTokens(FortalThemeConfig theme) { final ColorSwatch whiteA = whiteAlpha; // Backgrounds/panels/overlay - final Color colorBackground = computeColorBackground(gray); - final Color colorPanelSolid = computeColorPanelSolid(gray); - final Color colorPanelTranslucent = computeColorPanelTranslucent(gray); - final Color colorSurface = grayRC.surface; + final Color colorBackground = computeColorBackground( + gray, + isDark: theme.isDark, + ); + final Color colorPanelSolid = computeColorPanelSolid( + gray, + isDark: theme.isDark, + ); + final Color colorPanelTranslucent = computeColorPanelTranslucent( + gray, + isDark: theme.isDark, + ); + final Color colorSurface = computeColorSurface(isDark: theme.isDark); final Color colorOverlay = computeColorOverlay(isDark: theme.isDark); + final Color shadowStroke = computeShadowStroke(gray, isDark: theme.isDark); // Focus final Color focus8 = computeFocus8(accent); @@ -176,6 +535,7 @@ FortalThemeColors resolveFortalTokens(FortalThemeConfig theme) { colorPanelSolid: colorPanelSolid, colorPanelTranslucent: colorPanelTranslucent, colorOverlay: colorOverlay, + shadowStroke: shadowStroke, focus8: focus8, focusA8: focusA8, ); diff --git a/packages/remix/lib/src/fortal/fortal.dart b/packages/remix/lib/src/fortal/fortal.dart index a42ccb92..a4d35e04 100644 --- a/packages/remix/lib/src/fortal/fortal.dart +++ b/packages/remix/lib/src/fortal/fortal.dart @@ -4,6 +4,7 @@ export '../radix/colors/colors.dart'; // Computed helpers and shared types export 'computed.dart'; +export 'base_button_recipe.dart'; // Theme tokens and scope builder (consolidated) export 'fortal_theme.dart'; diff --git a/packages/remix/lib/src/fortal/fortal_theme.dart b/packages/remix/lib/src/fortal/fortal_theme.dart index b6696f10..f64bd9d1 100644 --- a/packages/remix/lib/src/fortal/fortal_theme.dart +++ b/packages/remix/lib/src/fortal/fortal_theme.dart @@ -1,8 +1,12 @@ // Fortal theme: tokens + MixScope builder in one place. +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:mix/mix.dart'; +import '../radix/colors/colors.dart' as radix; +import '../rendering/remix_box_effects.dart'; import 'computed.dart'; // OKLab mixing lives in computed.dart; no direct dependency here. @@ -27,13 +31,13 @@ class FortalTokens { // BACKGROUND AND SURFACE COLORS // ============================================================================ - /// Page background color (gray step 1). + /// Page background color selected for the active brightness. static const colorBackground = ColorToken('fortal.color.background'); /// Neutral surface color for input fields and controls. static const colorSurface = ColorToken('fortal.color.surface'); - /// Solid panel background (gray step 2). + /// Solid panel background selected for the active brightness. static const colorPanelSolid = ColorToken('fortal.color.panel.solid'); /// Translucent panel background with alpha transparency. @@ -41,6 +45,12 @@ class FortalTokens { 'fortal.color.panel.translucent', ); + /// Panel background selected by [FortalPanelBackground]. + static const colorPanel = ColorToken('fortal.color.panel'); + + /// Backdrop blur applied to translucent floating panels. + static const panelBlur = DoubleToken('fortal.panel.blur'); + /// Dark overlay for modals and dialogs. static const colorOverlay = ColorToken('fortal.color.overlay'); @@ -57,6 +67,11 @@ class FortalTokens { /// Track/rail background color for sliders and progress bars. static const accentTrack = ColorToken('fortal.accent.track'); + /// Mode-aware overlay used by high-contrast slider ranges. + static const sliderHighContrastOverlay = ColorToken( + 'fortal.slider.high-contrast-overlay', + ); + /// High contrast foreground for solid accent backgrounds. static const accentContrast = ColorToken('fortal.accent.contrast'); @@ -70,6 +85,15 @@ class FortalTokens { /// Translucent focus ring color with alpha transparency. static const focusA8 = ColorToken('fortal.focus.a8'); + /// Mode-aware red roles used by documented validation extensions. + static const error3 = ColorToken('fortal.error.3'); + static const error7 = ColorToken('fortal.error.7'); + static const error8 = ColorToken('fortal.error.8'); + static const error9 = ColorToken('fortal.error.9'); + static const error11 = ColorToken('fortal.error.11'); + static const error12 = ColorToken('fortal.error.12'); + static const errorA7 = ColorToken('fortal.error.a7'); + // ============================================================================ // ACCENT COLOR SCALE (12 STEPS) // ============================================================================ @@ -235,16 +259,43 @@ class FortalTokens { /// Gray alpha steps are declared above (grayA1..grayA12). /// Black alpha steps used in layered shadows. + static const blackA1 = ColorToken('fortal.black.a1'); + static const blackA2 = ColorToken('fortal.black.a2'); static const blackA3 = ColorToken('fortal.black.a3'); static const blackA4 = ColorToken('fortal.black.a4'); static const blackA5 = ColorToken('fortal.black.a5'); static const blackA6 = ColorToken('fortal.black.a6'); static const blackA7 = ColorToken('fortal.black.a7'); + static const blackA8 = ColorToken('fortal.black.a8'); + static const blackA9 = ColorToken('fortal.black.a9'); + static const blackA10 = ColorToken('fortal.black.a10'); static const blackA11 = ColorToken('fortal.black.a11'); - - /// OKLab-mixed shadow stroke: color-mix(in oklab, gray-a6, gray-6 25%). + static const blackA12 = ColorToken('fortal.black.a12'); + + /// White alpha steps used by layered classic-control recipes. + static const whiteA1 = ColorToken('fortal.white.a1'); + static const whiteA2 = ColorToken('fortal.white.a2'); + static const whiteA3 = ColorToken('fortal.white.a3'); + static const whiteA4 = ColorToken('fortal.white.a4'); + static const whiteA5 = ColorToken('fortal.white.a5'); + static const whiteA6 = ColorToken('fortal.white.a6'); + static const whiteA7 = ColorToken('fortal.white.a7'); + static const whiteA8 = ColorToken('fortal.white.a8'); + static const whiteA9 = ColorToken('fortal.white.a9'); + static const whiteA10 = ColorToken('fortal.white.a10'); + static const whiteA11 = ColorToken('fortal.white.a11'); + static const whiteA12 = ColorToken('fortal.white.a12'); + + /// Mode-aware mixed shadow stroke. static const shadowStroke = ColorToken('fortal.shadow.stroke'); + /// Premultiplied OKLab mixes used by Radix neutral one-pixel strokes. + static const grayStroke3 = ColorToken('fortal.gray.stroke.3'); + static const grayStroke4 = ColorToken('fortal.gray.stroke.4'); + static const grayStroke5 = ColorToken('fortal.gray.stroke.5'); + static const grayStroke6 = ColorToken('fortal.gray.stroke.6'); + static const grayStroke7 = ColorToken('fortal.gray.stroke.7'); + // ============================================================================ // SPACING SCALE (9 STEPS) // ============================================================================ @@ -276,36 +327,197 @@ class FortalTokens { /// for card padding and section margins. static const space4 = SpaceToken('fortal.space.4'); - /// Space step 5 - 20px. + /// Space step 5 - 24px. /// /// Medium spacing for generous padding and /// comfortable separation between sections. static const space5 = SpaceToken('fortal.space.5'); - /// Space step 6 - 24px. + /// Space step 6 - 32px. /// /// Large spacing for significant visual separation /// and generous component padding. static const space6 = SpaceToken('fortal.space.6'); - /// Space step 7 - 28px. + /// Space step 7 - 40px. /// /// Extra large spacing for major layout sections /// and prominent visual separation. static const space7 = SpaceToken('fortal.space.7'); - /// Space step 8 - 32px. + /// Space step 8 - 48px. /// /// Very large spacing for significant page sections /// and major layout boundaries. static const space8 = SpaceToken('fortal.space.8'); - /// Space step 9 - 36px. + /// Space step 9 - 64px. /// /// Maximum spacing for major page sections /// and substantial layout separation. static const space9 = SpaceToken('fortal.space.9'); + /// Spinner size 3 - 20px at 100% scaling. + /// + /// Radix defines this as 1.25 times space 4, so it needs its own resolved + /// token rather than arithmetic on an unresolved token reference. + static const spinnerSize3 = DoubleToken('fortal.spinner.size.3'); + + /// Compact gap shared by size-1 toggle extensions (2px at 100% scaling). + static const toggleGap1 = DoubleToken('fortal.toggle.gap.1'); + + /// Comfortable gap shared by size-3 toggle extensions (6px at 100%). + static const toggleGap3 = DoubleToken('fortal.toggle.gap.3'); + + /// Avatar sizes expressed as scaled pixels rather than spacing steps. + static const avatarSize6 = DoubleToken('fortal.avatar.size.6'); + static const avatarSize7 = DoubleToken('fortal.avatar.size.7'); + static const avatarSize8 = DoubleToken('fortal.avatar.size.8'); + static const avatarSize9 = DoubleToken('fortal.avatar.size.9'); + + /// Badge measurements that are fractional spacing expressions upstream. + static const badgePaddingX1 = DoubleToken('fortal.badge.padding-x.1'); + static const badgePaddingY1 = DoubleToken('fortal.badge.padding-y.1'); + static const badgePaddingX3 = DoubleToken('fortal.badge.padding-x.3'); + + /// Checkbox dimensions expressed as scaled pixels by Radix Themes. + static const checkboxSize1 = DoubleToken('fortal.checkbox.size.1'); + static const checkboxSize3 = DoubleToken('fortal.checkbox.size.3'); + static const checkboxIndicatorSize1 = DoubleToken( + 'fortal.checkbox.indicator-size.1', + ); + static const checkboxIndicatorSize2 = DoubleToken( + 'fortal.checkbox.indicator-size.2', + ); + static const checkboxIndicatorSize3 = DoubleToken( + 'fortal.checkbox.indicator-size.3', + ); + + /// Radio indicators are 40% of their control size in Radix Themes. + /// + /// These values need dedicated tokens because arithmetic on an unresolved + /// token reference would destroy its identity before Mix can resolve it. + static const radioIndicatorSize1 = DoubleToken( + 'fortal.radio.indicator-size.1', + ); + static const radioIndicatorSize2 = DoubleToken( + 'fortal.radio.indicator-size.2', + ); + static const radioIndicatorSize3 = DoubleToken( + 'fortal.radio.indicator-size.3', + ); + + /// Checkbox radii derived from fractional radius-step expressions. + static const checkboxRadius1 = RadiusToken('fortal.checkbox.radius.1'); + static const checkboxRadius3 = RadiusToken('fortal.checkbox.radius.3'); + + /// Switch geometry that cannot be derived from unresolved token references. + static const switchHeight2 = DoubleToken('fortal.switch.height.2'); + static const switchWidth1 = DoubleToken('fortal.switch.width.1'); + static const switchWidth2 = DoubleToken('fortal.switch.width.2'); + static const switchWidth3 = DoubleToken('fortal.switch.width.3'); + static const switchThumbSize1 = DoubleToken('fortal.switch.thumb-size.1'); + static const switchThumbSize2 = DoubleToken('fortal.switch.thumb-size.2'); + static const switchThumbSize3 = DoubleToken('fortal.switch.thumb-size.3'); + + /// Progress geometry derived from scaled fractional upstream expressions. + static const progressHeight2 = DoubleToken('fortal.progress.height.2'); + static const progressRadius1 = RadiusToken('fortal.progress.radius.1'); + static const progressRadius2 = RadiusToken('fortal.progress.radius.2'); + static const progressRadius3 = RadiusToken('fortal.progress.radius.3'); + + /// Slider geometry expressed as scaled Radix component dimensions. + static const sliderTrackSize1 = DoubleToken('fortal.slider.track-size.1'); + static const sliderTrackSize2 = DoubleToken('fortal.slider.track-size.2'); + static const sliderTrackSize3 = DoubleToken('fortal.slider.track-size.3'); + static const sliderThumbSize1 = DoubleToken('fortal.slider.thumb-size.1'); + static const sliderThumbSize2 = DoubleToken('fortal.slider.thumb-size.2'); + static const sliderThumbSize3 = DoubleToken('fortal.slider.thumb-size.3'); + static const sliderTrackRadius1 = RadiusToken('fortal.slider.track-radius.1'); + static const sliderTrackRadius2 = RadiusToken('fortal.slider.track-radius.2'); + static const sliderTrackRadius3 = RadiusToken('fortal.slider.track-radius.3'); + + /// TextField content insets after its fixed one-pixel border. + static const textFieldPadding1 = DoubleToken('fortal.text-field.padding.1'); + static const textFieldPadding2 = DoubleToken('fortal.text-field.padding.2'); + static const textFieldPadding3 = DoubleToken('fortal.text-field.padding.3'); + + /// Exact uppercase fallback typography for each Avatar size. + static const avatarFallback1One = TextStyleToken( + 'fortal.avatar.fallback.1.one', + ); + static const avatarFallback1Two = TextStyleToken( + 'fortal.avatar.fallback.1.two', + ); + static const avatarFallback2One = TextStyleToken( + 'fortal.avatar.fallback.2.one', + ); + static const avatarFallback2Two = TextStyleToken( + 'fortal.avatar.fallback.2.two', + ); + static const avatarFallback3One = TextStyleToken( + 'fortal.avatar.fallback.3.one', + ); + static const avatarFallback3Two = TextStyleToken( + 'fortal.avatar.fallback.3.two', + ); + static const avatarFallback4One = TextStyleToken( + 'fortal.avatar.fallback.4.one', + ); + static const avatarFallback4Two = TextStyleToken( + 'fortal.avatar.fallback.4.two', + ); + static const avatarFallback5 = TextStyleToken('fortal.avatar.fallback.5'); + static const avatarFallback6 = TextStyleToken('fortal.avatar.fallback.6'); + static const avatarFallback7 = TextStyleToken('fortal.avatar.fallback.7'); + static const avatarFallback8 = TextStyleToken('fortal.avatar.fallback.8'); + static const avatarFallback9 = TextStyleToken('fortal.avatar.fallback.9'); + + /// Tabs size 1 inner vertical padding - 2px at 100% scaling. + static const tabInnerPaddingY1 = DoubleToken('fortal.tabs.inner-padding-y.1'); + + /// Tabs size 1 active tracking - -0.12px at 100% scaling. + static const tabActiveLetterSpacing1 = DoubleToken( + 'fortal.tabs.active-letter-spacing.1', + ); + + /// Tabs size 2 active tracking - -0.14px at 100% scaling. + static const tabActiveLetterSpacing2 = DoubleToken( + 'fortal.tabs.active-letter-spacing.2', + ); + + /// Select's 1.5 × space-1 measurement (6px at 100% scaling). + static const selectSpace1Half = DoubleToken('fortal.select.space.1-half'); + + /// Select size-1 indicator column width (20px at 100% scaling). + static const selectIndicatorWidth1 = DoubleToken( + 'fortal.select.indicator-width.1', + ); + + /// Select size-1 check size (8px at 100% scaling). + static const selectIndicatorSize1 = DoubleToken( + 'fortal.select.indicator-size.1', + ); + + /// Select size-2/3 check size (10px at 100% scaling). + static const selectIndicatorSize2 = DoubleToken( + 'fortal.select.indicator-size.2', + ); + + /// Negative margins that cancel Select ghost-trigger padding. + static const selectGhostMarginX12 = DoubleToken( + 'fortal.select.ghost-margin-x.1-2', + ); + static const selectGhostMarginY12 = DoubleToken( + 'fortal.select.ghost-margin-y.1-2', + ); + static const selectGhostMarginX3 = DoubleToken( + 'fortal.select.ghost-margin-x.3', + ); + static const selectGhostMarginY3 = DoubleToken( + 'fortal.select.ghost-margin-y.3', + ); + // ============================================================================ // BORDER RADIUS SCALE (6 STEPS + FULL) // ============================================================================ @@ -346,12 +558,39 @@ class FortalTokens { /// Creates a soft, friendly appearance for large interface elements. static const radius6 = RadiusToken('fortal.radius.6'); - /// Full radius (9999px) for circular shapes. - /// - /// Creates perfect circles and pills. Use for avatars, - /// badges, and elements that should appear completely rounded. + /// Theme-level full radius, enabled only by [FortalRadius.full]. static const radiusFull = RadiusToken('fortal.radius.full'); + /// Radius used by control thumbs. + static const radiusThumb = RadiusToken('fortal.radius.thumb'); + + /// Fixed circle radius for shapes that stay circular across theme presets. + static const radiusCircle = RadiusToken('fortal.radius.circle'); + + /// Radius step 1 promoted to a pill when the theme radius is full. + static const radius1OrFull = RadiusToken('fortal.radius.1-or-full'); + + /// Radius step 2 promoted to a pill when the theme radius is full. + static const radius2OrFull = RadiusToken('fortal.radius.2-or-full'); + + /// Radius step 3 promoted to a pill when the theme radius is full. + static const radius3OrFull = RadiusToken('fortal.radius.3-or-full'); + + /// Radius step 4 promoted to a pill when the theme radius is full. + static const radius4OrFull = RadiusToken('fortal.radius.4-or-full'); + + /// Radius step 5 promoted to a pill when the theme radius is full. + static const radius5OrFull = RadiusToken('fortal.radius.5-or-full'); + + /// Radius step 6 promoted to a pill when the theme radius is full. + static const radius6OrFull = RadiusToken('fortal.radius.6-or-full'); + + /// Radius step 1 promoted to the control-thumb radius when larger. + static const radius1OrThumb = RadiusToken('fortal.radius.1-or-thumb'); + + /// Radius step 2 promoted to the control-thumb radius when larger. + static const radius2OrThumb = RadiusToken('fortal.radius.2-or-thumb'); + // ============================================================================ // ELEVATION SHADOWS (6 LEVELS) // ============================================================================ @@ -362,6 +601,19 @@ class FortalTokens { /// Good for cards and buttons in their resting state. static const shadow1 = BoxShadowToken('fortal.shadow.1'); + /// Exact layered shadow level 1, including inset layers. + /// + /// This additive token powers Fortal's Radix-compatible rendering while + /// [shadow1] retains the original Remix public token type. + static const shadow1Layers = RemixBoxShadowListToken( + 'fortal.shadow.1.layers', + ); + + /// Half-opacity shadow-1 layers used by a disabled classic slider track. + static const sliderClassicDisabledTrackShadows = RemixBoxShadowListToken( + 'fortal.slider.classic.disabled-track-shadows', + ); + /// Shadow level 2 - Low elevation. /// /// Light shadow for gentle elevation and hover states. @@ -392,6 +644,100 @@ class FortalTokens { /// Ensures content appears above all other interface elements. static const shadow6 = BoxShadowToken('fortal.shadow.6'); + /// Card classic outer and inset-pseudo-element shadow lists. + static const cardClassicOuterShadows = RemixBoxShadowListToken( + 'fortal.card.classic.outer-shadows', + ); + static const cardClassicInnerShadows = RemixBoxShadowListToken( + 'fortal.card.classic.inner-shadows', + ); + static const cardClassicHoverOuterShadows = RemixBoxShadowListToken( + 'fortal.card.classic.hover.outer-shadows', + ); + static const cardClassicHoverInnerShadows = RemixBoxShadowListToken( + 'fortal.card.classic.hover.inner-shadows', + ); + static const cardClassicActiveOuterShadows = RemixBoxShadowListToken( + 'fortal.card.classic.active.outer-shadows', + ); + static const cardClassicActiveInnerShadows = RemixBoxShadowListToken( + 'fortal.card.classic.active.inner-shadows', + ); + + /// Mode-aware inset layers for a classic Select trigger. + static const selectTriggerClassicShadows = RemixBoxShadowListToken( + 'fortal.select.trigger.classic.shadows', + ); + + /// Mode-aware open/hover layers for a classic Select trigger. + static const selectTriggerClassicHoverShadows = RemixBoxShadowListToken( + 'fortal.select.trigger.classic.hover-shadows', + ); + + /// Mode-aware disabled layers shared by classic button-shaped controls. + static const baseButtonClassicDisabledShadows = RemixBoxShadowListToken( + 'fortal.base-button.classic.disabled.shadows', + ); + + /// Mode-aware classic Button/IconButton layers. + static const baseButtonClassicShadows = RemixBoxShadowListToken( + 'fortal.base-button.classic.shadows', + ); + static const baseButtonClassicHighContrastShadows = RemixBoxShadowListToken( + 'fortal.base-button.classic.high-contrast.shadows', + ); + static const baseButtonClassicActiveShadows = RemixBoxShadowListToken( + 'fortal.base-button.classic.active.shadows', + ); + static const baseButtonClassicActiveHighContrastShadows = + RemixBoxShadowListToken( + 'fortal.base-button.classic.active.high-contrast.shadows', + ); + static const baseButtonClassicAfterInset = DoubleToken( + 'fortal.base-button.classic.after-inset', + ); + static const baseButtonGhostPaddingY3 = DoubleToken( + 'fortal.base-button.ghost.padding-y.3', + ); + static const baseButtonGhostMarginX12 = DoubleToken( + 'fortal.base-button.ghost.margin-x.1-2', + ); + static const baseButtonGhostMarginY12 = DoubleToken( + 'fortal.base-button.ghost.margin-y.1-2', + ); + static const baseButtonGhostMarginX3 = DoubleToken( + 'fortal.base-button.ghost.margin-x.3', + ); + static const baseButtonGhostMarginY3 = DoubleToken( + 'fortal.base-button.ghost.margin-y.3', + ); + static const baseButtonGhostMarginX4 = DoubleToken( + 'fortal.base-button.ghost.margin-x.4', + ); + static const baseButtonGhostMarginY4 = DoubleToken( + 'fortal.base-button.ghost.margin-y.4', + ); + static const iconButtonGhostPadding2 = DoubleToken( + 'fortal.icon-button.ghost.padding.2', + ); + static const iconButtonGhostMargin1 = DoubleToken( + 'fortal.icon-button.ghost.margin.1', + ); + static const iconButtonGhostMargin2 = DoubleToken( + 'fortal.icon-button.ghost.margin.2', + ); + static const iconButtonGhostMargin3 = DoubleToken( + 'fortal.icon-button.ghost.margin.3', + ); + static const iconButtonGhostMargin4 = DoubleToken( + 'fortal.icon-button.ghost.margin.4', + ); + static const cardGhostMargin1 = DoubleToken('fortal.card.ghost.margin.1'); + static const cardGhostMargin2 = DoubleToken('fortal.card.ghost.margin.2'); + static const cardGhostMargin3 = DoubleToken('fortal.card.ghost.margin.3'); + static const cardGhostMargin4 = DoubleToken('fortal.card.ghost.margin.4'); + static const cardGhostMargin5 = DoubleToken('fortal.card.ghost.margin.5'); + // ============================================================================ // BORDER AND STROKE WIDTHS // ============================================================================ @@ -446,6 +792,12 @@ class FortalTokens { /// readability for extended reading on all device types. static const text3 = TextStyleToken('fortal.text.3'); + /// Accordion size-2 text (15px with a 20px line box at 100% scaling). + /// + /// Accordion is a Fortal extension, so this intermediate size is kept + /// separate from the upstream Radix typography scale. + static const accordionText2 = TextStyleToken('fortal.accordion.text.2'); + /// Text size 4 - 18px (Prominent body text). /// /// For important content that needs more visual weight than @@ -506,7 +858,7 @@ class FortalTokens { /// more visual weight, like active states and button text. static const fontWeightMedium = FontWeightToken('fortal.font.weight.medium'); - /// Bold font weight (600). + /// Bold font weight (700). /// /// For headings and content that needs strong emphasis. /// Provides clear hierarchy without being too heavy. @@ -530,18 +882,10 @@ class FortalTokens { } /// Builds the token map for a Fortal scope. Used by [FortalScope]. -Map _buildFortalScopeTokens({ - required FortalAccentColor accent, - required FortalGrayColor gray, - required Brightness brightness, -}) { - final theme = FortalThemeConfig( - accent: accent, - gray: gray, - brightness: brightness, - ); - +Map _buildFortalScopeTokens(FortalThemeData theme) { final tokens = resolveFortalTokens(theme); + final scaling = theme.scaling.factor; + final shadows = buildFortalShadows(isDark: theme.isDark, colors: tokens); final colorTokens = { // Role and functional tokens @@ -549,26 +893,31 @@ Map _buildFortalScopeTokens({ FortalTokens.colorSurface: tokens.colorSurface, FortalTokens.colorPanelSolid: tokens.colorPanelSolid, FortalTokens.colorPanelTranslucent: tokens.colorPanelTranslucent, + FortalTokens.colorPanel: theme.panelBackground == .solid + ? tokens.colorPanelSolid + : tokens.colorPanelTranslucent, FortalTokens.colorOverlay: tokens.colorOverlay, - FortalTokens.accentSurface: tokens.accent.surface, - FortalTokens.accentIndicator: tokens.accent.indicator, - FortalTokens.accentTrack: tokens.accent.track, - FortalTokens.accentContrast: tokens.accent.contrast, - FortalTokens.focus8: tokens.focus8, - FortalTokens.focusA8: tokens.focusA8, - // Accent steps (explicit for clarity) - FortalTokens.accent1: tokens.accent.scale.step(1), - FortalTokens.accent2: tokens.accent.scale.step(2), - FortalTokens.accent3: tokens.accent.scale.step(3), - FortalTokens.accent4: tokens.accent.scale.step(4), - FortalTokens.accent5: tokens.accent.scale.step(5), - FortalTokens.accent6: tokens.accent.scale.step(6), - FortalTokens.accent7: tokens.accent.scale.step(7), - FortalTokens.accent8: tokens.accent.scale.step(8), - FortalTokens.accent9: tokens.accent.scale.step(9), - FortalTokens.accent10: tokens.accent.scale.step(10), - FortalTokens.accent11: tokens.accent.scale.step(11), - FortalTokens.accent12: tokens.accent.scale.step(12), + FortalTokens.sliderHighContrastOverlay: theme.isDark + ? Colors.transparent + : tokens.blackAlpha[8]!, + FortalTokens.error3: (theme.isDark ? radix.red.dark : radix.red.light).scale + .step(3), + FortalTokens.error7: (theme.isDark ? radix.red.dark : radix.red.light).scale + .step(7), + FortalTokens.error8: (theme.isDark ? radix.red.dark : radix.red.light).scale + .step(8), + FortalTokens.error9: (theme.isDark ? radix.red.dark : radix.red.light).scale + .step(9), + FortalTokens.error11: (theme.isDark ? radix.red.dark : radix.red.light) + .scale + .step(11), + FortalTokens.error12: (theme.isDark ? radix.red.dark : radix.red.light) + .scale + .step(12), + FortalTokens.errorA7: (theme.isDark ? radix.red.dark : radix.red.light) + .scale + .alphaStep(7), + ..._accentColorTokens(tokens), // Gray steps FortalTokens.gray1: tokens.gray.scale.step(1), FortalTokens.gray2: tokens.gray.scale.step(2), @@ -587,20 +936,6 @@ Map _buildFortalScopeTokens({ FortalTokens.grayIndicator: tokens.gray.indicator, FortalTokens.grayTrack: tokens.gray.track, FortalTokens.grayContrast: tokens.gray.contrast, - // Accent alpha a1..a12 - FortalTokens.accentA1: tokens.accent.scale.alphaStep(1), - FortalTokens.accentA2: tokens.accent.scale.alphaStep(2), - FortalTokens.accentA3: tokens.accent.scale.alphaStep(3), - FortalTokens.accentA4: tokens.accent.scale.alphaStep(4), - FortalTokens.accentA5: tokens.accent.scale.alphaStep(5), - FortalTokens.accentA6: tokens.accent.scale.alphaStep(6), - FortalTokens.accentA7: tokens.accent.scale.alphaStep(7), - FortalTokens.accentA8: tokens.accent.scale.alphaStep(8), - FortalTokens.accentA9: tokens.accent.scale.alphaStep(9), - FortalTokens.accentA10: tokens.accent.scale.alphaStep(10), - FortalTokens.accentA11: tokens.accent.scale.alphaStep(11), - FortalTokens.accentA12: tokens.accent.scale.alphaStep(12), - // Gray alpha a1..a12 FortalTokens.grayA1: tokens.gray.scale.alphaStep(1), FortalTokens.grayA2: tokens.gray.scale.alphaStep(2), @@ -615,247 +950,335 @@ Map _buildFortalScopeTokens({ FortalTokens.grayA11: tokens.gray.scale.alphaStep(11), FortalTokens.grayA12: tokens.gray.scale.alphaStep(12), // Neutral helpers derived from primitives + FortalTokens.blackA1: tokens.blackAlpha[1]!, + FortalTokens.blackA2: tokens.blackAlpha[2]!, FortalTokens.blackA3: tokens.blackAlpha[3]!, FortalTokens.blackA4: tokens.blackAlpha[4]!, FortalTokens.blackA5: tokens.blackAlpha[5]!, FortalTokens.blackA6: tokens.blackAlpha[6]!, FortalTokens.blackA7: tokens.blackAlpha[7]!, + FortalTokens.blackA8: tokens.blackAlpha[8]!, + FortalTokens.blackA9: tokens.blackAlpha[9]!, + FortalTokens.blackA10: tokens.blackAlpha[10]!, FortalTokens.blackA11: tokens.blackAlpha[11]!, - // Shadow stroke helper from black alpha primitives - FortalTokens.shadowStroke: tokens.blackAlpha[6]!, + FortalTokens.blackA12: tokens.blackAlpha[12]!, + FortalTokens.whiteA1: tokens.whiteAlpha[1]!, + FortalTokens.whiteA2: tokens.whiteAlpha[2]!, + FortalTokens.whiteA3: tokens.whiteAlpha[3]!, + FortalTokens.whiteA4: tokens.whiteAlpha[4]!, + FortalTokens.whiteA5: tokens.whiteAlpha[5]!, + FortalTokens.whiteA6: tokens.whiteAlpha[6]!, + FortalTokens.whiteA7: tokens.whiteAlpha[7]!, + FortalTokens.whiteA8: tokens.whiteAlpha[8]!, + FortalTokens.whiteA9: tokens.whiteAlpha[9]!, + FortalTokens.whiteA10: tokens.whiteAlpha[10]!, + FortalTokens.whiteA11: tokens.whiteAlpha[11]!, + FortalTokens.whiteA12: tokens.whiteAlpha[12]!, + FortalTokens.shadowStroke: tokens.shadowStroke, + FortalTokens.grayStroke3: mixOklabPremultiplied( + tokens.gray.scale.alphaStep(3), + tokens.gray.scale.step(3), + 0.25, + ), + FortalTokens.grayStroke4: mixOklabPremultiplied( + tokens.gray.scale.alphaStep(4), + tokens.gray.scale.step(4), + 0.25, + ), + FortalTokens.grayStroke5: mixOklabPremultiplied( + tokens.gray.scale.alphaStep(5), + tokens.gray.scale.step(5), + 0.25, + ), + FortalTokens.grayStroke6: mixOklabPremultiplied( + tokens.gray.scale.alphaStep(6), + tokens.gray.scale.step(6), + 0.25, + ), + FortalTokens.grayStroke7: mixOklabPremultiplied( + tokens.gray.scale.alphaStep(7), + tokens.gray.scale.step(7), + 0.25, + ), }; // Build base tokens map final allTokens = { ...colorTokens, // Defaults (may be overridden by JSON tokens below) - FortalTokens.space1: 4.0, - FortalTokens.space2: 8.0, - FortalTokens.space3: 12.0, - FortalTokens.space4: 16.0, - FortalTokens.space5: 24.0, - FortalTokens.space6: 32.0, - FortalTokens.space7: 40.0, - FortalTokens.space8: 48.0, - FortalTokens.space9: 64.0, - FortalTokens.radius1: const Radius.circular(3.0), - FortalTokens.radius2: const Radius.circular(4.0), - FortalTokens.radius3: const Radius.circular(6.0), - FortalTokens.radius4: const Radius.circular(8.0), - FortalTokens.radius5: const Radius.circular(12.0), - FortalTokens.radius6: const Radius.circular(16.0), - FortalTokens.radiusFull: const Radius.circular(9999.0), - - // Shadow lists - // Layered shadows approximating Radix Themes CSS tokens - // shadow-1: Radix uses inset layers; Flutter lacks inset. Approximate with subtle stroke + small ambient. - FortalTokens.shadow1: [ - BoxShadow( - color: FortalTokens.grayA6(), - offset: const Offset(0, 0), - blurRadius: 0, - spreadRadius: 1, - ), - BoxShadow( - color: FortalTokens.blackA5(), - offset: const Offset(0, 1), - blurRadius: 2, - spreadRadius: 0, - ), - ], - // shadow-2 - FortalTokens.shadow2: [ - // 0 0 0 1px color-mix(in oklab, gray-a6, gray-6 25%) - BoxShadow( - color: FortalTokens.shadowStroke(), - offset: const Offset(0, 0), - blurRadius: 0, - spreadRadius: 1, - ), - // 0 0 0 0.5px black-a3 -> approximate with small blur instead of half-px spread - BoxShadow( - color: FortalTokens.blackA3(), - offset: const Offset(0, 0), - blurRadius: 0.5, - spreadRadius: 0, - ), - // 0 1px 1px 0 black-a6 - BoxShadow( - color: FortalTokens.blackA6(), - offset: const Offset(0, 1), - blurRadius: 1, - spreadRadius: 0, - ), - // 0 2px 1px -1px black-a6 - BoxShadow( - color: FortalTokens.blackA6(), - offset: const Offset(0, 2), - blurRadius: 1, - spreadRadius: -1, - ), - // 0 1px 3px 0 black-a5 - BoxShadow( - color: FortalTokens.blackA5(), - offset: const Offset(0, 1), - blurRadius: 3, - spreadRadius: 0, - ), - ], - // shadow-3 - FortalTokens.shadow3: [ - BoxShadow( - color: FortalTokens.shadowStroke(), - offset: const Offset(0, 0), - blurRadius: 0, - spreadRadius: 1, - ), - // 0 2px 3px -2px black-a3 - BoxShadow( - color: FortalTokens.blackA3(), - offset: const Offset(0, 2), - blurRadius: 3, - spreadRadius: -2, - ), - // 0 3px 8px -2px black-a6 - BoxShadow( - color: FortalTokens.blackA6(), - offset: const Offset(0, 3), - blurRadius: 8, - spreadRadius: -2, - ), - // 0 4px 12px -4px black-a7 - BoxShadow( - color: FortalTokens.blackA7(), - offset: const Offset(0, 4), - blurRadius: 12, - spreadRadius: -4, - ), - ], - // shadow-4 - FortalTokens.shadow4: [ - BoxShadow( - color: FortalTokens.shadowStroke(), - offset: const Offset(0, 0), - blurRadius: 0, - spreadRadius: 1, - ), - // 0 8px 40px black-a3 - BoxShadow( - color: FortalTokens.blackA3(), - offset: const Offset(0, 8), - blurRadius: 40, - spreadRadius: 0, - ), - // 0 12px 32px -16px black-a5 - BoxShadow( - color: FortalTokens.blackA5(), - offset: const Offset(0, 12), - blurRadius: 32, - spreadRadius: -16, - ), - ], - // shadow-5 - FortalTokens.shadow5: [ - BoxShadow( - color: FortalTokens.shadowStroke(), - offset: const Offset(0, 0), - blurRadius: 0, - spreadRadius: 1, - ), - // 0 12px 60px black-a5 - BoxShadow( - color: FortalTokens.blackA5(), - offset: const Offset(0, 12), - blurRadius: 60, - spreadRadius: 0, - ), - // 0 12px 32px -16px black-a7 - BoxShadow( - color: FortalTokens.blackA7(), - offset: const Offset(0, 12), - blurRadius: 32, - spreadRadius: -16, - ), - ], - // shadow-6 - FortalTokens.shadow6: [ - BoxShadow( - color: FortalTokens.shadowStroke(), - offset: const Offset(0, 0), - blurRadius: 0, - spreadRadius: 1, - ), - // 0 12px 60px black-a4 - BoxShadow( - color: FortalTokens.blackA4(), - offset: const Offset(0, 12), - blurRadius: 60, - spreadRadius: 0, - ), - // 0 16px 64px black-a6 - BoxShadow( - color: FortalTokens.blackA6(), - offset: const Offset(0, 16), - blurRadius: 64, - spreadRadius: 0, - ), - // 0 16px 36px -20px black-a11 - BoxShadow( - color: FortalTokens.blackA11(), - offset: const Offset(0, 16), - blurRadius: 36, - spreadRadius: -20, - ), + FortalTokens.panelBlur: + theme.panelBackground == FortalPanelBackground.translucent ? 64.0 : 0.0, + FortalTokens.space1: 4.0 * scaling, + FortalTokens.space2: 8.0 * scaling, + FortalTokens.space3: 12.0 * scaling, + FortalTokens.space4: 16.0 * scaling, + FortalTokens.space5: 24.0 * scaling, + FortalTokens.space6: 32.0 * scaling, + FortalTokens.space7: 40.0 * scaling, + FortalTokens.space8: 48.0 * scaling, + FortalTokens.space9: 64.0 * scaling, + FortalTokens.spinnerSize3: 20.0 * scaling, + FortalTokens.toggleGap1: 2.0 * scaling, + FortalTokens.toggleGap3: 6.0 * scaling, + FortalTokens.avatarSize6: 80.0 * scaling, + FortalTokens.avatarSize7: 96.0 * scaling, + FortalTokens.avatarSize8: 128.0 * scaling, + FortalTokens.avatarSize9: 160.0 * scaling, + FortalTokens.badgePaddingX1: 6.0 * scaling, + FortalTokens.badgePaddingY1: 2.0 * scaling, + FortalTokens.badgePaddingX3: 10.0 * scaling, + FortalTokens.checkboxSize1: 14.0 * scaling, + FortalTokens.checkboxSize3: 20.0 * scaling, + FortalTokens.checkboxIndicatorSize1: 9.0 * scaling, + FortalTokens.checkboxIndicatorSize2: 10.0 * scaling, + FortalTokens.checkboxIndicatorSize3: 12.0 * scaling, + FortalTokens.radioIndicatorSize1: 5.6 * scaling, + FortalTokens.radioIndicatorSize2: 6.4 * scaling, + FortalTokens.radioIndicatorSize3: 8.0 * scaling, + FortalTokens.checkboxRadius1: _scaledRadiusToken( + theme.radius, + scaling, + 3.0 * 0.875, + ), + FortalTokens.checkboxRadius3: _scaledRadiusToken( + theme.radius, + scaling, + 3.0 * 1.25, + ), + FortalTokens.switchHeight2: 20.0 * scaling, + FortalTokens.switchWidth1: 28.0 * scaling, + FortalTokens.switchWidth2: 35.0 * scaling, + FortalTokens.switchWidth3: 42.0 * scaling, + FortalTokens.switchThumbSize1: 16.0 * scaling - 2.0, + FortalTokens.switchThumbSize2: 20.0 * scaling - 2.0, + FortalTokens.switchThumbSize3: 24.0 * scaling - 2.0, + FortalTokens.progressHeight2: 6.0 * scaling, + FortalTokens.sliderTrackSize1: 6.0 * scaling, + FortalTokens.sliderTrackSize2: 8.0 * scaling, + FortalTokens.sliderTrackSize3: 10.0 * scaling, + FortalTokens.sliderThumbSize1: 13.0 * scaling, + FortalTokens.sliderThumbSize2: 16.0 * scaling, + FortalTokens.sliderThumbSize3: 19.0 * scaling, + FortalTokens.textFieldPadding1: 6.0 * scaling - 1.0, + FortalTokens.textFieldPadding2: 8.0 * scaling - 1.0, + FortalTokens.textFieldPadding3: 12.0 * scaling - 1.0, + FortalTokens.tabInnerPaddingY1: 2.0 * scaling, + FortalTokens.tabActiveLetterSpacing1: -0.12 * scaling, + FortalTokens.tabActiveLetterSpacing2: -0.14 * scaling, + FortalTokens.selectSpace1Half: 6.0 * scaling, + FortalTokens.selectIndicatorWidth1: 20.0 * scaling, + FortalTokens.selectIndicatorSize1: 8.0 * scaling, + FortalTokens.selectIndicatorSize2: 10.0 * scaling, + FortalTokens.selectGhostMarginX12: -8.0 * scaling, + FortalTokens.selectGhostMarginY12: -4.0 * scaling, + FortalTokens.selectGhostMarginX3: -12.0 * scaling, + FortalTokens.selectGhostMarginY3: -6.0 * scaling, + ..._radiusTokensFor(theme.radius, scaling), + + // Exact layered Radix shadow tokens, resolved for the active color scales. + ...shadows, + FortalTokens.sliderClassicDisabledTrackShadows: _scaleShadowOpacity( + shadows[FortalTokens.shadow1Layers]! as List, + 0.5, + ), + FortalTokens.cardClassicOuterShadows: _cardClassicShadows( + tokens, + isDark: theme.isDark, + layer: .outer, + state: .idle, + ), + FortalTokens.cardClassicInnerShadows: _cardClassicShadows( + tokens, + isDark: theme.isDark, + layer: .inner, + state: .idle, + ), + FortalTokens.cardClassicHoverOuterShadows: _cardClassicShadows( + tokens, + isDark: theme.isDark, + layer: .outer, + state: .hovered, + ), + FortalTokens.cardClassicHoverInnerShadows: _cardClassicShadows( + tokens, + isDark: theme.isDark, + layer: .inner, + state: .hovered, + ), + FortalTokens.cardClassicActiveOuterShadows: _cardClassicShadows( + tokens, + isDark: theme.isDark, + layer: .outer, + state: .active, + ), + FortalTokens.cardClassicActiveInnerShadows: _cardClassicShadows( + tokens, + isDark: theme.isDark, + layer: .inner, + state: .active, + ), + FortalTokens.selectTriggerClassicShadows: _selectClassicShadows( + tokens, + isDark: theme.isDark, + ), + FortalTokens.selectTriggerClassicHoverShadows: [ + _insetShadow(tokens.gray.scale.alphaStep(3), spread: 1), + ..._selectClassicShadows(tokens, isDark: theme.isDark), ], + FortalTokens.baseButtonClassicDisabledShadows: + _baseButtonClassicDisabledShadows(tokens, isDark: theme.isDark), + FortalTokens.baseButtonClassicShadows: _baseButtonClassicShadows( + tokens, + isDark: theme.isDark, + highContrast: false, + ), + FortalTokens.baseButtonClassicHighContrastShadows: + _baseButtonClassicShadows( + tokens, + isDark: theme.isDark, + highContrast: true, + ), + FortalTokens.baseButtonClassicActiveShadows: + _baseButtonClassicActiveShadows(tokens, highContrast: false), + FortalTokens.baseButtonClassicActiveHighContrastShadows: + _baseButtonClassicActiveShadows(tokens, highContrast: true), + FortalTokens.baseButtonClassicAfterInset: theme.isDark ? 1.0 : 2.0, + FortalTokens.baseButtonGhostPaddingY3: 6.0 * scaling, + FortalTokens.baseButtonGhostMarginX12: -8.0 * scaling, + FortalTokens.baseButtonGhostMarginY12: -4.0 * scaling, + FortalTokens.baseButtonGhostMarginX3: -12.0 * scaling, + FortalTokens.baseButtonGhostMarginY3: -6.0 * scaling, + FortalTokens.baseButtonGhostMarginX4: -16.0 * scaling, + FortalTokens.baseButtonGhostMarginY4: -8.0 * scaling, + FortalTokens.iconButtonGhostPadding2: 6.0 * scaling, + FortalTokens.iconButtonGhostMargin1: -4.0 * scaling, + FortalTokens.iconButtonGhostMargin2: -6.0 * scaling, + FortalTokens.iconButtonGhostMargin3: -8.0 * scaling, + FortalTokens.iconButtonGhostMargin4: -12.0 * scaling, + FortalTokens.cardGhostMargin1: -12.0 * scaling, + FortalTokens.cardGhostMargin2: -16.0 * scaling, + FortalTokens.cardGhostMargin3: -24.0 * scaling, + FortalTokens.cardGhostMargin4: -32.0 * scaling, + FortalTokens.cardGhostMargin5: -48.0 * scaling, FortalTokens.borderWidth1: 1.0, FortalTokens.borderWidth2: 2.0, FortalTokens.focusRingWidth: 2.0, FortalTokens.focusRingOffset: 2.0, FortalTokens.text1: TextStyle( - fontSize: 12.0, - letterSpacing: 0.0025 * 12.0, + fontSize: 12.0 * scaling, + letterSpacing: 0.0025 * 12.0 * scaling, height: 16.0 / 12.0, ), FortalTokens.text2: TextStyle( - fontSize: 14.0, + fontSize: 14.0 * scaling, letterSpacing: 0.0, height: 20.0 / 14.0, ), FortalTokens.text3: TextStyle( - fontSize: 16.0, + fontSize: 16.0 * scaling, letterSpacing: 0.0, height: 24.0 / 16.0, ), + FortalTokens.accordionText2: TextStyle( + fontSize: 15.0 * scaling, + letterSpacing: 0.0, + height: 20.0 / 15.0, + ), FortalTokens.text4: TextStyle( - fontSize: 18.0, - letterSpacing: -0.0025 * 18.0, + fontSize: 18.0 * scaling, + letterSpacing: -0.0025 * 18.0 * scaling, height: 26.0 / 18.0, ), FortalTokens.text5: TextStyle( - fontSize: 20.0, - letterSpacing: -0.005 * 20.0, + fontSize: 20.0 * scaling, + letterSpacing: -0.005 * 20.0 * scaling, height: 28.0 / 20.0, ), FortalTokens.text6: TextStyle( - fontSize: 24.0, - letterSpacing: -0.00625 * 24.0, + fontSize: 24.0 * scaling, + letterSpacing: -0.00625 * 24.0 * scaling, height: 30.0 / 24.0, ), FortalTokens.text7: TextStyle( - fontSize: 28.0, - letterSpacing: -0.0075 * 28.0, + fontSize: 28.0 * scaling, + letterSpacing: -0.0075 * 28.0 * scaling, height: 36.0 / 28.0, ), FortalTokens.text8: TextStyle( - fontSize: 35.0, - letterSpacing: -0.01 * 35.0, + fontSize: 35.0 * scaling, + letterSpacing: -0.01 * 35.0 * scaling, height: 40.0 / 35.0, ), FortalTokens.text9: TextStyle( - fontSize: 60.0, - letterSpacing: -0.025 * 60.0, + fontSize: 60.0 * scaling, + letterSpacing: -0.025 * 60.0 * scaling, height: 1.0, ), + FortalTokens.avatarFallback1One: _avatarFallbackText( + fontSize: 14, + letterSpacing: 0.0025 * 12, + scaling: scaling, + ), + FortalTokens.avatarFallback1Two: _avatarFallbackText( + fontSize: 12, + letterSpacing: 0.0025 * 12, + scaling: scaling, + ), + FortalTokens.avatarFallback2One: _avatarFallbackText( + fontSize: 16, + letterSpacing: 0, + scaling: scaling, + ), + FortalTokens.avatarFallback2Two: _avatarFallbackText( + fontSize: 14, + letterSpacing: 0, + scaling: scaling, + ), + FortalTokens.avatarFallback3One: _avatarFallbackText( + fontSize: 18, + letterSpacing: 0, + scaling: scaling, + ), + FortalTokens.avatarFallback3Two: _avatarFallbackText( + fontSize: 16, + letterSpacing: 0, + scaling: scaling, + ), + FortalTokens.avatarFallback4One: _avatarFallbackText( + fontSize: 20, + letterSpacing: -0.0025 * 18, + scaling: scaling, + ), + FortalTokens.avatarFallback4Two: _avatarFallbackText( + fontSize: 18, + letterSpacing: -0.0025 * 18, + scaling: scaling, + ), + FortalTokens.avatarFallback5: _avatarFallbackText( + fontSize: 24, + letterSpacing: -0.00625 * 24, + scaling: scaling, + ), + FortalTokens.avatarFallback6: _avatarFallbackText( + fontSize: 28, + letterSpacing: -0.0075 * 28, + scaling: scaling, + ), + FortalTokens.avatarFallback7: _avatarFallbackText( + fontSize: 28, + letterSpacing: -0.0075 * 28, + scaling: scaling, + ), + FortalTokens.avatarFallback8: _avatarFallbackText( + fontSize: 35, + letterSpacing: -0.01 * 35, + scaling: scaling, + ), + FortalTokens.avatarFallback9: _avatarFallbackText( + fontSize: 60, + letterSpacing: -0.025 * 60, + scaling: scaling, + ), // Font weights (token values) FortalTokens.fontWeightLight: FontWeight.w300, @@ -872,161 +1295,618 @@ Map _buildFortalScopeTokens({ return allTokens; } +TextStyle _avatarFallbackText({ + required double fontSize, + required double letterSpacing, + required double scaling, +}) => TextStyle( + fontSize: fontSize * scaling, + letterSpacing: letterSpacing * scaling, + height: 1, +); + +enum _CardShadowLayer { outer, inner } + +enum _CardShadowState { idle, hovered, active } + +List _cardClassicShadows( + FortalThemeColors colors, { + required bool isDark, + required _CardShadowLayer layer, + required _CardShadowState state, +}) { + final inner = layer == _CardShadowLayer.inner; + final shapeInset = inner ? 1.0 : 0.0; + final border = switch ((isDark, state)) { + (true, _) => mixOklabPremultiplied( + colors.gray.scale.alphaStep(6), + colors.gray.scale.step(6), + 0.25, + ), + (false, _CardShadowState.hovered) => mixOklabPremultiplied( + colors.gray.scale.alphaStep(4), + colors.gray.scale.step(4), + 0.25, + ), + (false, _) => mixOklabPremultiplied( + colors.gray.scale.alphaStep(3), + colors.gray.scale.step(3), + 0.25, + ), + }; + + RemixBoxShadow shadow( + Color color, { + Offset offset = Offset.zero, + double blur = 0, + required double spread, + }) => RemixBoxShadow( + color: color, + offset: offset, + blurRadius: blur, + spreadRadius: spread, + shapeInset: shapeInset, + ); + + if (state == _CardShadowState.hovered) { + if (isDark) { + return [ + shadow(border, spread: inner ? 1 : 0), + shadow(colors.gray.scale.alphaStep(4), blur: 1, spread: inner ? 1 : 0), + shadow( + colors.gray.scale.alphaStep(4), + blur: 1, + spread: inner ? -1 : -2, + ), + shadow( + colors.gray.scale.alphaStep(3), + blur: 3, + spread: inner ? -2 : -3, + ), + shadow( + colors.gray.scale.alphaStep(3), + blur: 12, + spread: inner ? -2 : -3, + ), + shadow( + colors.gray.scale.alphaStep(7), + blur: 16, + spread: inner ? -8 : -9, + ), + ]; + } + return [ + shadow(border, spread: inner ? 1 : 0), + shadow( + colors.blackAlpha[1]!, + offset: const Offset(0, 1), + blur: 1, + spread: inner ? 1 : 0, + ), + shadow( + colors.gray.scale.alphaStep(3), + offset: const Offset(0, 2), + blur: 1, + spread: inner ? -1 : -2, + ), + shadow( + colors.blackAlpha[1]!, + offset: const Offset(0, 2), + blur: 3, + spread: inner ? -2 : -3, + ), + shadow( + colors.gray.scale.alphaStep(3), + offset: const Offset(0, 3), + blur: 12, + spread: inner ? -4 : -5, + ), + shadow( + colors.blackAlpha[1]!, + offset: const Offset(0, 4), + blur: 16, + spread: inner ? -8 : -9, + ), + ]; + } + + final active = state == _CardShadowState.active; + final subtle = isDark ? colors.blackAlpha[3]! : colors.blackAlpha[1]!; + final middle = isDark + ? colors.blackAlpha[6]! + : colors.gray.scale.alphaStep(active ? 4 : 2); + final bottom = isDark ? colors.blackAlpha[5]! : colors.blackAlpha[1]!; + return [ + shadow(border, spread: inner ? 1 : 0), + shadow(Colors.transparent, spread: inner ? 1 : 0), + shadow(subtle, spread: inner ? 0.5 : 0), + shadow(middle, offset: const Offset(0, 1), blur: 1, spread: inner ? 0 : -1), + shadow( + isDark ? colors.blackAlpha[6]! : colors.blackAlpha[1]!, + offset: const Offset(0, 2), + blur: 1, + spread: inner ? -1 : -2, + ), + shadow(bottom, offset: const Offset(0, 1), blur: 3, spread: inner ? 0 : -1), + ]; +} + +RemixBoxShadow _insetShadow( + Color color, { + Offset offset = Offset.zero, + double blur = 0, + double spread = 0, + double shapeInset = 0, +}) => RemixBoxShadow( + kind: RemixBoxShadowKind.inset, + color: color, + offset: offset, + blurRadius: blur, + spreadRadius: spread, + shapeInset: shapeInset, +); + +List _selectClassicShadows( + FortalThemeColors colors, { + required bool isDark, +}) { + if (isDark) { + return [ + _insetShadow(colors.whiteAlpha[4]!, spread: 1), + _insetShadow(colors.whiteAlpha[4]!, offset: const Offset(0, 1), blur: 1), + _insetShadow(colors.blackAlpha[9]!, offset: const Offset(0, -1), blur: 1), + ]; + } + return [ + _insetShadow(colors.gray.scale.alphaStep(5), spread: 1), + _insetShadow(colors.whiteAlpha[11]!, offset: const Offset(0, 2), blur: 1), + _insetShadow( + colors.gray.scale.alphaStep(4), + offset: const Offset(0, -2), + blur: 1, + ), + ]; +} + +List _baseButtonClassicDisabledShadows( + FortalThemeColors colors, { + required bool isDark, +}) { + if (isDark) { + return [ + _insetShadow(colors.gray.scale.alphaStep(5), spread: 1), + _insetShadow( + colors.gray.scale.alphaStep(2), + offset: const Offset(0, 4), + blur: 2, + spread: -2, + ), + _insetShadow( + colors.gray.scale.alphaStep(5), + offset: const Offset(0, 1), + blur: 1, + ), + _insetShadow(colors.blackAlpha[3]!, offset: const Offset(0, -1), blur: 1), + _insetShadow(colors.gray.scale.alphaStep(2), spread: 1), + ]; + } + return [ + _insetShadow(colors.gray.scale.alphaStep(4), spread: 1), + _insetShadow( + colors.gray.scale.alphaStep(3), + offset: const Offset(0, -2), + blur: 1, + ), + _insetShadow( + colors.whiteAlpha[9]!, + offset: const Offset(0, 4), + blur: 2, + spread: -2, + ), + _insetShadow( + colors.whiteAlpha[9]!, + offset: const Offset(0, 2), + blur: 1, + spread: -1, + ), + ]; +} + +List _baseButtonClassicShadows( + FortalThemeColors colors, { + required bool isDark, + required bool highContrast, +}) { + final accent = highContrast + ? colors.accent.scale.step(12) + : colors.accent.scale.step(9); + if (isDark) { + return [ + _insetShadow( + colors.whiteAlpha[4]!, + offset: const Offset(0, 2), + blur: 3, + spread: -1, + shapeInset: 1, + ), + _insetShadow(colors.whiteAlpha[2]!, spread: 1), + _insetShadow( + colors.whiteAlpha[3]!, + offset: const Offset(0, 4), + blur: 2, + spread: -2, + ), + _insetShadow(colors.whiteAlpha[6]!, offset: const Offset(0, 1), blur: 1), + _insetShadow(colors.blackAlpha[6]!, offset: const Offset(0, -1), blur: 1), + _insetShadow(accent, spread: 1), + ]; + } + return [ + _insetShadow( + colors.whiteAlpha[4]!, + offset: const Offset(0, 2), + blur: 3, + spread: -1, + shapeInset: 2, + ), + _insetShadow(colors.gray.scale.alphaStep(4), spread: 1), + _insetShadow( + colors.gray.scale.alphaStep(3), + offset: const Offset(0, -2), + blur: 1, + ), + _insetShadow(accent, spread: 1), + _insetShadow( + colors.whiteAlpha[9]!, + offset: const Offset(0, 4), + blur: 2, + spread: -2, + ), + _insetShadow( + colors.whiteAlpha[9]!, + offset: const Offset(0, 2), + blur: 1, + spread: -1, + ), + ]; +} + +List _baseButtonClassicActiveShadows( + FortalThemeColors colors, { + required bool highContrast, +}) { + final accent = highContrast + ? colors.accent.scale.step(12) + : colors.accent.scale.step(9); + return [ + _insetShadow( + colors.gray.scale.alphaStep(4), + offset: const Offset(0, 4), + blur: 2, + spread: -2, + ), + _insetShadow( + colors.gray.scale.alphaStep(7), + offset: const Offset(0, 1), + blur: 1, + ), + _insetShadow(colors.gray.scale.alphaStep(5), spread: 1), + _insetShadow(accent, spread: 1), + _insetShadow( + colors.gray.scale.alphaStep(3), + offset: const Offset(0, 3), + blur: 2, + ), + _insetShadow(colors.whiteAlpha[7]!, spread: 1), + _insetShadow(colors.whiteAlpha[5]!, offset: const Offset(0, -2), blur: 1), + ]; +} + +Map _radiusTokensFor(FortalRadius radius, double scaling) { + final factor = _radiusFactor(radius); + final thumb = switch (radius) { + .none || .small => const Radius.circular(0.5), + .medium || .large || .full => const Radius.circular(9999.0), + }; + Radius scaled(double base) => Radius.circular(base * factor * scaling); + Radius larger(Radius first, Radius second) => Radius.elliptical( + first.x > second.x ? first.x : second.x, + first.y > second.y ? first.y : second.y, + ); + final radius1 = scaled(3.0); + final radius2 = scaled(4.0); + final radius3 = scaled(6.0); + final radius4 = scaled(8.0); + final radius5 = scaled(12.0); + final radius6 = scaled(16.0); + final full = radius == .full ? const Radius.circular(9999.0) : Radius.zero; + Radius progressRadius(double height) { + final thumbBase = switch (radius) { + .none || .small => 0.5, + .medium || .large || .full => 9999.0, + }; + return Radius.circular(math.max(factor * height / 3, factor * thumbBase)); + } + + return { + FortalTokens.radius1: radius1, + FortalTokens.radius2: radius2, + FortalTokens.radius3: radius3, + FortalTokens.radius4: radius4, + FortalTokens.radius5: radius5, + FortalTokens.radius6: radius6, + FortalTokens.radiusFull: full, + FortalTokens.radiusThumb: thumb, + FortalTokens.radiusCircle: const Radius.circular(9999.0), + FortalTokens.radius1OrFull: larger(radius1, full), + FortalTokens.radius2OrFull: larger(radius2, full), + FortalTokens.radius3OrFull: larger(radius3, full), + FortalTokens.radius4OrFull: larger(radius4, full), + FortalTokens.radius5OrFull: larger(radius5, full), + FortalTokens.radius6OrFull: larger(radius6, full), + FortalTokens.radius1OrThumb: larger(radius1, thumb), + FortalTokens.radius2OrThumb: larger(radius2, thumb), + FortalTokens.progressRadius1: progressRadius(4.0 * scaling), + FortalTokens.progressRadius2: progressRadius(6.0 * scaling), + FortalTokens.progressRadius3: progressRadius(8.0 * scaling), + FortalTokens.sliderTrackRadius1: progressRadius(6.0 * scaling), + FortalTokens.sliderTrackRadius2: progressRadius(8.0 * scaling), + FortalTokens.sliderTrackRadius3: progressRadius(10.0 * scaling), + }; +} + +List _scaleShadowOpacity( + List shadows, + double factor, +) => [ + for (final shadow in shadows) + RemixBoxShadow( + kind: shadow.kind, + color: shadow.color.withValues(alpha: shadow.color.a * factor), + offset: shadow.offset, + blurRadius: shadow.blurRadius, + spreadRadius: shadow.spreadRadius, + shapeInset: shadow.shapeInset, + ), +]; + +double _radiusFactor(FortalRadius radius) => switch (radius) { + .none => 0.0, + .small => 0.75, + .medium => 1.0, + .large || .full => 1.5, +}; + +Radius _scaledRadiusToken(FortalRadius radius, double scaling, double base) => + Radius.circular(base * scaling * _radiusFactor(radius)); + +Map _accentColorTokens(FortalThemeColors tokens) { + final scale = tokens.accent.scale; + + return { + FortalTokens.accentSurface: tokens.accent.surface, + FortalTokens.accentIndicator: tokens.accent.indicator, + FortalTokens.accentTrack: tokens.accent.track, + FortalTokens.accentContrast: tokens.accent.contrast, + FortalTokens.focus8: tokens.focus8, + FortalTokens.focusA8: tokens.focusA8, + FortalTokens.accent1: scale.step(1), + FortalTokens.accent2: scale.step(2), + FortalTokens.accent3: scale.step(3), + FortalTokens.accent4: scale.step(4), + FortalTokens.accent5: scale.step(5), + FortalTokens.accent6: scale.step(6), + FortalTokens.accent7: scale.step(7), + FortalTokens.accent8: scale.step(8), + FortalTokens.accent9: scale.step(9), + FortalTokens.accent10: scale.step(10), + FortalTokens.accent11: scale.step(11), + FortalTokens.accent12: scale.step(12), + FortalTokens.accentA1: scale.alphaStep(1), + FortalTokens.accentA2: scale.alphaStep(2), + FortalTokens.accentA3: scale.alphaStep(3), + FortalTokens.accentA4: scale.alphaStep(4), + FortalTokens.accentA5: scale.alphaStep(5), + FortalTokens.accentA6: scale.alphaStep(6), + FortalTokens.accentA7: scale.alphaStep(7), + FortalTokens.accentA8: scale.alphaStep(8), + FortalTokens.accentA9: scale.alphaStep(9), + FortalTokens.accentA10: scale.alphaStep(10), + FortalTokens.accentA11: scale.alphaStep(11), + FortalTokens.accentA12: scale.alphaStep(12), + }; +} + /// Widget that provides Fortal design tokens to its subtree via [MixScope]. /// /// Use [FortalScope] at the root of your app (or around any subtree that uses /// Fortal styles) so that [FortalTokens] resolve to actual values. /// -/// **Theme Configuration:** -/// - [accent]: The accent color scale (indigo, blue, red, etc.) -/// - [gray]: The neutral gray scale with different undertones -/// - [brightness]: Light or dark mode -/// -/// **Color Theory:** -/// The Fortal color system builds on Radix color science for perceptual -/// uniformity and accessibility: -/// - Each color scale has 12 steps with semantic meaning -/// - Colors are designed to work together harmoniously -/// - Alpha variants provide translucency without losing saturation -/// - Dark mode uses different algorithms to maintain contrast ratios -/// -/// Example: /// ```dart /// FortalScope( -/// accent: .blue, -/// gray: .slate, -/// brightness: .light, -/// child: MyApp(), -/// ) -/// ``` -/// -/// The scope provides access to all design tokens through Mix styles: -/// ```dart -/// Style( -/// $box.color.ref(FortalTokens.accent9), // Solid accent -/// $text.color.ref(FortalTokens.gray12), // High contrast text -/// $box.padding.ref(FortalTokens.space4), // 16px padding -/// $box.shadow.ref(FortalTokens.shadow2), // Subtle elevation +/// child: WidgetsApp(...), /// ) /// ``` class FortalScope extends StatelessWidget { const FortalScope({ super.key, - this.accent = .indigo, - this.gray = .slate, - this.brightness = .light, + this.accent, + this.gray, + this.brightness, + this.panelBackground, + this.radius, + this.scaling, + this.hasBackground, this.orderOfModifiers, required this.child, }); - final FortalAccentColor accent; - final FortalGrayColor gray; - final Brightness brightness; + final FortalAccentColor? accent; + final FortalGrayColor? gray; + final Brightness? brightness; + final FortalPanelBackground? panelBackground; + final FortalRadius? radius; + final FortalScaling? scaling; + final bool? hasBackground; final List? orderOfModifiers; final Widget child; @override Widget build(BuildContext context) { - final tokens = _buildFortalScopeTokens( + final config = FortalThemeConfig( accent: accent, gray: gray, brightness: brightness, + panelBackground: panelBackground, + radius: radius, + scaling: scaling, + hasBackground: hasBackground, ); - - return MixScope( + final data = _resolveFortalTheme( + config, + parent: FortalTheme.maybeOf(context), + ); + final tokens = _buildFortalScopeTokens(data); + Widget result = MixScope( tokens: tokens, orderOfModifiers: orderOfModifiers, child: child, ); + if (data.hasBackground) { + result = ColoredBox( + color: tokens[FortalTokens.colorBackground]! as Color, + child: result, + ); + } + + return FortalTheme( + data: data, + orderOfModifiers: orderOfModifiers, + child: result, + ); } } -// OKLab mixing implemented in computed.dart; no helper here. - -// (No internal helpers needed; explicit step mapping kept for clarity.) - /// Available accent colors matching Radix Themes names. enum FortalAccentColor { - amber, - blue, + gray, + mauve, + slate, + sage, + olive, + sand, + gold, bronze, brown, + yellow, + amber, + orange, + tomato, + red, + ruby, crimson, - cyan, - gold, - grass, - green, - indigo, + pink, + plum, + purple, + violet, iris, + indigo, + blue, + cyan, + teal, jade, + green, + grass, lime, mint, - orange, - pink, - plum, - purple, - red, - ruby, sky, - teal, - tomato, - violet, - yellow, - // neutrals also allowed as accent for convenience - gray, - mauve, - slate, - sage, - olive, - sand, } /// Available neutral gray families matching Radix Themes names. enum FortalGrayColor { gray, mauve, slate, sage, olive, sand } -/// Immutable configuration object for Fortal theme settings. -/// -/// Provides a convenient way to store and pass around theme configuration, -/// including methods to create variants and apply the theme to widgets. -/// This is useful when you need to switch themes dynamically or pass -/// theme configuration through your app. -/// -/// Example: -/// ```dart -/// const theme = FortalThemeConfig( -/// accent: .green, -/// gray: .sage, -/// brightness: .dark, -/// ); -/// -/// // Create variants -/// final lightTheme = theme.copyWith(brightness: .light); -/// -/// // Apply to widgets -/// theme.createScope(child: MyApp()) -/// ``` +/// Theme-level radius multipliers matching the Radix Themes presets. +enum FortalRadius { none, small, medium, large, full } + +/// Background treatment used by floating panels. +enum FortalPanelBackground { solid, translucent } + +/// Discrete theme scaling values supported by Radix Themes. +enum FortalScaling { + percent90(0.9), + percent95(0.95), + percent100(1.0), + percent105(1.05), + percent110(1.1); + + const FortalScaling(this.factor); + + /// Numeric multiplier represented by this preset. + final double factor; +} + +/// Partial theme values applied by a [FortalScope]. @immutable class FortalThemeConfig { - final FortalAccentColor accent; - final FortalGrayColor gray; - final Brightness brightness; - const FortalThemeConfig({ - this.accent = .indigo, - this.gray = .slate, - this.brightness = .light, + this.accent, + this.gray, + this.brightness, + this.panelBackground, + this.radius, + this.scaling, + this.hasBackground, }); + final FortalAccentColor? accent; + final FortalGrayColor? gray; + final Brightness? brightness; + final FortalPanelBackground? panelBackground; + final FortalRadius? radius; + final FortalScaling? scaling; + final bool? hasBackground; + bool get isDark => brightness == .dark; FortalThemeConfig copyWith({ FortalAccentColor? accent, FortalGrayColor? gray, Brightness? brightness, - }) => .new( + FortalPanelBackground? panelBackground, + FortalRadius? radius, + FortalScaling? scaling, + bool? hasBackground, + }) => FortalThemeConfig( accent: accent ?? this.accent, gray: gray ?? this.gray, brightness: brightness ?? this.brightness, + panelBackground: panelBackground ?? this.panelBackground, + radius: radius ?? this.radius, + scaling: scaling ?? this.scaling, + hasBackground: hasBackground ?? this.hasBackground, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FortalThemeConfig && + accent == other.accent && + gray == other.gray && + brightness == other.brightness && + panelBackground == other.panelBackground && + radius == other.radius && + scaling == other.scaling && + hasBackground == other.hasBackground; + + @override + int get hashCode => Object.hash( + accent, + gray, + brightness, + panelBackground, + radius, + scaling, + hasBackground, ); Widget createScope({List? orderOfModifiers, required Widget child}) => @@ -1034,7 +1914,157 @@ class FortalThemeConfig { accent: accent, gray: gray, brightness: brightness, + panelBackground: panelBackground, + radius: radius, + scaling: scaling, + hasBackground: hasBackground, orderOfModifiers: orderOfModifiers, child: child, ); } + +/// Fully resolved theme values inherited by a Fortal subtree. +@immutable +class FortalThemeData extends FortalThemeConfig { + const FortalThemeData({ + required this.accent, + required this.gray, + required this.brightness, + required this.panelBackground, + required this.radius, + required this.scaling, + required this.hasBackground, + }) : super( + accent: accent, + gray: gray, + brightness: brightness, + panelBackground: panelBackground, + radius: radius, + scaling: scaling, + hasBackground: hasBackground, + ); + + @override + final FortalAccentColor accent; + @override + final FortalGrayColor gray; + @override + final Brightness brightness; + @override + final FortalPanelBackground panelBackground; + @override + final FortalRadius radius; + @override + final FortalScaling scaling; + @override + final bool hasBackground; + + @override + bool get isDark => brightness == .dark; + + @override + FortalThemeData copyWith({ + FortalAccentColor? accent, + FortalGrayColor? gray, + Brightness? brightness, + FortalPanelBackground? panelBackground, + FortalRadius? radius, + FortalScaling? scaling, + bool? hasBackground, + }) => FortalThemeData( + accent: accent ?? this.accent, + gray: gray ?? this.gray, + brightness: brightness ?? this.brightness, + panelBackground: panelBackground ?? this.panelBackground, + radius: radius ?? this.radius, + scaling: scaling ?? this.scaling, + hasBackground: hasBackground ?? this.hasBackground, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FortalThemeData && + accent == other.accent && + gray == other.gray && + brightness == other.brightness && + panelBackground == other.panelBackground && + radius == other.radius && + scaling == other.scaling && + hasBackground == other.hasBackground; + + @override + int get hashCode => Object.hash( + accent, + gray, + brightness, + panelBackground, + radius, + scaling, + hasBackground, + ); +} + +FortalThemeData _resolveFortalTheme( + FortalThemeConfig config, { + FortalThemeData? parent, +}) { + final accent = config.accent ?? parent?.accent ?? FortalAccentColor.indigo; + + return FortalThemeData( + accent: accent, + gray: config.gray ?? parent?.gray ?? FortalGrayColor.slate, + brightness: config.brightness ?? parent?.brightness ?? Brightness.light, + panelBackground: + config.panelBackground ?? + parent?.panelBackground ?? + FortalPanelBackground.translucent, + radius: config.radius ?? parent?.radius ?? FortalRadius.medium, + scaling: config.scaling ?? parent?.scaling ?? FortalScaling.percent100, + hasBackground: config.hasBackground ?? parent == null, + ); +} + +/// Makes the active [FortalThemeData] available to descendants. +class FortalTheme extends InheritedTheme { + const FortalTheme({ + super.key, + required this.data, + this.orderOfModifiers, + required super.child, + }); + + final FortalThemeData data; + final List? orderOfModifiers; + + /// Returns the closest resolved Fortal theme. + static FortalThemeData of(BuildContext context) { + final data = maybeOf(context); + if (data != null) return data; + throw FlutterError.fromParts([ + ErrorSummary('No FortalTheme found.'), + ErrorDescription( + '${context.widget.runtimeType} tried to read the Fortal theme, but no FortalScope was found above it.', + ), + context.describeElement('The context used was'), + ]); + } + + /// Returns the closest resolved Fortal theme, if one is available. + static FortalThemeData? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.data; + + @override + Widget wrap(BuildContext context, Widget child) => FortalTheme( + data: data, + orderOfModifiers: orderOfModifiers, + child: MixScope( + tokens: _buildFortalScopeTokens(data), + orderOfModifiers: orderOfModifiers, + child: child, + ), + ); + + @override + bool updateShouldNotify(FortalTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/remix/lib/src/radix/colors/colors_generated.dart b/packages/remix/lib/src/radix/colors/colors_generated.dart index 04be166f..ca8dbe6d 100644 --- a/packages/remix/lib/src/radix/colors/colors_generated.dart +++ b/packages/remix/lib/src/radix/colors/colors_generated.dart @@ -1,8 +1,8 @@ // GENERATED CODE - DO NOT EDIT -// Generated from: Radix color scales + neutrals. Generated by scripts/extract_radix_tokens.dart -// Radix Themes version: 3.2.1 -// Radix Colors version: 3.0.0 -// Generated on: 2025-09-12T17:43:27.841305 +// Generated from: sRGB fallback tokens extracted from the pinned @radix-ui/themes npm artifact +// Radix Themes version: 3.3.0 +// Radix Colors version: bundled with Radix Themes 3.3.0 +// Source integrity: sha512-I0/h2CRNTpYNB7Mi3xFIvSsQq5a108d7kK8dTO5zp5b9HR5QJXKag6B8tjpz2ITkVYkFdkGk45doNkSr7OxwNw== part of 'colors.dart'; @@ -166,26 +166,26 @@ const _slateLight = RadixColor( 4: Color(0xffe8e8ec), 5: Color(0xffe0e1e6), 6: Color(0xffd9d9e0), - 7: Color(0xffcdced7), + 7: Color(0xffcdced6), 8: Color(0xffb9bbc6), 9: Color(0xff8b8d98), - 10: Color(0xff80828D), - 11: Color(0xff62636C), - 12: Color(0xff1E1F24), + 10: Color(0xff80838d), + 11: Color(0xff60646c), + 12: Color(0xff1c2024), }), ColorSwatch(0x7400051d, { 1: Color(0x03000055), 2: Color(0x06000055), - 3: Color(0x0f001040), - 4: Color(0x17000B36), + 3: Color(0x0f000033), + 4: Color(0x1700002d), 5: Color(0x1f000932), - 6: Color(0x26000735), - 7: Color(0x32000633), + 6: Color(0x2600002f), + 7: Color(0x3200062e), 8: Color(0x46000830), - 9: Color(0x7400051D), - 10: Color(0x7f00051B), - 11: Color(0x9f000211), - 12: Color(0xe3000107), + 9: Color(0x7400051d), + 10: Color(0x7f00071b), + 11: Color(0x9f000714), + 12: Color(0xe3000509), }), ), Color(0xccffffff), @@ -1134,33 +1134,33 @@ const _irisDark = RadixColor( // indigo color scale const _indigoLight = RadixColor( RadixColorScale( - ColorSwatch(0xff3D63DD, { - 1: Color(0xffFDFDFE), - 2: Color(0xffF7F9FF), - 3: Color(0xffEDF2FE), - 4: Color(0xffDFEAFF), - 5: Color(0xffD0DFFF), - 6: Color(0xffBDD1FF), - 7: Color(0xffA6BFF9), - 8: Color(0xff87A5EF), - 9: Color(0xff3D63DD), - 10: Color(0xff3657C3), - 11: Color(0xff395BC7), - 12: Color(0xff1D2E5C), - }), - ColorSwatch(0xc10032D2, { + ColorSwatch(0xff3e63dd, { + 1: Color(0xfffdfdfe), + 2: Color(0xfff7f9ff), + 3: Color(0xffedf2fe), + 4: Color(0xffe1e9ff), + 5: Color(0xffd2deff), + 6: Color(0xffc1d0ff), + 7: Color(0xffabbdf9), + 8: Color(0xff8da4ef), + 9: Color(0xff3e63dd), + 10: Color(0xff3358d4), + 11: Color(0xff3a5bc7), + 12: Color(0xff1f2d5c), + }), + ColorSwatch(0xc10031d2, { 1: Color(0x02000080), - 2: Color(0x080040FF), - 3: Color(0x120047F1), - 4: Color(0x1e0058FF), - 5: Color(0x2d0052FF), - 6: Color(0x3e004EFF), - 7: Color(0x540048EE), - 8: Color(0x720040DD), - 9: Color(0xc10032D2), - 10: Color(0xcc002AB3), - 11: Color(0xc5002CB7), - 12: Color(0xe0001347), + 2: Color(0x080040ff), + 3: Color(0x120047f1), + 4: Color(0x1e0044ff), + 5: Color(0x2d0044ff), + 6: Color(0x3e003eff), + 7: Color(0x540037ed), + 8: Color(0x720034dc), + 9: Color(0xc10031d2), + 10: Color(0xcc002ec9), + 11: Color(0xc5002bb7), + 12: Color(0xe0001046), }), ), Color(0xccf5f8ff), @@ -1172,32 +1172,32 @@ const _indigoLight = RadixColor( const _indigoDark = RadixColor( RadixColorScale( ColorSwatch(0xff3e63dd, { - 1: Color(0xff0C111C), - 2: Color(0xff111725), - 3: Color(0xff172448), - 4: Color(0xff1D2E61), - 5: Color(0xff243974), - 6: Color(0xff2D4484), - 7: Color(0xff375098), - 8: Color(0xff405EB2), - 9: Color(0xff3D63DD), - 10: Color(0xff3F5CB0), - 11: Color(0xff93B4FF), - 12: Color(0xffD5E2FF), + 1: Color(0xff11131f), + 2: Color(0xff141726), + 3: Color(0xff182449), + 4: Color(0xff1d2e62), + 5: Color(0xff253974), + 6: Color(0xff304384), + 7: Color(0xff3a4f97), + 8: Color(0xff435db1), + 9: Color(0xff3e63dd), + 10: Color(0xff5472e4), + 11: Color(0xff9eb1ff), + 12: Color(0xffd6e1ff), }), ColorSwatch(0xdb4671ff, { - 1: Color(0x0f0012FB), - 2: Color(0x171156F9), - 3: Color(0x3c2B64FF), - 4: Color(0x573567FF), - 5: Color(0x6b3F71FD), - 6: Color(0x7c4B7AFD), - 7: Color(0x905480FF), - 8: Color(0xac5783FF), - 9: Color(0xdb4571FF), - 10: Color(0xe35580FE), - 11: Color(0xff93B4FF), - 12: Color(0xffD5E2FF), + 1: Color(0x0f1133ff), + 2: Color(0x173354fa), + 3: Color(0x3c2f62ff), + 4: Color(0x573566ff), + 5: Color(0x6b4171fd), + 6: Color(0x7c5178fd), + 7: Color(0x905a7fff), + 8: Color(0xac5b81fe), + 9: Color(0xdb4671ff), + 10: Color(0xe35c7efe), + 11: Color(0xff9eb1ff), + 12: Color(0xffd6e1ff), }), ), Color(0x80171d3b), @@ -2027,7 +2027,7 @@ const _amberDark = RadixColor( ), Color(0x80271f13), Color(0xffffc53d), - Color(0xffffc53d), + Color(0xffe2ac37), Color(0xff21201c), ); @@ -2064,8 +2064,8 @@ const _yellowLight = RadixColor( }), ), Color(0xccfefbe4), - Color(0xffffdc00), - Color(0xffffdc00), + Color(0xffffe629), + Color(0xffffe629), Color(0xff21201c), ); @@ -2102,7 +2102,7 @@ const _yellowDark = RadixColor( ), Color(0x80231f13), Color(0xffffe629), - Color(0xffffe629), + Color(0xffd2b929), Color(0xff21201c), ); @@ -2177,7 +2177,7 @@ const _limeDark = RadixColor( ), Color(0x801b2115), Color(0xffbdee63), - Color(0xffbdee63), + Color(0xff98c254), Color(0xff1d211c), ); @@ -2252,7 +2252,7 @@ const _mintDark = RadixColor( ), Color(0x80152727), Color(0xff86ead4), - Color(0xff86ead4), + Color(0xff65c3b0), Color(0xff1a211e), ); @@ -2327,7 +2327,7 @@ const _skyDark = RadixColor( ), Color(0x8013233b), Color(0xff7ce2fe), - Color(0xff7ce2fe), + Color(0xff5bbde2), Color(0xff1c2024), ); diff --git a/packages/remix/lib/src/rendering/remix_blend_mode.dart b/packages/remix/lib/src/rendering/remix_blend_mode.dart new file mode 100644 index 00000000..42bcba12 --- /dev/null +++ b/packages/remix/lib/src/rendering/remix_blend_mode.dart @@ -0,0 +1,166 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// Supplies the local paint bounds of a [CustomPainter] that paints beyond its +/// render box. +/// +/// Flutter does not expose painter overflow through [RenderObject.paintBounds], +/// so canvas effects that need an exact subtree layer can opt into this narrow +/// internal contract. +@internal +abstract interface class RemixCustomPainterPaintBounds { + Rect paintBoundsForSize(Size size); +} + +/// Composites a canvas-painted subtree with [blendMode] against its backdrop. +/// +/// This is the Flutter equivalent of CSS `mix-blend-mode`; layout, hit testing, +/// and semantics are unchanged. Composited descendants such as +/// [RepaintBoundary] are rejected because Flutter's retained layers cannot be +/// enclosed by a canvas `saveLayer`. +// TODO(https://github.com/conceptadev/mix/issues/994): Remove this local +// implementation once Mix provides an equivalent subtree blend-mode modifier. +class RemixBlendMode extends SingleChildRenderObjectWidget { + const RemixBlendMode({ + super.key, + required this.blendMode, + required super.child, + }); + + final BlendMode blendMode; + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderRemixBlendMode(blendMode); + + @override + void updateRenderObject( + BuildContext context, + _RenderRemixBlendMode renderObject, + ) { + renderObject.blendMode = blendMode; + } +} + +class _RenderRemixBlendMode extends RenderProxyBox { + _RenderRemixBlendMode(this._blendMode); + + BlendMode _blendMode; + + BlendMode get blendMode => _blendMode; + + set blendMode(BlendMode value) { + if (_blendMode == value) return; + _blendMode = value; + markNeedsPaint(); + } + + @override + Rect get paintBounds { + final child = this.child; + return child == null ? super.paintBounds : _subtreePaintBounds(child); + } + + @override + void paint(PaintingContext context, Offset offset) { + final child = this.child; + if (child == null) return; + if (_blendMode == BlendMode.srcOver) { + context.paintChild(child, offset); + return; + } + if (child.isRepaintBoundary || child.needsCompositing) { + throw FlutterError( + 'RemixBlendMode does not support composited descendants. ' + 'Remove the RepaintBoundary or compositing effect below it, or use ' + 'BlendMode.srcOver.', + ); + } + final bounds = paintBounds.shift(offset); + context.canvas + ..save() + ..clipRect(bounds) + ..saveLayer(bounds, Paint()..blendMode = _blendMode); + context.paintChild(child, offset); + context.canvas + ..restore() + ..restore(); + } +} + +// Computes conservative descendant bounds while preserving explicit clips. +Rect _subtreePaintBounds(RenderObject root) { + var bounds = _localPaintBounds(root); + + void includeDescendants( + RenderObject parent, + Matrix4 parentTransform, + Rect? ancestorClip, + ) { + parent.visitChildren((child) { + if (!parent.paintsChild(child)) return; + + var childClip = ancestorClip; + if (parent.describeApproximatePaintClip(child) case final clip?) { + final transformedClip = MatrixUtils.transformRect( + parentTransform, + clip, + ); + if (_isFinite(transformedClip)) { + childClip = childClip == null + ? transformedClip + : childClip.intersect(transformedClip); + if (childClip.isEmpty) return; + } + } + + final childTransform = Matrix4.copy(parentTransform); + parent.applyPaintTransform(child, childTransform); + var childBounds = MatrixUtils.transformRect( + childTransform, + _localPaintBounds(child), + ); + if (childClip != null) childBounds = childBounds.intersect(childClip); + if (_isFinite(childBounds) && !childBounds.isEmpty) { + bounds = bounds.expandToInclude(childBounds); + } + includeDescendants(child, childTransform, childClip); + }); + } + + includeDescendants(root, Matrix4.identity(), null); + return bounds; +} + +Rect _localPaintBounds(RenderObject object) { + var bounds = object.paintBounds; + if (object case final RenderCustomPaint customPaint) { + bounds = _includePainterBounds( + bounds, + customPaint.painter, + customPaint.size, + ); + bounds = _includePainterBounds( + bounds, + customPaint.foregroundPainter, + customPaint.size, + ); + } + return bounds; +} + +Rect _includePainterBounds(Rect bounds, CustomPainter? painter, Size size) { + if (painter case final RemixCustomPainterPaintBounds boundsProvider) { + final painterBounds = boundsProvider.paintBoundsForSize(size); + if (!_isFinite(painterBounds) || painterBounds.isEmpty) return bounds; + return bounds.expandToInclude(painterBounds); + } + return bounds; +} + +bool _isFinite(Rect rect) => + rect.left.isFinite && + rect.top.isFinite && + rect.right.isFinite && + rect.bottom.isFinite; diff --git a/packages/remix/lib/src/rendering/remix_box_effects.dart b/packages/remix/lib/src/rendering/remix_box_effects.dart new file mode 100644 index 00000000..b065b662 --- /dev/null +++ b/packages/remix/lib/src/rendering/remix_box_effects.dart @@ -0,0 +1,13 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:mix/mix.dart'; + +import 'remix_blend_mode.dart'; + +part 'remix_box_effects_model.dart'; +part 'remix_box_effects_adapter.dart'; +part 'remix_box_effects_painters.dart'; diff --git a/packages/remix/lib/src/rendering/remix_box_effects_adapter.dart b/packages/remix/lib/src/rendering/remix_box_effects_adapter.dart new file mode 100644 index 00000000..c2dca50f --- /dev/null +++ b/packages/remix/lib/src/rendering/remix_box_effects_adapter.dart @@ -0,0 +1,575 @@ +part of 'remix_box_effects.dart'; + +/// A Mix [Box] that inserts advanced paint at the decoration boundary. +@internal +final class RemixBoxWithEffects extends StatelessWidget { + const RemixBoxWithEffects({ + super.key, + required this.styleSpec, + this.containerEffects, + this.child, + }); + + final StyleSpec styleSpec; + final RemixBoxEffectsSpec? containerEffects; + final Widget? child; + + @override + Widget build(BuildContext context) { + final effects = containerEffects ?? const RemixBoxEffectsSpec(); + _validateEffects(effects); + return StyleSpecBuilder( + styleSpec: styleSpec, + builder: (context, spec) { + if (effects.isEmpty && + (spec.margin == null || spec.margin!.isNonNegative)) { + return Box( + styleSpec: StyleSpec(spec: spec), + child: child, + ); + } + _validateBoxDecoration(spec.decoration, effects: effects, flex: false); + return _RemixDecoratedBox( + spec: spec, + containerEffects: effects, + child: child, + ); + }, + ); + } +} + +/// A Mix [FlexBox] that inserts advanced paint at the nested Box boundary. +@internal +final class RemixFlexBoxWithEffects extends StatelessWidget { + const RemixFlexBoxWithEffects({ + super.key, + required this.styleSpec, + this.direction, + this.containerEffects, + this.children = const [], + }); + + final StyleSpec styleSpec; + final Axis? direction; + final RemixBoxEffectsSpec? containerEffects; + final List children; + + @override + Widget build(BuildContext context) { + final effects = containerEffects ?? const RemixBoxEffectsSpec(); + _validateEffects(effects); + return StyleSpecBuilder( + styleSpec: styleSpec, + builder: (context, spec) { + final box = spec.box?.spec; + final margin = box?.margin; + if (effects.isEmpty && (margin == null || margin.isNonNegative)) { + final resolved = StyleSpec(spec: spec); + return switch (direction) { + Axis.horizontal => RowBox(styleSpec: resolved, children: children), + Axis.vertical => ColumnBox(styleSpec: resolved, children: children), + null => FlexBox(styleSpec: resolved, children: children), + }; + } + + _validateBoxDecoration(box?.decoration, effects: effects, flex: true); + final flex = spec.flex?.spec; + assert( + direction == null || + flex?.direction == null || + direction == flex!.direction, + 'The forced and styled flex directions must agree.', + ); + final content = Flex( + direction: flex?.direction ?? direction ?? Axis.horizontal, + mainAxisAlignment: flex?.mainAxisAlignment ?? MainAxisAlignment.start, + mainAxisSize: flex?.mainAxisSize ?? MainAxisSize.max, + crossAxisAlignment: + flex?.crossAxisAlignment ?? CrossAxisAlignment.center, + textDirection: flex?.textDirection, + verticalDirection: flex?.verticalDirection ?? VerticalDirection.down, + textBaseline: flex?.textBaseline, + clipBehavior: flex?.clipBehavior ?? Clip.none, + spacing: flex?.spacing ?? 0, + children: children, + ); + return RemixBoxWithEffects( + styleSpec: spec.box ?? const StyleSpec(spec: BoxSpec()), + containerEffects: effects, + child: content, + ); + }, + ); + } +} + +void _validateEffects(RemixBoxEffectsSpec containerEffects) { + if (!containerEffects.backdropBlur.isFinite) { + throw FlutterError( + 'RemixBoxEffectsSpec.backdropBlur must be finite. ' + 'Received ${containerEffects.backdropBlur}.', + ); + } + if (containerEffects.backdropBlur < 0) { + throw FlutterError( + 'RemixBoxEffectsSpec.backdropBlur must be non-negative. ' + 'Received ${containerEffects.backdropBlur}.', + ); + } + if (!containerEffects.outline.width.isFinite) { + throw FlutterError( + 'RemixBoxEffectsSpec.outline.width must be finite. ' + 'Received ${containerEffects.outline.width}.', + ); + } + if (containerEffects.outline.width < 0) { + throw FlutterError( + 'RemixBoxEffectsSpec.outline.width must be non-negative. ' + 'Received ${containerEffects.outline.width}.', + ); + } + if (!containerEffects.outlineOffset.isFinite) { + throw FlutterError( + 'RemixBoxEffectsSpec.outlineOffset must be finite. ' + 'Received ${containerEffects.outlineOffset}.', + ); + } + if (containerEffects.outline.style != BorderStyle.none && + containerEffects.outline.strokeAlign != BorderSide.strokeAlignInside) { + throw FlutterError( + 'Remix box effects outlines must use BorderSide.strokeAlignInside.', + ); + } + if (containerEffects.behindContent case final layer?) { + _validateEffectLayer(layer, 'behindContent'); + } + if (containerEffects.overContent case final layer?) { + _validateEffectLayer(layer, 'overContent'); + } +} + +void _validateEffectLayer(RemixBoxEffectLayerSpec layer, String name) { + if (layer.gradientInsets.isNotEmpty && + layer.gradientInsets.length != layer.gradients.length) { + throw FlutterError( + 'RemixBoxEffectsSpec.$name.gradientInsets must be empty or match the ' + 'gradients length.', + ); + } + for (final inset in layer.gradientInsets) { + if (!inset.isFinite || inset < 0) { + throw FlutterError( + 'RemixBoxEffectsSpec.$name.gradientInsets must contain finite ' + 'non-negative values. Received ${layer.gradientInsets}.', + ); + } + } + for (var index = 0; index < layer.shadows.length; index++) { + final shadow = layer.shadows[index]; + final path = 'RemixBoxEffectsSpec.$name.shadows[$index]'; + if (!shadow.offset.dx.isFinite || !shadow.offset.dy.isFinite) { + throw FlutterError( + '$path.offset must contain finite values. Received ${shadow.offset}.', + ); + } + if (!shadow.blurRadius.isFinite || shadow.blurRadius < 0) { + throw FlutterError( + '$path.blurRadius must be finite and non-negative. ' + 'Received ${shadow.blurRadius}.', + ); + } + if (!shadow.spreadRadius.isFinite) { + throw FlutterError( + '$path.spreadRadius must be finite. ' + 'Received ${shadow.spreadRadius}.', + ); + } + if (!shadow.shapeInset.isFinite || shadow.shapeInset < 0) { + throw FlutterError( + '$path.shapeInset must be finite and non-negative. ' + 'Received ${shadow.shapeInset}.', + ); + } + } +} + +void _validateBoxDecoration( + Decoration? decoration, { + required RemixBoxEffectsSpec effects, + required bool flex, +}) { + final unsupported = + (decoration != null && decoration is! BoxDecoration) || + decoration is BoxDecoration && decoration.shape == BoxShape.circle; + if (!unsupported) return; + + if (effects.isEmpty) { + throw FlutterError( + 'Negative Remix ${flex ? 'FlexBox' : 'Box'} margins require a ' + 'rectangular BoxDecoration.', + ); + } + throw FlutterError( + 'Remix box effects require a rectangular BoxDecoration. ' + 'ShapeDecoration and BoxShape.circle are not supported.', + ); +} + +/// Internal Box equivalent that inserts box effect layers at the decoration +/// boundary, inside constraints and margin. +class _RemixDecoratedBox extends StatelessWidget { + const _RemixDecoratedBox({ + required this.spec, + required this.containerEffects, + this.child, + }); + + final BoxSpec spec; + final RemixBoxEffectsSpec containerEffects; + final Widget? child; + + @override + Widget build(BuildContext context) { + Widget? current = child; + if (child == null) { + current = spec.constraints == null || !spec.constraints!.isTight + ? LimitedBox( + maxWidth: 0, + maxHeight: 0, + child: ConstrainedBox(constraints: const BoxConstraints.expand()), + ) + : const SizedBox.shrink(); + } else if (spec.alignment != null) { + current = Align(alignment: spec.alignment!, child: current); + } + + final decorationPadding = spec.decoration?.padding; + final padding = switch ((spec.padding, decorationPadding)) { + (null, final value) => value, + (final value, null) => value, + (final value?, final decoration?) => value.add(decoration), + }; + if (padding != null) current = Padding(padding: padding, child: current); + + final decoration = + spec.decoration as BoxDecoration? ?? const BoxDecoration(); + final textDirection = Directionality.maybeOf(context); + final borderRadius = (decoration.borderRadius ?? BorderRadius.zero).resolve( + textDirection, + ); + final behindContent = + containerEffects.behindContent ?? const RemixBoxEffectLayerSpec(); + + // Box fill -> advanced behindContent -> border -> child. + if (decoration.border case final border?) { + current = CustomPaint( + painter: _RemixBoxBorderPainter( + border: border, + borderRadius: borderRadius, + textDirection: textDirection, + ), + child: current, + ); + } + if (!behindContent.isEmpty) { + current = CustomPaint( + painter: _RemixBoxEffectLayerPainter( + spec: behindContent, + borderRadius: borderRadius, + textDirection: textDirection, + phase: _BoxEffectPaintPhase.inner, + ), + child: current, + ); + } + current = DecoratedBox( + decoration: _backgroundDecoration(decoration), + child: current, + ); + + if (containerEffects.backdropBlur > 0) { + current = ClipRRect( + borderRadius: borderRadius, + clipBehavior: Clip.antiAlias, + child: BackdropFilter( + filter: ui.ImageFilter.blur( + sigmaX: containerEffects.backdropBlur, + sigmaY: containerEffects.backdropBlur, + ), + child: current, + ), + ); + } + + // Clip decorated content without clipping box effect shadows or outlines. + if (spec.clipBehavior case final clip? when clip != Clip.none) { + current = ClipPath( + clipper: _RemixDecorationClipper( + textDirection: textDirection, + decoration: decoration, + ), + clipBehavior: clip, + child: current, + ); + } + + if (_decorationShadow(decoration) case final shadow?) { + current = DecoratedBox(decoration: shadow, child: current); + } + if (!behindContent.isEmpty) { + current = CustomPaint( + painter: _RemixBoxEffectLayerPainter( + spec: behindContent, + borderRadius: borderRadius, + textDirection: textDirection, + phase: _BoxEffectPaintPhase.outer, + ), + child: current, + ); + } + if (spec.foregroundDecoration case final overContent?) { + current = DecoratedBox( + decoration: overContent, + position: DecorationPosition.foreground, + child: current, + ); + } + final hasForeground = + containerEffects.overContent != null && + !containerEffects.overContent!.isEmpty; + final hasOutline = + containerEffects.outline.style != BorderStyle.none && + containerEffects.outline.width > 0; + if (hasForeground || hasOutline) { + current = CustomPaint( + foregroundPainter: _RemixBoxEffectsForegroundPainter( + containerEffects: containerEffects, + borderRadius: borderRadius, + textDirection: textDirection, + ), + child: current, + ); + } + if (spec.constraints case final constraints?) { + current = ConstrainedBox(constraints: constraints, child: current); + } + if (spec.margin case final margin?) { + current = margin.isNonNegative + ? Padding(padding: margin, child: current) + : _RemixNegativeMargin(margin: margin, child: current); + } + if (spec.transform case final transform?) { + current = Transform( + transform: transform, + alignment: spec.transformAlignment, + child: current, + ); + } + return current; + } +} + +/// Lays out a child using CSS margin arithmetic when one or more insets are +/// negative. +/// +/// Flutter's [Padding] intentionally rejects negative values, while Radix's +/// ghost controls use a negative margin to cancel their visual padding in +/// surrounding layout. This render object keeps the padded child paintable at +/// its full size while reporting the margin-adjusted footprint to its parent. +class _RemixNegativeMargin extends SingleChildRenderObjectWidget { + const _RemixNegativeMargin({required this.margin, required super.child}); + + final EdgeInsetsGeometry margin; + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderRemixNegativeMargin( + margin: margin, + textDirection: Directionality.maybeOf(context), + ); + + @override + void updateRenderObject( + BuildContext context, + _RenderRemixNegativeMargin renderObject, + ) { + renderObject + ..margin = margin + ..textDirection = Directionality.maybeOf(context); + } +} + +class _RenderRemixNegativeMargin extends RenderShiftedBox { + _RenderRemixNegativeMargin({ + required EdgeInsetsGeometry margin, + required TextDirection? textDirection, + RenderBox? child, + }) : _margin = margin, + _textDirection = textDirection, + super(child); + + EdgeInsets? _resolvedMarginCache; + + EdgeInsets get _resolvedMargin => + _resolvedMarginCache ??= margin.resolve(textDirection); + + EdgeInsetsGeometry get margin => _margin; + EdgeInsetsGeometry _margin; + + set margin(EdgeInsetsGeometry value) { + if (_margin == value) return; + _margin = value; + _markNeedsResolution(); + } + + TextDirection? get textDirection => _textDirection; + TextDirection? _textDirection; + + set textDirection(TextDirection? value) { + if (_textDirection == value) return; + _textDirection = value; + _markNeedsResolution(); + } + + void _markNeedsResolution() { + _resolvedMarginCache = null; + markNeedsLayout(); + } + + double _intrinsicExtent(double childExtent, double marginExtent) => + math.max(0, childExtent + marginExtent); + + @override + double computeMinIntrinsicWidth(double height) { + final resolved = _resolvedMargin; + return _intrinsicExtent( + child?.getMinIntrinsicWidth(math.max(0, height - resolved.vertical)) ?? 0, + resolved.horizontal, + ); + } + + @override + double computeMaxIntrinsicWidth(double height) { + final resolved = _resolvedMargin; + return _intrinsicExtent( + child?.getMaxIntrinsicWidth(math.max(0, height - resolved.vertical)) ?? 0, + resolved.horizontal, + ); + } + + @override + double computeMinIntrinsicHeight(double width) { + final resolved = _resolvedMargin; + return _intrinsicExtent( + child?.getMinIntrinsicHeight(math.max(0, width - resolved.horizontal)) ?? + 0, + resolved.vertical, + ); + } + + @override + double computeMaxIntrinsicHeight(double width) { + final resolved = _resolvedMargin; + return _intrinsicExtent( + child?.getMaxIntrinsicHeight(math.max(0, width - resolved.horizontal)) ?? + 0, + resolved.vertical, + ); + } + + Size _constrainOuterSize(BoxConstraints constraints, Size childSize) { + final resolved = _resolvedMargin; + return constraints.constrain( + Size( + math.max(0, childSize.width + resolved.horizontal), + math.max(0, childSize.height + resolved.vertical), + ), + ); + } + + @override + @protected + Size computeDryLayout(covariant BoxConstraints constraints) { + final resolved = _resolvedMargin; + final currentChild = child; + if (currentChild == null) { + return constraints.constrain( + Size(math.max(0, resolved.horizontal), math.max(0, resolved.vertical)), + ); + } + return _constrainOuterSize( + constraints, + currentChild.getDryLayout(constraints.deflate(resolved)), + ); + } + + @override + double? computeDryBaseline( + covariant BoxConstraints constraints, + TextBaseline baseline, + ) { + final currentChild = child; + if (currentChild == null) return null; + final resolved = _resolvedMargin; + final childBaseline = currentChild.getDryBaseline( + constraints.deflate(resolved), + baseline, + ); + return childBaseline == null ? null : childBaseline + resolved.top; + } + + @override + void performLayout() { + final resolved = _resolvedMargin; + final currentChild = child; + if (currentChild == null) { + size = constraints.constrain( + Size(math.max(0, resolved.horizontal), math.max(0, resolved.vertical)), + ); + return; + } + + currentChild.layout(constraints.deflate(resolved), parentUsesSize: true); + (currentChild.parentData! as BoxParentData).offset = Offset( + resolved.left, + resolved.top, + ); + size = _constrainOuterSize(constraints, currentChild.size); + } + + @override + Rect get paintBounds { + final currentChild = child; + if (currentChild == null) return Offset.zero & size; + final offset = (currentChild.parentData! as BoxParentData).offset; + return (Offset.zero & size).expandToInclude( + currentChild.paintBounds.shift(offset), + ); + } + + @override + Rect get semanticBounds => paintBounds; + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + if (child == null || !hasSize) return false; + if (!hitTestChildren(result, position: position)) return false; + result.add(BoxHitTestEntry(this, position)); + return true; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('margin', margin)) + ..add( + EnumProperty( + 'textDirection', + textDirection, + defaultValue: null, + ), + ); + } +} diff --git a/packages/remix/lib/src/rendering/remix_box_effects_model.dart b/packages/remix/lib/src/rendering/remix_box_effects_model.dart new file mode 100644 index 00000000..a5983d68 --- /dev/null +++ b/packages/remix/lib/src/rendering/remix_box_effects_model.dart @@ -0,0 +1,780 @@ +part of 'remix_box_effects.dart'; + +/// Whether a painted shadow falls outside a box or into its interior. +enum RemixBoxShadowKind { outer, inset } + +/// A CSS-shaped box-shadow layer that can represent real inset shadows. +@immutable +class RemixBoxShadow { + const RemixBoxShadow({ + this.kind = RemixBoxShadowKind.outer, + this.color = const Color(0xFF000000), + this.offset = Offset.zero, + this.blurRadius = 0, + this.spreadRadius = 0, + this.shapeInset = 0, + }) : assert(blurRadius >= 0), + assert(shapeInset >= 0); + + final RemixBoxShadowKind kind; + final Color color; + final Offset offset; + final double blurRadius; + final double spreadRadius; + final double shapeInset; + + RemixBoxShadow scale(double factor) => RemixBoxShadow( + kind: kind, + color: color.withValues(alpha: color.a * factor), + offset: offset * factor, + blurRadius: math.max(0, blurRadius * factor), + spreadRadius: spreadRadius * factor, + shapeInset: math.max(0, shapeInset * factor), + ); + + static RemixBoxShadow? lerp(RemixBoxShadow? a, RemixBoxShadow? b, double t) { + if (identical(a, b)) return a; + if (a == null) return b!.scale(t); + if (b == null) return a.scale(1 - t); + + return RemixBoxShadow( + kind: t < 0.5 ? a.kind : b.kind, + color: Color.lerp(a.color, b.color, t)!, + offset: Offset.lerp(a.offset, b.offset, t)!, + blurRadius: math.max(0, ui.lerpDouble(a.blurRadius, b.blurRadius, t)!), + spreadRadius: ui.lerpDouble(a.spreadRadius, b.spreadRadius, t)!, + shapeInset: math.max(0, ui.lerpDouble(a.shapeInset, b.shapeInset, t)!), + ); + } + + static List lerpList( + List a, + List b, + double t, + ) { + final commonLength = math.min(a.length, b.length); + return [ + for (var index = 0; index < commonLength; index++) + lerp(a[index], b[index], t)!, + for (var index = commonLength; index < a.length; index++) + lerp(a[index], null, t)!, + for (var index = commonLength; index < b.length; index++) + lerp(null, b[index], t)!, + ]; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RemixBoxShadow && + kind == other.kind && + color == other.color && + offset == other.offset && + blurRadius == other.blurRadius && + spreadRadius == other.spreadRadius && + shapeInset == other.shapeInset; + + @override + int get hashCode => + Object.hash(kind, color, offset, blurRadius, spreadRadius, shapeInset); + + @override + String toString() => + 'RemixBoxShadow($kind, $color, $offset, $blurRadius, $spreadRadius, ' + '$shapeInset)'; +} + +/// Mix representation of [RemixBoxShadow] with token-aware fields. +@immutable +class RemixBoxShadowMix extends Mix { + RemixBoxShadowMix({ + RemixBoxShadowKind? kind, + Color? color, + Offset? offset, + double? blurRadius, + double? spreadRadius, + double? shapeInset, + }) : this.create( + kind: Prop.maybe(kind), + color: Prop.maybe(color), + offset: Prop.maybe(offset), + blurRadius: Prop.maybe(blurRadius), + spreadRadius: Prop.maybe(spreadRadius), + shapeInset: Prop.maybe(shapeInset), + ); + + const RemixBoxShadowMix.create({ + Prop? kind, + Prop? color, + Prop? offset, + Prop? blurRadius, + Prop? spreadRadius, + Prop? shapeInset, + }) : $kind = kind, + $color = color, + $offset = offset, + $blurRadius = blurRadius, + $spreadRadius = spreadRadius, + $shapeInset = shapeInset; + + RemixBoxShadowMix.value(RemixBoxShadow value) + : this( + kind: value.kind, + color: value.color, + offset: value.offset, + blurRadius: value.blurRadius, + spreadRadius: value.spreadRadius, + shapeInset: value.shapeInset, + ); + + final Prop? $kind; + final Prop? $color; + final Prop? $offset; + final Prop? $blurRadius; + final Prop? $spreadRadius; + final Prop? $shapeInset; + + @override + RemixBoxShadowMix merge(RemixBoxShadowMix? other) { + if (other == null) return this; + return RemixBoxShadowMix.create( + kind: MixOps.merge($kind, other.$kind), + color: MixOps.merge($color, other.$color), + offset: MixOps.merge($offset, other.$offset), + blurRadius: MixOps.merge($blurRadius, other.$blurRadius), + spreadRadius: MixOps.merge($spreadRadius, other.$spreadRadius), + shapeInset: MixOps.merge($shapeInset, other.$shapeInset), + ); + } + + @override + RemixBoxShadow resolve(BuildContext context) => RemixBoxShadow( + kind: MixOps.resolve(context, $kind) ?? RemixBoxShadowKind.outer, + color: MixOps.resolve(context, $color) ?? const Color(0xFF000000), + offset: MixOps.resolve(context, $offset) ?? Offset.zero, + blurRadius: MixOps.resolve(context, $blurRadius) ?? 0, + spreadRadius: MixOps.resolve(context, $spreadRadius) ?? 0, + shapeInset: MixOps.resolve(context, $shapeInset) ?? 0, + ); + + @override + List get props => [ + $kind, + $color, + $offset, + $blurRadius, + $spreadRadius, + $shapeInset, + ]; +} + +/// A theme token containing an ordered list of inset-capable shadows. +class RemixBoxShadowListToken extends MixToken> { + const RemixBoxShadowListToken(super.name); +} + +/// Token-aware Mix representation of a CSS linear gradient. +/// +/// Unlike Flutter's `LinearGradientMix`, each color and stop is resolved as an +/// individual Mix property. This allows multiple design-token references in a +/// single gradient. +@immutable +class RemixLinearGradientMix extends Mix { + RemixLinearGradientMix({ + List? colors, + List? stops, + AlignmentGeometry? begin, + AlignmentGeometry? end, + TileMode? tileMode, + GradientTransform? transform, + }) : this.create( + colors: colors + ?.map((value) => Prop.value(value)) + .toList(growable: false), + stops: stops + ?.map((value) => Prop.value(value)) + .toList(growable: false), + begin: Prop.maybe(begin), + end: Prop.maybe(end), + tileMode: Prop.maybe(tileMode), + transform: Prop.maybe(transform), + ); + + const RemixLinearGradientMix.create({ + List>? colors, + List>? stops, + Prop? begin, + Prop? end, + Prop? tileMode, + Prop? transform, + }) : $colors = colors, + $stops = stops, + $begin = begin, + $end = end, + $tileMode = tileMode, + $transform = transform; + + RemixLinearGradientMix.value(LinearGradient value) + : this( + colors: value.colors, + stops: value.stops, + begin: value.begin, + end: value.end, + tileMode: value.tileMode, + transform: value.transform, + ); + + final List>? $colors; + final List>? $stops; + final Prop? $begin; + final Prop? $end; + final Prop? $tileMode; + final Prop? $transform; + + @override + RemixLinearGradientMix merge(RemixLinearGradientMix? other) { + if (other == null) return this; + return RemixLinearGradientMix.create( + colors: other.$colors ?? $colors, + stops: other.$stops ?? $stops, + begin: MixOps.merge($begin, other.$begin), + end: MixOps.merge($end, other.$end), + tileMode: MixOps.merge($tileMode, other.$tileMode), + transform: MixOps.merge($transform, other.$transform), + ); + } + + @override + LinearGradient resolve(BuildContext context) { + final colors = $colors + ?.map((color) => MixOps.resolve(context, color)!) + .toList(growable: false); + if (colors == null || colors.length < 2) { + throw FlutterError( + 'RemixLinearGradientMix requires at least two resolved colors.', + ); + } + final stops = $stops + ?.map((stop) => MixOps.resolve(context, stop)!) + .toList(growable: false); + final normalized = _normalizeGradient(colors, stops); + + return LinearGradient( + colors: normalized.colors, + stops: normalized.stops, + begin: MixOps.resolve(context, $begin) ?? Alignment.topCenter, + end: MixOps.resolve(context, $end) ?? Alignment.bottomCenter, + tileMode: MixOps.resolve(context, $tileMode) ?? TileMode.clamp, + transform: MixOps.resolve(context, $transform), + ); + } + + @override + List get props => [ + $colors, + $stops, + $begin, + $end, + $tileMode, + $transform, + ]; +} + +typedef _NormalizedGradient = ({List colors, List stops}); + +_NormalizedGradient _normalizeGradient( + List colors, + List? suppliedStops, +) { + if (suppliedStops != null && suppliedStops.length != colors.length) { + throw FlutterError( + 'Gradient stops (${suppliedStops.length}) must match colors ' + '(${colors.length}).', + ); + } + + final stops = suppliedStops == null + ? List.generate( + colors.length, + (index) => index / (colors.length - 1), + growable: false, + ) + : List.of(suppliedStops); + if (stops.any((stop) => !stop.isFinite)) { + throw FlutterError('Gradient stops must be finite numbers.'); + } + for (var index = 1; index < stops.length; index++) { + stops[index] = math.max(stops[index - 1], stops[index]); + } + + Color sample(double position) { + if (position <= stops.first) return colors.first; + if (position >= stops.last) return colors.last; + for (var index = 1; index < stops.length; index++) { + if (position <= stops[index]) { + final start = stops[index - 1]; + final end = stops[index]; + if (end == start) return colors[index]; + return Color.lerp( + colors[index - 1], + colors[index], + (position - start) / (end - start), + )!; + } + } + return colors.last; + } + + final normalizedColors = [sample(0)]; + final normalizedStops = [0]; + for (var index = 0; index < stops.length; index++) { + final stop = stops[index]; + if (stop > 0 && stop < 1) { + normalizedStops.add(stop); + normalizedColors.add(colors[index]); + } else if (stop == 0 && index > 0) { + normalizedStops.add(0); + normalizedColors.add(colors[index]); + } else if (stop == 1 && index < stops.length - 1) { + normalizedStops.add(1); + normalizedColors.add(colors[index]); + } + } + normalizedStops.add(1); + normalizedColors.add(sample(1)); + + return (colors: normalizedColors, stops: normalizedStops); +} + +/// Resolved ordered box effect layers painted behind component content. +@immutable +class RemixBoxEffectLayerSpec extends Spec { + const RemixBoxEffectLayerSpec({ + this.gradients = const [], + this.gradientInsets = const [], + this.shadows = const [], + }); + + /// CSS order: the first gradient is painted closest to the content. + final List gradients; + + /// Optional clip inset for each corresponding [gradients] layer. + /// + /// This models CSS backgrounds clipped to a transparent inner border, such + /// as Radix classic-button pseudo-elements. An empty list means no insets. + final List gradientInsets; + + /// CSS order: the first shadow is painted closest to the content. + final List shadows; + + /// Whether this layer contributes no paint. + bool get isEmpty => gradients.isEmpty && shadows.isEmpty; + + /// Interpolates nullable surface slots through an empty visual layer. + /// + /// Component specs use nullable layers so variants can add or remove a + /// surface. Treating absence as an empty layer lets those transitions fade + /// instead of snapping while preserving `null` at the exact absent endpoint. + static RemixBoxEffectLayerSpec? lerpNullable( + RemixBoxEffectLayerSpec? begin, + RemixBoxEffectLayerSpec? end, + double t, + ) { + if (begin == null && end == null) return null; + if (begin == null) { + if (t <= 0) return null; + if (t >= 1) return end; + return const RemixBoxEffectLayerSpec().lerp(end, t); + } + if (end == null) { + if (t <= 0) return begin; + if (t >= 1) return null; + return begin.lerp(const RemixBoxEffectLayerSpec(), t); + } + return begin.lerp(end, t); + } + + @override + RemixBoxEffectLayerSpec copyWith({ + List? gradients, + List? gradientInsets, + List? shadows, + }) => RemixBoxEffectLayerSpec( + gradients: gradients ?? this.gradients, + gradientInsets: gradientInsets ?? this.gradientInsets, + shadows: shadows ?? this.shadows, + ); + + @override + RemixBoxEffectLayerSpec lerp(RemixBoxEffectLayerSpec? other, double t) { + if (other == null) return this; + final interpolatedGradients = _lerpGradientList( + gradients, + other.gradients, + t, + ); + final currentInsets = gradientInsets.isEmpty + ? List.filled(gradients.length, 0) + : gradientInsets; + final otherInsets = other.gradientInsets.isEmpty + ? List.filled(other.gradients.length, 0) + : other.gradientInsets; + return RemixBoxEffectLayerSpec( + gradients: interpolatedGradients, + gradientInsets: _lerpDoubleList(currentInsets, otherInsets, t), + shadows: RemixBoxShadow.lerpList(shadows, other.shadows, t), + ); + } + + @override + List get props => [gradients, gradientInsets, shadows]; +} + +List _lerpDoubleList(List a, List b, double t) { + final length = math.max(a.length, b.length); + return [ + for (var index = 0; index < length; index++) + math.max( + 0, + ui.lerpDouble( + index < a.length ? a[index] : 0, + index < b.length ? b[index] : 0, + t, + )!, + ), + ]; +} + +List _lerpGradientList(List a, List b, double t) { + final commonLength = math.min(a.length, b.length); + return [ + for (var index = 0; index < commonLength; index++) + Gradient.lerp(a[index], b[index], t)!, + for (var index = commonLength; index < a.length; index++) + Gradient.lerp(a[index], null, t)!, + for (var index = commonLength; index < b.length; index++) + Gradient.lerp(null, b[index], t)!, + ]; +} + +@immutable +class _RemixGradientListMix extends Mix> { + const _RemixGradientListMix(this.values); + + final List values; + + @override + _RemixGradientListMix merge(_RemixGradientListMix? other) => other ?? this; + + @override + List resolve(BuildContext context) => + values.map((value) => value.resolve(context)).toList(growable: false); + + @override + List get props => [values]; +} + +@immutable +class _RemixDoubleListMix extends Mix> { + const _RemixDoubleListMix(this.values); + + final List values; + + @override + _RemixDoubleListMix merge(_RemixDoubleListMix? other) => other ?? this; + + @override + List resolve(BuildContext context) => values + .map((value) => MixOps.resolve(context, Prop.value(value))!) + .toList(growable: false); + + @override + List get props => [values]; +} + +@immutable +class _RemixShadowListMix extends Mix> { + const _RemixShadowListMix.values(this.values) : token = null; + + const _RemixShadowListMix.token(this.token) : values = null; + + final List? values; + final RemixBoxShadowListToken? token; + + @override + _RemixShadowListMix merge(_RemixShadowListMix? other) => other ?? this; + + @override + List resolve(BuildContext context) { + final token = this.token; + if (token != null) return token.resolve(context); + + return values! + .map((value) => value.resolve(context)) + .toList(growable: false); + } + + @override + List get props => [values, token]; +} + +Prop>? _shadowProp( + List? shadows, + RemixBoxShadowListToken? token, +) { + if (shadows != null && token != null) { + throw ArgumentError('Provide either shadows or shadowToken, not both.'); + } + if (token != null) return Prop.mix(_RemixShadowListMix.token(token)); + if (shadows != null) { + return Prop.mix(_RemixShadowListMix.values(shadows)); + } + return null; +} + +/// Mix representation of [RemixBoxEffectLayerSpec]. +@immutable +class RemixBoxEffectLayerMix extends Mix { + RemixBoxEffectLayerMix({ + List? gradients, + List? gradientInsets, + List? shadows, + RemixBoxShadowListToken? shadowToken, + }) : this.create( + gradients: gradients == null + ? null + : Prop.mix(_RemixGradientListMix(gradients)), + gradientInsets: gradientInsets == null + ? null + : Prop.mix(_RemixDoubleListMix(gradientInsets)), + shadows: _shadowProp(shadows, shadowToken), + ); + + const RemixBoxEffectLayerMix.create({ + Prop>? gradients, + Prop>? gradientInsets, + Prop>? shadows, + }) : $gradients = gradients, + $gradientInsets = gradientInsets, + $shadows = shadows; + + final Prop>? $gradients; + final Prop>? $gradientInsets; + final Prop>? $shadows; + + @override + RemixBoxEffectLayerMix merge(RemixBoxEffectLayerMix? other) { + if (other == null) return this; + return RemixBoxEffectLayerMix.create( + gradients: MixOps.merge($gradients, other.$gradients), + gradientInsets: MixOps.merge($gradientInsets, other.$gradientInsets), + shadows: MixOps.merge($shadows, other.$shadows), + ); + } + + @override + RemixBoxEffectLayerSpec resolve(BuildContext context) => + RemixBoxEffectLayerSpec( + gradients: MixOps.resolve(context, $gradients) ?? const [], + gradientInsets: MixOps.resolve(context, $gradientInsets) ?? const [], + shadows: MixOps.resolve(context, $shadows) ?? const [], + ); + + @override + List get props => [$gradients, $gradientInsets, $shadows]; +} + +/// Advanced paint that augments, but does not replace, a Mix Box decoration. +@immutable +class RemixBoxEffectsSpec extends Spec { + const RemixBoxEffectsSpec({ + this.behindContent, + this.overContent, + this.backdropBlur = 0, + this.outline = BorderSide.none, + this.outlineOffset = 0, + }) : assert(backdropBlur >= 0), + assert( + outlineOffset >= -double.maxFinite && + outlineOffset <= double.maxFinite, + ); + + final RemixBoxEffectLayerSpec? behindContent; + final RemixBoxEffectLayerSpec? overContent; + final double backdropBlur; + final BorderSide outline; + final double outlineOffset; + + /// Whether this aggregate contributes no advanced paint. + bool get isEmpty => + (behindContent == null || behindContent!.isEmpty) && + (overContent == null || overContent!.isEmpty) && + backdropBlur == 0 && + (outline.style == BorderStyle.none || outline.width == 0); + + RemixBoxEffectsSpec merge(RemixBoxEffectsSpec? other) { + if (other == null) return this; + final hasOutline = other.outline.style != BorderStyle.none; + return RemixBoxEffectsSpec( + behindContent: other.behindContent ?? behindContent, + overContent: other.overContent ?? overContent, + backdropBlur: other.backdropBlur == 0 ? backdropBlur : other.backdropBlur, + outline: hasOutline ? other.outline : outline, + outlineOffset: hasOutline ? other.outlineOffset : outlineOffset, + ); + } + + static RemixBoxEffectsSpec? lerpNullable( + RemixBoxEffectsSpec? begin, + RemixBoxEffectsSpec? end, + double t, + ) { + if (begin == null && end == null) return null; + if (begin == null) { + if (t <= 0) return null; + if (t >= 1) return end; + return RemixBoxEffectsSpec().lerp(end, t); + } + if (end == null) { + if (t <= 0) return begin; + if (t >= 1) return null; + return begin.lerp(RemixBoxEffectsSpec(), t); + } + return begin.lerp(end, t); + } + + @override + RemixBoxEffectsSpec copyWith({ + RemixBoxEffectLayerSpec? behindContent, + RemixBoxEffectLayerSpec? overContent, + double? backdropBlur, + BorderSide? outline, + double? outlineOffset, + }) => RemixBoxEffectsSpec( + behindContent: behindContent ?? this.behindContent, + overContent: overContent ?? this.overContent, + backdropBlur: backdropBlur ?? this.backdropBlur, + outline: outline ?? this.outline, + outlineOffset: outlineOffset ?? this.outlineOffset, + ); + + @override + RemixBoxEffectsSpec lerp(RemixBoxEffectsSpec? other, double t) { + if (other == null) return this; + return RemixBoxEffectsSpec( + behindContent: RemixBoxEffectLayerSpec.lerpNullable( + behindContent, + other.behindContent, + t, + ), + overContent: RemixBoxEffectLayerSpec.lerpNullable( + overContent, + other.overContent, + t, + ), + backdropBlur: math.max( + 0, + ui.lerpDouble(backdropBlur, other.backdropBlur, t)!, + ), + outline: _lerpOutline(outline, other.outline, t), + outlineOffset: ui.lerpDouble(outlineOffset, other.outlineOffset, t)!, + ); + } + + @override + List get props => [ + behindContent, + overContent, + backdropBlur, + outline, + outlineOffset, + ]; +} + +BorderSide _lerpOutline(BorderSide begin, BorderSide end, double t) { + final beginVisible = begin.style != BorderStyle.none && begin.width > 0; + final endVisible = end.style != BorderStyle.none && end.width > 0; + final beginColor = beginVisible + ? begin.color + : end.color.withValues(alpha: 0); + final endColor = endVisible ? end.color : begin.color.withValues(alpha: 0); + return BorderSide( + color: Color.lerp(beginColor, endColor, t)!, + width: math.max( + 0, + ui.lerpDouble( + beginVisible ? begin.width : 0, + endVisible ? end.width : 0, + t, + )!, + ), + style: beginVisible || endVisible ? BorderStyle.solid : BorderStyle.none, + strokeAlign: BorderSide.strokeAlignInside, + ); +} + +/// Mix representation of [RemixBoxEffectsSpec]. +@immutable +class RemixBoxEffectsMix extends Mix { + RemixBoxEffectsMix({ + RemixBoxEffectLayerMix? behindContent, + RemixBoxEffectLayerMix? overContent, + double? backdropBlur, + BorderSideMix? outline, + double? outlineOffset, + }) : this.create( + behindContent: Prop.maybeMix(behindContent), + overContent: Prop.maybeMix(overContent), + backdropBlur: Prop.maybe(backdropBlur), + outline: Prop.maybeMix(outline), + outlineOffset: Prop.maybe(outlineOffset), + ); + + const RemixBoxEffectsMix.create({ + Prop? behindContent, + Prop? overContent, + Prop? backdropBlur, + Prop? outline, + Prop? outlineOffset, + }) : $behindContent = behindContent, + $overContent = overContent, + $backdropBlur = backdropBlur, + $outline = outline, + $outlineOffset = outlineOffset; + + final Prop? $behindContent; + final Prop? $overContent; + final Prop? $backdropBlur; + final Prop? $outline; + final Prop? $outlineOffset; + + @override + RemixBoxEffectsMix merge(RemixBoxEffectsMix? other) { + if (other == null) return this; + return RemixBoxEffectsMix.create( + behindContent: MixOps.merge($behindContent, other.$behindContent), + overContent: MixOps.merge($overContent, other.$overContent), + backdropBlur: MixOps.merge($backdropBlur, other.$backdropBlur), + outline: MixOps.merge($outline, other.$outline), + outlineOffset: MixOps.merge($outlineOffset, other.$outlineOffset), + ); + } + + @override + RemixBoxEffectsSpec resolve(BuildContext context) => RemixBoxEffectsSpec( + behindContent: MixOps.resolve(context, $behindContent), + overContent: MixOps.resolve(context, $overContent), + backdropBlur: MixOps.resolve(context, $backdropBlur) ?? 0, + outline: MixOps.resolve(context, $outline) ?? BorderSide.none, + outlineOffset: MixOps.resolve(context, $outlineOffset) ?? 0, + ); + + @override + List get props => [ + $behindContent, + $overContent, + $backdropBlur, + $outline, + $outlineOffset, + ]; +} diff --git a/packages/remix/lib/src/rendering/remix_box_effects_painters.dart b/packages/remix/lib/src/rendering/remix_box_effects_painters.dart new file mode 100644 index 00000000..a0925db8 --- /dev/null +++ b/packages/remix/lib/src/rendering/remix_box_effects_painters.dart @@ -0,0 +1,375 @@ +part of 'remix_box_effects.dart'; + +Decoration _backgroundDecoration(Decoration decoration) { + if (decoration is BoxDecoration) { + return BoxDecoration( + color: decoration.color, + image: decoration.image, + borderRadius: decoration.borderRadius, + gradient: decoration.gradient, + backgroundBlendMode: decoration.backgroundBlendMode, + shape: decoration.shape, + ); + } + return decoration; +} + +Decoration? _decorationShadow(Decoration decoration) { + if (decoration case BoxDecoration( + :final boxShadow?, + ) when boxShadow.isNotEmpty) { + return BoxDecoration( + borderRadius: decoration.borderRadius, + boxShadow: boxShadow, + shape: decoration.shape, + ); + } + return null; +} + +class _RemixDecorationClipper extends CustomClipper { + const _RemixDecorationClipper({ + required this.textDirection, + required this.decoration, + }); + + final TextDirection? textDirection; + final Decoration decoration; + + @override + Path getClip(Size size) => decoration.getClipPath( + Offset.zero & size, + textDirection ?? TextDirection.ltr, + ); + + @override + bool shouldReclip(_RemixDecorationClipper oldClipper) => + decoration != oldClipper.decoration || + textDirection != oldClipper.textDirection; +} + +enum _BoxEffectPaintPhase { all, outer, inner, outline } + +class _RemixBoxBorderPainter extends CustomPainter { + const _RemixBoxBorderPainter({ + required this.border, + required this.borderRadius, + required this.textDirection, + }); + + final BoxBorder border; + final BorderRadius borderRadius; + final TextDirection? textDirection; + + @override + void paint(Canvas canvas, Size size) { + border.paint( + canvas, + Offset.zero & size, + shape: BoxShape.rectangle, + borderRadius: borderRadius, + textDirection: textDirection, + ); + } + + @override + bool shouldRepaint(_RemixBoxBorderPainter oldDelegate) => + border != oldDelegate.border || + borderRadius != oldDelegate.borderRadius || + textDirection != oldDelegate.textDirection; +} + +class _RemixBoxEffectsForegroundPainter extends CustomPainter + implements RemixCustomPainterPaintBounds { + const _RemixBoxEffectsForegroundPainter({ + required this.containerEffects, + required this.borderRadius, + required this.textDirection, + }); + + final RemixBoxEffectsSpec containerEffects; + final BorderRadius borderRadius; + final TextDirection? textDirection; + + @override + void paint(Canvas canvas, Size size) { + if (containerEffects.overContent case final overContent?) { + _RemixBoxEffectLayerPainter( + spec: overContent, + borderRadius: borderRadius, + textDirection: textDirection, + phase: _BoxEffectPaintPhase.all, + ).paint(canvas, size); + } + + _RemixBoxEffectLayerPainter( + spec: const RemixBoxEffectLayerSpec(), + borderRadius: borderRadius, + textDirection: textDirection, + phase: _BoxEffectPaintPhase.outline, + outline: containerEffects.outline, + outlineOffset: containerEffects.outlineOffset, + ).paint(canvas, size); + } + + @override + bool hitTest(Offset position) => false; + + @override + Rect paintBoundsForSize(Size size) { + var bounds = _boxEffectPaintBounds( + const RemixBoxEffectLayerSpec(), + size, + borderRadius: borderRadius, + includeOuterShadows: false, + includeOutline: true, + outline: containerEffects.outline, + outlineOffset: containerEffects.outlineOffset, + ); + if (containerEffects.overContent case final overContent?) { + bounds = bounds.expandToInclude( + _boxEffectPaintBounds( + overContent, + size, + borderRadius: borderRadius, + includeOuterShadows: true, + includeOutline: false, + ), + ); + } + return bounds; + } + + @override + bool shouldRepaint(_RemixBoxEffectsForegroundPainter oldDelegate) => + containerEffects != oldDelegate.containerEffects || + borderRadius != oldDelegate.borderRadius || + textDirection != oldDelegate.textDirection; +} + +class _RemixBoxEffectLayerPainter extends CustomPainter + implements RemixCustomPainterPaintBounds { + const _RemixBoxEffectLayerPainter({ + required this.spec, + required this.borderRadius, + required this.textDirection, + required this.phase, + this.outline = BorderSide.none, + this.outlineOffset = 0, + }); + + final RemixBoxEffectLayerSpec spec; + final BorderRadius borderRadius; + final TextDirection? textDirection; + final _BoxEffectPaintPhase phase; + final BorderSide outline; + final double outlineOffset; + + @override + void paint(Canvas canvas, Size size) { + if (size.isEmpty) return; + final rect = Offset.zero & size; + final shape = borderRadius.toRRect(rect); + + if (phase == _BoxEffectPaintPhase.outline) { + _paintOutline(canvas, shape); + return; + } + if (phase != _BoxEffectPaintPhase.inner) { + _paintOuterShadows(canvas, shape); + } + if (phase != _BoxEffectPaintPhase.outer) { + _paintInnerLayers(canvas, rect, shape); + } + if (phase == _BoxEffectPaintPhase.all) { + _paintOutline(canvas, shape); + } + } + + void _paintOuterShadows(Canvas canvas, RRect shape) { + for (final shadow in spec.shadows.reversed) { + if (shadow.kind != RemixBoxShadowKind.outer || shadow.color.a == 0) { + continue; + } + final targetShape = shape.deflate(shadow.shapeInset); + final shadowShape = targetShape + .inflate(shadow.spreadRadius) + .shift(shadow.offset); + if (shadowShape.outerRect.isEmpty) continue; + final paint = BoxShadow( + color: shadow.color, + blurRadius: shadow.blurRadius, + ).toPaint(); + + final blurExtent = ui.Shadow.convertRadiusToSigma(shadow.blurRadius) * 3; + final shadowBounds = shadowShape.outerRect + .inflate(blurExtent) + .expandToInclude(targetShape.outerRect); + // Isolate the knockout so it cannot erase the filtered backdrop. + canvas.saveLayer(shadowBounds, Paint()); + canvas.drawRRect(shadowShape, paint); + canvas.drawRRect(targetShape, Paint()..blendMode = BlendMode.dstOut); + canvas.restore(); + } + } + + void _paintOutline(Canvas canvas, RRect shape) { + if (outline.style == BorderStyle.none || outline.width <= 0) return; + final inner = shape.inflate(outlineOffset); + final outer = shape.inflate(outlineOffset + outline.width); + if (outer.outerRect.isEmpty) return; + + final path = Path() + ..fillType = PathFillType.evenOdd + ..addRRect(outer); + if (!inner.outerRect.isEmpty) path.addRRect(inner); + canvas.drawPath(path, Paint()..color = outline.color); + } + + void _paintInnerLayers(Canvas canvas, Rect rect, RRect shape) { + canvas.save(); + canvas.clipRRect(shape, doAntiAlias: true); + + for (var index = spec.gradients.length - 1; index >= 0; index--) { + final gradient = spec.gradients[index]; + final inset = spec.gradientInsets.isEmpty + ? 0.0 + : spec.gradientInsets[index]; + final gradientShape = inset == 0 ? shape : shape.deflate(inset); + if (gradientShape.outerRect.isEmpty) continue; + final gradientRect = gradientShape.outerRect; + canvas.save(); + canvas.clipRRect(gradientShape, doAntiAlias: true); + canvas.drawRect( + gradientRect, + Paint() + ..shader = gradient.createShader( + gradientRect, + textDirection: textDirection, + ), + ); + canvas.restore(); + } + canvas.restore(); + + for (final shadow in spec.shadows.reversed) { + if (shadow.kind == RemixBoxShadowKind.inset) { + _paintInsetShadow(canvas, shape, shadow); + } + } + } + + void _paintInsetShadow(Canvas canvas, RRect shape, RemixBoxShadow shadow) { + final targetShape = shape.deflate(shadow.shapeInset); + if (targetShape.outerRect.isEmpty) return; + final hole = targetShape.deflate(shadow.spreadRadius).shift(shadow.offset); + if (hole.outerRect.isEmpty) { + canvas.drawRRect(targetShape, Paint()..color = shadow.color); + return; + } + + final blurExtent = shadow.blurRadius * 2 + 1; + final layerBounds = targetShape.outerRect + .expandToInclude(hole.outerRect) + .inflate(blurExtent); + canvas.saveLayer(layerBounds, Paint()); + if (shadow.blurRadius > 0) { + final sigma = ui.Shadow.convertRadiusToSigma(shadow.blurRadius); + canvas.saveLayer( + layerBounds, + Paint() + ..imageFilter = ui.ImageFilter.blur( + sigmaX: sigma, + sigmaY: sigma, + tileMode: TileMode.decal, + ), + ); + } + final shadowPath = Path() + ..fillType = PathFillType.evenOdd + ..addRect(layerBounds) + ..addRRect(hole); + canvas.drawPath(shadowPath, Paint()..color = shadow.color); + if (shadow.blurRadius > 0) canvas.restore(); + final outsideShape = Path() + ..fillType = PathFillType.evenOdd + ..addRect(layerBounds) + ..addRRect(targetShape); + canvas.drawPath( + outsideShape, + Paint() + ..color = const Color(0xFFFFFFFF) + ..blendMode = BlendMode.dstOut, + ); + canvas.restore(); + } + + @override + bool hitTest(Offset position) => false; + + @override + Rect paintBoundsForSize(Size size) => _boxEffectPaintBounds( + spec, + size, + borderRadius: borderRadius, + includeOuterShadows: + phase != _BoxEffectPaintPhase.inner && + phase != _BoxEffectPaintPhase.outline, + includeOutline: + phase == _BoxEffectPaintPhase.all || + phase == _BoxEffectPaintPhase.outline, + outline: outline, + outlineOffset: outlineOffset, + ); + + @override + bool shouldRepaint(_RemixBoxEffectLayerPainter oldDelegate) => + spec != oldDelegate.spec || + borderRadius != oldDelegate.borderRadius || + textDirection != oldDelegate.textDirection || + phase != oldDelegate.phase || + outline != oldDelegate.outline || + outlineOffset != oldDelegate.outlineOffset; +} + +Rect _boxEffectPaintBounds( + RemixBoxEffectLayerSpec spec, + Size size, { + required BorderRadius borderRadius, + required bool includeOuterShadows, + required bool includeOutline, + BorderSide outline = BorderSide.none, + double outlineOffset = 0, +}) { + var bounds = Offset.zero & size; + final shape = borderRadius.toRRect(bounds); + + if (includeOuterShadows) { + for (final shadow in spec.shadows) { + if (shadow.kind != RemixBoxShadowKind.outer || shadow.color.a == 0) { + continue; + } + final shadowShape = shape + .deflate(shadow.shapeInset) + .inflate(shadow.spreadRadius) + .shift(shadow.offset); + if (shadowShape.outerRect.isEmpty) continue; + final blurExtent = ui.Shadow.convertRadiusToSigma(shadow.blurRadius) * 3; + bounds = bounds.expandToInclude( + shadowShape.outerRect.inflate(blurExtent), + ); + } + } + + if (includeOutline && + outline.style != BorderStyle.none && + outline.color.a > 0 && + outline.width > 0) { + final outlineShape = shape.inflate(outlineOffset + outline.width); + if (!outlineShape.outerRect.isEmpty) { + bounds = bounds.expandToInclude(outlineShape.outerRect); + } + } + + return bounds; +} diff --git a/packages/remix/lib/src/rendering/remix_ordered_color_filter.dart b/packages/remix/lib/src/rendering/remix_ordered_color_filter.dart new file mode 100644 index 00000000..e88a3e7d --- /dev/null +++ b/packages/remix/lib/src/rendering/remix_ordered_color_filter.dart @@ -0,0 +1,218 @@ +import 'dart:ui' as ui; + +import 'package:flutter/widgets.dart'; +import 'package:mix/mix.dart'; + +/// CSS color-filter functions used by the pinned Radix recipes. +enum RemixCssColorFilterFunction { brightness, contrast, saturate } + +/// One ordered CSS color-filter function. +/// +/// CSS filter functions operate on non-premultiplied sRGB values. The matrices +/// here are the Filter Effects Level 1 equivalents, expressed in Flutter's +/// 0-255 translation convention. +@immutable +final class RemixCssColorFilterOperation with Equatable { + const RemixCssColorFilterOperation.brightness(this.amount) + : function = .brightness, + assert(amount >= 0); + + const RemixCssColorFilterOperation.contrast(this.amount) + : function = .contrast, + assert(amount >= 0); + + const RemixCssColorFilterOperation.saturate(this.amount) + : function = .saturate, + assert(amount >= 0); + + final RemixCssColorFilterFunction function; + final double amount; + + /// Identity value used when interpolating a missing CSS filter function. + RemixCssColorFilterOperation get identity => switch (function) { + .brightness => const RemixCssColorFilterOperation.brightness(1), + .contrast => const RemixCssColorFilterOperation.contrast(1), + .saturate => const RemixCssColorFilterOperation.saturate(1), + }; + + bool get isIdentity => amount == 1; + + List get matrix => switch (function) { + .brightness => [ + amount, + 0, + 0, + 0, + 0, + 0, + amount, + 0, + 0, + 0, + 0, + 0, + amount, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ], + .contrast => [ + amount, + 0, + 0, + 0, + 127.5 * (1 - amount), + 0, + amount, + 0, + 0, + 127.5 * (1 - amount), + 0, + 0, + amount, + 0, + 127.5 * (1 - amount), + 0, + 0, + 0, + 1, + 0, + ], + .saturate => [ + 0.213 + 0.787 * amount, + 0.715 - 0.715 * amount, + 0.072 - 0.072 * amount, + 0, + 0, + 0.213 - 0.213 * amount, + 0.715 + 0.285 * amount, + 0.072 - 0.072 * amount, + 0, + 0, + 0.213 - 0.213 * amount, + 0.715 - 0.715 * amount, + 0.072 + 0.928 * amount, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ], + }; + + RemixCssColorFilterOperation lerp( + RemixCssColorFilterOperation other, + double t, + ) { + assert(function == other.function); + final value = ui.lerpDouble(amount, other.amount, t)!; + return switch (function) { + .brightness => RemixCssColorFilterOperation.brightness(value), + .contrast => RemixCssColorFilterOperation.contrast(value), + .saturate => RemixCssColorFilterOperation.saturate(value), + }; + } + + @override + List get props => [function, amount]; +} + +/// Applies a CSS-compatible sequence to the complete painted child subtree. +/// +/// Each function gets its own [ColorFiltered] boundary. This preserves CSS +/// function ordering and intermediate clamping instead of collapsing the +/// sequence into one matrix. +final class RemixOrderedColorFilterModifier + extends WidgetModifier { + const RemixOrderedColorFilterModifier(this.operations); + + final List operations; + + @override + RemixOrderedColorFilterModifier copyWith({ + List? operations, + }) => RemixOrderedColorFilterModifier(operations ?? this.operations); + + @override + RemixOrderedColorFilterModifier lerp( + RemixOrderedColorFilterModifier? other, + double t, + ) { + final target = other ?? const RemixOrderedColorFilterModifier([]); + if (!_areCompatible(operations, target.operations)) { + return t < 0.5 ? this : target; + } + + final length = operations.length > target.operations.length + ? operations.length + : target.operations.length; + final result = []; + for (var index = 0; index < length; index++) { + final start = index < operations.length ? operations[index] : null; + final end = index < target.operations.length + ? target.operations[index] + : null; + final function = start ?? end!; + result.add( + (start ?? function.identity).lerp(end ?? function.identity, t), + ); + } + return RemixOrderedColorFilterModifier(result); + } + + @override + Widget build(Widget child) { + var current = child; + for (final operation in operations) { + if (operation.isIdentity) continue; + current = ColorFiltered( + colorFilter: ColorFilter.matrix(operation.matrix), + child: current, + ); + } + return current; + } + + @override + List get props => [operations]; +} + +/// Mix representation for [RemixOrderedColorFilterModifier]. +final class RemixOrderedColorFilterModifierMix + extends ModifierMix { + const RemixOrderedColorFilterModifierMix(this.operations); + + final List operations; + + @override + RemixOrderedColorFilterModifier resolve(BuildContext context) => + RemixOrderedColorFilterModifier(operations); + + @override + RemixOrderedColorFilterModifierMix merge( + RemixOrderedColorFilterModifierMix? other, + ) => other ?? this; + + @override + List get props => [operations]; +} + +bool _areCompatible( + List first, + List second, +) { + if (first.isEmpty || second.isEmpty) return true; + final commonLength = first.length < second.length + ? first.length + : second.length; + for (var index = 0; index < commonLength; index++) { + if (first[index].function != second[index].function) return false; + } + return true; +} diff --git a/packages/remix/lib/src/style/mixins/spinner_style_mixin.dart b/packages/remix/lib/src/style/mixins/spinner_style_mixin.dart index 2a4fe12f..1d8614a9 100644 --- a/packages/remix/lib/src/style/mixins/spinner_style_mixin.dart +++ b/packages/remix/lib/src/style/mixins/spinner_style_mixin.dart @@ -11,12 +11,17 @@ mixin SpinnerStyleMixin> { /// Should merge the provided RemixSpinnerStyler with the component's spinner style T spinner(RemixSpinnerStyler value); - /// Sets spinner indicator color + /// Sets an explicit spinner color instead of inherited current color. + T spinnerColor(Color value) { + return spinner(RemixSpinnerStyler(color: value)); + } + + /// Sets the legacy circular-spinner indicator color. T spinnerIndicatorColor(Color value) { return spinner(RemixSpinnerStyler(indicatorColor: value)); } - /// Sets spinner track color + /// Sets the legacy circular-spinner track color. T spinnerTrackColor(Color value) { return spinner(RemixSpinnerStyler(trackColor: value)); } @@ -26,16 +31,26 @@ mixin SpinnerStyleMixin> { return spinner(RemixSpinnerStyler(size: value)); } - /// Sets spinner stroke width + /// Sets the legacy circular-spinner indicator stroke width. T spinnerStrokeWidth(double value) { return spinner(RemixSpinnerStyler(strokeWidth: value)); } - /// Sets spinner track stroke width + /// Sets the legacy circular-spinner track stroke width. T spinnerTrackStrokeWidth(double value) { return spinner(RemixSpinnerStyler(trackStrokeWidth: value)); } + /// Sets the complete spinner opacity. + T spinnerOpacity(double value) { + return spinner(RemixSpinnerStyler(opacity: value)); + } + + /// Sets each leaf's corner radius. + T spinnerLeafRadius(Radius value) { + return spinner(RemixSpinnerStyler(leafRadius: value)); + } + /// Sets spinner animation duration T spinnerDuration(Duration value) { return spinner(RemixSpinnerStyler(duration: value)); diff --git a/packages/remix/lib/src/utilities/remix_path_icon.dart b/packages/remix/lib/src/utilities/remix_path_icon.dart new file mode 100644 index 00000000..9f19a3ba --- /dev/null +++ b/packages/remix/lib/src/utilities/remix_path_icon.dart @@ -0,0 +1,129 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:mix/mix.dart'; + +/// Exact nine-unit paths shared by Radix-shaped component recipes. +enum RemixPathGlyph { chevronDown, chevronRight, thickCheck } + +/// Paints a resolved icon spec with one of the pinned Radix vector paths. +class RemixPathIcon extends StatelessWidget { + const RemixPathIcon({ + super.key, + required this.glyph, + required this.styleSpec, + this.matchTextDirection = false, + }); + + final RemixPathGlyph glyph; + final StyleSpec styleSpec; + final bool matchTextDirection; + + @override + Widget build(BuildContext context) => StyleSpecBuilder( + styleSpec: styleSpec, + builder: (context, spec) { + final theme = IconTheme.of(context); + var size = spec.size ?? theme.size ?? 9; + if (spec.applyTextScaling == true) { + size = MediaQuery.textScalerOf(context).scale(size); + } + final opacity = (spec.opacity ?? theme.opacity ?? 1).clamp(0.0, 1.0); + final baseColor = spec.color ?? theme.color ?? const Color(0xFF000000); + final color = baseColor.withValues(alpha: baseColor.a * opacity); + return SizedBox.square( + dimension: size, + child: CustomPaint( + painter: _RemixPathIconPainter( + glyph: glyph, + color: color, + shadows: spec.shadows ?? const [], + flipHorizontally: + matchTextDirection && Directionality.of(context) == .rtl, + ), + ), + ); + }, + ); +} + +class _RemixPathIconPainter extends CustomPainter { + const _RemixPathIconPainter({ + required this.glyph, + required this.color, + required this.shadows, + required this.flipHorizontally, + }); + + final RemixPathGlyph glyph; + final Color color; + final List shadows; + final bool flipHorizontally; + + @override + void paint(Canvas canvas, Size size) { + final scale = size.shortestSide / 9; + canvas.save(); + canvas.scale(scale); + if (flipHorizontally) { + canvas.translate(9, 0); + canvas.scale(-1, 1); + } + final path = switch (glyph) { + .chevronDown => _chevronDownPath(), + .chevronRight => _chevronRightPath(), + .thickCheck => _thickCheckPath(), + }; + for (final shadow in shadows) { + canvas.drawPath(path.shift(shadow.offset / scale), shadow.toPaint()); + } + canvas.drawPath(path, Paint()..color = color); + canvas.restore(); + } + + @override + bool shouldRepaint(_RemixPathIconPainter oldDelegate) => + glyph != oldDelegate.glyph || + color != oldDelegate.color || + flipHorizontally != oldDelegate.flipHorizontally || + !listEquals(shadows, oldDelegate.shadows); +} + +Path _chevronDownPath() => Path() + ..moveTo(0.135232, 3.15803) + ..cubicTo(0.324102, 2.95657, 0.640521, 2.94637, 0.841971, 3.13523) + ..lineTo(4.5, 6.56464) + ..lineTo(8.158, 3.13523) + ..cubicTo(8.3595, 2.94637, 8.6759, 2.95657, 8.8648, 3.15803) + ..cubicTo(9.0536, 3.35949, 9.0434, 3.67591, 8.842, 3.86477) + ..lineTo(4.84197, 7.6148) + ..cubicTo(4.64964, 7.7951, 4.35036, 7.7951, 4.15803, 7.6148) + ..lineTo(0.158031, 3.86477) + ..cubicTo(-0.0434285, 3.67591, -0.0536285, 3.35949, 0.135232, 3.15803) + ..close(); + +Path _chevronRightPath() => Path() + ..moveTo(3.23826, 0.201711) + ..cubicTo(3.54108, -0.0809141, 4.01567, -0.0645489, 4.29829, 0.238264) + ..lineTo(7.79829, 3.98826) + ..cubicTo(8.06724, 4.27642, 8.06724, 4.72359, 7.79829, 5.01174) + ..lineTo(4.29829, 8.76174) + ..cubicTo(4.01567, 9.06455, 3.54108, 9.08092, 3.23826, 8.79829) + ..cubicTo(2.93545, 8.51567, 2.91909, 8.04108, 3.20171, 7.73826) + ..lineTo(6.22409, 4.5) + ..lineTo(3.20171, 1.26174) + ..cubicTo(2.91909, 0.958928, 2.93545, 0.484337, 3.23826, 0.201711) + ..close(); + +Path _thickCheckPath() => Path() + ..moveTo(8.53547, 0.62293) + ..cubicTo(8.88226, 0.849446, 8.97976, 1.3142, 8.75325, 1.66099) + ..lineTo(4.5083, 8.1599) + ..cubicTo(4.38833, 8.34356, 4.19397, 8.4655, 3.9764, 8.49358) + ..cubicTo(3.75883, 8.52167, 3.53987, 8.45309, 3.3772, 8.30591) + ..lineTo(0.616113, 5.80777) + ..cubicTo(0.308959, 5.52987, 0.285246, 5.05559, 0.563148, 4.74844) + ..cubicTo(0.84105, 4.44128, 1.31533, 4.41757, 1.62249, 4.69547) + ..lineTo(3.73256, 6.60459) + ..lineTo(7.49741, 0.840706) + ..cubicTo(7.72393, 0.493916, 8.18868, 0.396414, 8.53547, 0.62293) + ..close(); diff --git a/packages/remix/lib/src/utilities/remix_style.dart b/packages/remix/lib/src/utilities/remix_style.dart index 58ca4246..f9a5ae85 100644 --- a/packages/remix/lib/src/utilities/remix_style.dart +++ b/packages/remix/lib/src/utilities/remix_style.dart @@ -1,6 +1,63 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/foundation.dart' show internal; +import 'package:flutter/material.dart'; import 'package:mix/mix.dart' hide AnimationConfig; +/// Applies resolved Remix text and icon specs as inherited defaults for +/// arbitrary component content. +/// +/// Explicit styles on descendants still win, matching the way Radix styles +/// arbitrary button, badge, and callout children through inherited CSS. +@internal +final class RemixDefaultContentStyle extends StatelessWidget { + const RemixDefaultContentStyle({ + super.key, + required this.child, + this.text, + this.icon, + }); + + final Widget child; + final StyleSpec? text; + final StyleSpec? icon; + + @override + Widget build(BuildContext context) { + final textSpec = text?.spec; + final iconSpec = icon?.spec; + + Widget current = child; + if (iconSpec != null) { + current = IconTheme.merge( + data: IconThemeData( + color: iconSpec.color, + opacity: iconSpec.opacity, + size: iconSpec.size, + fill: iconSpec.fill, + weight: iconSpec.weight, + grade: iconSpec.grade, + opticalSize: iconSpec.opticalSize, + shadows: iconSpec.shadows, + applyTextScaling: iconSpec.applyTextScaling, + ), + child: current, + ); + } + if (textSpec != null) { + current = DefaultTextStyle.merge( + style: textSpec.style ?? const TextStyle(), + textAlign: textSpec.textAlign, + softWrap: textSpec.softWrap ?? true, + overflow: textSpec.overflow ?? TextOverflow.clip, + maxLines: textSpec.maxLines, + textWidthBasis: textSpec.textWidthBasis ?? TextWidthBasis.parent, + textHeightBehavior: textSpec.textHeightBehavior, + child: current, + ); + } + return current; + } +} + /// Builds from a raw spec when supplied, otherwise resolves the fluent style. class RemixStyleSpecBuilder> extends StatelessWidget { /// Creates a builder that supports both style and raw spec inputs. diff --git a/packages/remix/pubspec.yaml b/packages/remix/pubspec.yaml index 7d7f3b77..15cc1571 100644 --- a/packages/remix/pubspec.yaml +++ b/packages/remix/pubspec.yaml @@ -21,9 +21,9 @@ environment: dependencies: flutter: sdk: flutter - mix: ^2.1.0 + mix: ^2.2.0-beta.1 mix_annotations: ^2.2.0-beta.1 - naked_ui: ^1.0.0-beta.4 + naked_ui: 1.0.0-beta.7 dev_dependencies: flutter_test: diff --git a/packages/remix/radix_colors.generated.json b/packages/remix/radix_colors.generated.json new file mode 100644 index 00000000..be2b0f3c --- /dev/null +++ b/packages/remix/radix_colors.generated.json @@ -0,0 +1,2216 @@ +{ + "meta": { + "description": "sRGB fallback tokens extracted from the pinned @radix-ui/themes npm artifact", + "radix_themes_version": "3.3.0", + "radix_colors_version": "bundled with Radix Themes 3.3.0", + "source_integrity": "sha512-I0/h2CRNTpYNB7Mi3xFIvSsQq5a108d7kK8dTO5zp5b9HR5QJXKag6B8tjpz2ITkVYkFdkGk45doNkSr7OxwNw==" + }, + "colors": { + "scales": { + "gray": { + "light": { + "solid": { + "1": "#fcfcfc", + "2": "#f9f9f9", + "3": "#f0f0f0", + "4": "#e8e8e8", + "5": "#e0e0e0", + "6": "#d9d9d9", + "7": "#cecece", + "8": "#bbbbbb", + "9": "#8d8d8d", + "10": "#838383", + "11": "#646464", + "12": "#202020" + }, + "alpha": { + "a1": "#00000003", + "a2": "#00000006", + "a3": "#0000000f", + "a4": "#00000017", + "a5": "#0000001f", + "a6": "#00000026", + "a7": "#00000031", + "a8": "#00000044", + "a9": "#00000072", + "a10": "#0000007c", + "a11": "#0000009b", + "a12": "#000000df" + }, + "surface": "#ffffffcc", + "indicator": "#8d8d8d", + "track": "#8d8d8d", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#111111", + "2": "#191919", + "3": "#222222", + "4": "#2a2a2a", + "5": "#313131", + "6": "#3a3a3a", + "7": "#484848", + "8": "#606060", + "9": "#6e6e6e", + "10": "#7b7b7b", + "11": "#b4b4b4", + "12": "#eeeeee" + }, + "alpha": { + "a1": "#00000000", + "a2": "#ffffff09", + "a3": "#ffffff12", + "a4": "#ffffff1b", + "a5": "#ffffff22", + "a6": "#ffffff2c", + "a7": "#ffffff3b", + "a8": "#ffffff55", + "a9": "#ffffff64", + "a10": "#ffffff72", + "a11": "#ffffffaf", + "a12": "#ffffffed" + }, + "surface": "#21212180", + "indicator": "#6e6e6e", + "track": "#6e6e6e", + "contrast": "#ffffff" + } + }, + "mauve": { + "light": { + "solid": { + "1": "#fdfcfd", + "2": "#faf9fb", + "3": "#f2eff3", + "4": "#eae7ec", + "5": "#e3dfe6", + "6": "#dbd8e0", + "7": "#d0cdd7", + "8": "#bcbac7", + "9": "#8e8c99", + "10": "#84828e", + "11": "#65636d", + "12": "#211f26" + }, + "alpha": { + "a1": "#55005503", + "a2": "#2b005506", + "a3": "#30004010", + "a4": "#20003618", + "a5": "#20003820", + "a6": "#14003527", + "a7": "#10003332", + "a8": "#08003145", + "a9": "#05001d73", + "a10": "#0500197d", + "a11": "#0400119c", + "a12": "#020008e0" + }, + "surface": "#ffffffcc", + "indicator": "#8e8c99", + "track": "#8e8c99", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#121113", + "2": "#1a191b", + "3": "#232225", + "4": "#2b292d", + "5": "#323035", + "6": "#3c393f", + "7": "#49474e", + "8": "#625f69", + "9": "#6f6d78", + "10": "#7c7a85", + "11": "#b5b2bc", + "12": "#eeeef0" + }, + "alpha": { + "a1": "#00000000", + "a2": "#f5f4f609", + "a3": "#ebeaf814", + "a4": "#eee5f81d", + "a5": "#efe6fe25", + "a6": "#f1e6fd30", + "a7": "#eee9ff40", + "a8": "#eee7ff5d", + "a9": "#eae6fd6e", + "a10": "#ece9fd7c", + "a11": "#f5f1ffb7", + "a12": "#fdfdffef" + }, + "surface": "#22212380", + "indicator": "#6f6d78", + "track": "#6f6d78", + "contrast": "#ffffff" + } + }, + "slate": { + "light": { + "solid": { + "1": "#fcfcfd", + "2": "#f9f9fb", + "3": "#f0f0f3", + "4": "#e8e8ec", + "5": "#e0e1e6", + "6": "#d9d9e0", + "7": "#cdced6", + "8": "#b9bbc6", + "9": "#8b8d98", + "10": "#80838d", + "11": "#60646c", + "12": "#1c2024" + }, + "alpha": { + "a1": "#00005503", + "a2": "#00005506", + "a3": "#0000330f", + "a4": "#00002d17", + "a5": "#0009321f", + "a6": "#00002f26", + "a7": "#00062e32", + "a8": "#00083046", + "a9": "#00051d74", + "a10": "#00071b7f", + "a11": "#0007149f", + "a12": "#000509e3" + }, + "surface": "#ffffffcc", + "indicator": "#8b8d98", + "track": "#8b8d98", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#111113", + "2": "#18191b", + "3": "#212225", + "4": "#272a2d", + "5": "#2e3135", + "6": "#363a3f", + "7": "#43484e", + "8": "#5a6169", + "9": "#696e77", + "10": "#777b84", + "11": "#b0b4ba", + "12": "#edeef0" + }, + "alpha": { + "a1": "#00000000", + "a2": "#d8f4f609", + "a3": "#ddeaf814", + "a4": "#d3edf81d", + "a5": "#d9edfe25", + "a6": "#d6ebfd30", + "a7": "#d9edff40", + "a8": "#d9edff5d", + "a9": "#dfebfd6d", + "a10": "#e5edfd7b", + "a11": "#f1f7feb5", + "a12": "#fcfdffef" + }, + "surface": "#1f212380", + "indicator": "#696e77", + "track": "#696e77", + "contrast": "#ffffff" + } + }, + "sage": { + "light": { + "solid": { + "1": "#fbfdfc", + "2": "#f7f9f8", + "3": "#eef1f0", + "4": "#e6e9e8", + "5": "#dfe2e0", + "6": "#d7dad9", + "7": "#cbcfcd", + "8": "#b8bcba", + "9": "#868e8b", + "10": "#7c8481", + "11": "#5f6563", + "12": "#1a211e" + }, + "alpha": { + "a1": "#00804004", + "a2": "#00402008", + "a3": "#002d1e11", + "a4": "#001f1519", + "a5": "#00180820", + "a6": "#00140d28", + "a7": "#00140a34", + "a8": "#000f0847", + "a9": "#00110b79", + "a10": "#00100a83", + "a11": "#000a07a0", + "a12": "#000805e5" + }, + "surface": "#ffffffcc", + "indicator": "#868e8b", + "track": "#868e8b", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#101211", + "2": "#171918", + "3": "#202221", + "4": "#272a29", + "5": "#2e3130", + "6": "#373b39", + "7": "#444947", + "8": "#5b625f", + "9": "#63706b", + "10": "#717d79", + "11": "#adb5b2", + "12": "#eceeed" + }, + "alpha": { + "a1": "#00000000", + "a2": "#f0f2f108", + "a3": "#f3f5f412", + "a4": "#f2fefd1a", + "a5": "#f1fbfa22", + "a6": "#edfbf42d", + "a7": "#edfcf73c", + "a8": "#ebfdf657", + "a9": "#dffdf266", + "a10": "#e5fdf674", + "a11": "#f4fefbb0", + "a12": "#fdfffeed" + }, + "surface": "#1e201f80", + "indicator": "#63706b", + "track": "#63706b", + "contrast": "#ffffff" + } + }, + "olive": { + "light": { + "solid": { + "1": "#fcfdfc", + "2": "#f8faf8", + "3": "#eff1ef", + "4": "#e7e9e7", + "5": "#dfe2df", + "6": "#d7dad7", + "7": "#cccfcc", + "8": "#b9bcb8", + "9": "#898e87", + "10": "#7f847d", + "11": "#60655f", + "12": "#1d211c" + }, + "alpha": { + "a1": "#00550003", + "a2": "#00490007", + "a3": "#00200010", + "a4": "#00160018", + "a5": "#00180020", + "a6": "#00140028", + "a7": "#000f0033", + "a8": "#040f0047", + "a9": "#050f0078", + "a10": "#040e0082", + "a11": "#020a00a0", + "a12": "#010600e3" + }, + "surface": "#ffffffcc", + "indicator": "#898e87", + "track": "#898e87", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#111210", + "2": "#181917", + "3": "#212220", + "4": "#282a27", + "5": "#2f312e", + "6": "#383a36", + "7": "#454843", + "8": "#5c625b", + "9": "#687066", + "10": "#767d74", + "11": "#afb5ad", + "12": "#eceeec" + }, + "alpha": { + "a1": "#00000000", + "a2": "#f1f2f008", + "a3": "#f4f5f312", + "a4": "#f3fef21a", + "a5": "#f2fbf122", + "a6": "#f4faed2c", + "a7": "#f2fced3b", + "a8": "#edfdeb57", + "a9": "#ebfde766", + "a10": "#f0fdec74", + "a11": "#f6fef4b0", + "a12": "#fdfffded" + }, + "surface": "#1f201e80", + "indicator": "#687066", + "track": "#687066", + "contrast": "#ffffff" + } + }, + "sand": { + "light": { + "solid": { + "1": "#fdfdfc", + "2": "#f9f9f8", + "3": "#f1f0ef", + "4": "#e9e8e6", + "5": "#e2e1de", + "6": "#dad9d6", + "7": "#cfceca", + "8": "#bcbbb5", + "9": "#8d8d86", + "10": "#82827c", + "11": "#63635e", + "12": "#21201c" + }, + "alpha": { + "a1": "#55550003", + "a2": "#25250007", + "a3": "#20100010", + "a4": "#1f150019", + "a5": "#1f180021", + "a6": "#19130029", + "a7": "#19140035", + "a8": "#1915014a", + "a9": "#0f0f0079", + "a10": "#0c0c0083", + "a11": "#080800a1", + "a12": "#060500e3" + }, + "surface": "#ffffffcc", + "indicator": "#8d8d86", + "track": "#8d8d86", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#111110", + "2": "#191918", + "3": "#222221", + "4": "#2a2a28", + "5": "#31312e", + "6": "#3b3a37", + "7": "#494844", + "8": "#62605b", + "9": "#6f6d66", + "10": "#7c7b74", + "11": "#b5b3ad", + "12": "#eeeeec" + }, + "alpha": { + "a1": "#00000000", + "a2": "#f4f4f309", + "a3": "#f6f6f513", + "a4": "#fefef31b", + "a5": "#fbfbeb23", + "a6": "#fffaed2d", + "a7": "#fffbed3c", + "a8": "#fff9eb57", + "a9": "#fffae965", + "a10": "#fffdee73", + "a11": "#fffcf4b0", + "a12": "#fffffded" + }, + "surface": "#21212080", + "indicator": "#6f6d66", + "track": "#6f6d66", + "contrast": "#ffffff" + } + }, + "tomato": { + "light": { + "solid": { + "1": "#fffcfc", + "2": "#fff8f7", + "3": "#feebe7", + "4": "#ffdcd3", + "5": "#ffcdc2", + "6": "#fdbdaf", + "7": "#f5a898", + "8": "#ec8e7b", + "9": "#e54d2e", + "10": "#dd4425", + "11": "#d13415", + "12": "#5c271f" + }, + "alpha": { + "a1": "#ff000003", + "a2": "#ff200008", + "a3": "#f52b0018", + "a4": "#ff35002c", + "a5": "#ff2e003d", + "a6": "#f92d0050", + "a7": "#e7280067", + "a8": "#db250084", + "a9": "#df2600d1", + "a10": "#d72400da", + "a11": "#cd2200ea", + "a12": "#460900e0" + }, + "surface": "#fff6f5cc", + "indicator": "#e54d2e", + "track": "#e54d2e", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#181111", + "2": "#1f1513", + "3": "#391714", + "4": "#4e1511", + "5": "#5e1c16", + "6": "#6e2920", + "7": "#853a2d", + "8": "#ac4d39", + "9": "#e54d2e", + "10": "#ec6142", + "11": "#ff977d", + "12": "#fbd3cb" + }, + "alpha": { + "a1": "#f1121208", + "a2": "#ff55330f", + "a3": "#ff35232b", + "a4": "#fd201142", + "a5": "#fe332153", + "a6": "#ff4f3864", + "a7": "#fd644a7d", + "a8": "#fe6d4ea7", + "a9": "#fe5431e4", + "a10": "#ff6847eb", + "a11": "#ff977d", + "a12": "#ffd6cefb" + }, + "surface": "#2d191580", + "indicator": "#e54d2e", + "track": "#e54d2e", + "contrast": "#ffffff" + } + }, + "red": { + "light": { + "solid": { + "1": "#fffcfc", + "2": "#fff7f7", + "3": "#feebec", + "4": "#ffdbdc", + "5": "#ffcdce", + "6": "#fdbdbe", + "7": "#f4a9aa", + "8": "#eb8e90", + "9": "#e5484d", + "10": "#dc3e42", + "11": "#ce2c31", + "12": "#641723" + }, + "alpha": { + "a1": "#ff000003", + "a2": "#ff000008", + "a3": "#f3000d14", + "a4": "#ff000824", + "a5": "#ff000632", + "a6": "#f8000442", + "a7": "#df000356", + "a8": "#d2000571", + "a9": "#db0007b7", + "a10": "#d10005c1", + "a11": "#c40006d3", + "a12": "#55000de8" + }, + "surface": "#fff5f5cc", + "indicator": "#e5484d", + "track": "#e5484d", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#191111", + "2": "#201314", + "3": "#3b1219", + "4": "#500f1c", + "5": "#611623", + "6": "#72232d", + "7": "#8c333a", + "8": "#b54548", + "9": "#e5484d", + "10": "#ec5d5e", + "11": "#ff9592", + "12": "#ffd1d9" + }, + "alpha": { + "a1": "#f4121209", + "a2": "#f22f3e11", + "a3": "#ff173f2d", + "a4": "#fe0a3b44", + "a5": "#ff204756", + "a6": "#ff3e5668", + "a7": "#ff536184", + "a8": "#ff5d61b0", + "a9": "#fe4e54e4", + "a10": "#ff6465eb", + "a11": "#ff9592", + "a12": "#ffd1d9" + }, + "surface": "#2f151780", + "indicator": "#e5484d", + "track": "#e5484d", + "contrast": "#ffffff" + } + }, + "ruby": { + "light": { + "solid": { + "1": "#fffcfd", + "2": "#fff7f8", + "3": "#feeaed", + "4": "#ffdce1", + "5": "#ffced6", + "6": "#f8bfc8", + "7": "#efacb8", + "8": "#e592a3", + "9": "#e54666", + "10": "#dc3b5d", + "11": "#ca244d", + "12": "#64172b" + }, + "alpha": { + "a1": "#ff005503", + "a2": "#ff002008", + "a3": "#f3002515", + "a4": "#ff002523", + "a5": "#ff002a31", + "a6": "#e4002440", + "a7": "#ce002553", + "a8": "#c300286d", + "a9": "#db002cb9", + "a10": "#d2002cc4", + "a11": "#c10030db", + "a12": "#550016e8" + }, + "surface": "#fff5f6cc", + "indicator": "#e54666", + "track": "#e54666", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#191113", + "2": "#1e1517", + "3": "#3a141e", + "4": "#4e1325", + "5": "#5e1a2e", + "6": "#6f2539", + "7": "#883447", + "8": "#b3445a", + "9": "#e54666", + "10": "#ec5a72", + "11": "#ff949d", + "12": "#fed2e1" + }, + "alpha": { + "a1": "#f4124a09", + "a2": "#fe5a7f0e", + "a3": "#ff235d2c", + "a4": "#fd195e42", + "a5": "#fe2d6b53", + "a6": "#ff447665", + "a7": "#ff577d80", + "a8": "#ff5c7cae", + "a9": "#fe4c70e4", + "a10": "#ff617beb", + "a11": "#ff949d", + "a12": "#ffd3e2fe" + }, + "surface": "#2b191d80", + "indicator": "#e54666", + "track": "#e54666", + "contrast": "#ffffff" + } + }, + "crimson": { + "light": { + "solid": { + "1": "#fffcfd", + "2": "#fef7f9", + "3": "#ffe9f0", + "4": "#fedce7", + "5": "#facedd", + "6": "#f3bed1", + "7": "#eaacc3", + "8": "#e093b2", + "9": "#e93d82", + "10": "#df3478", + "11": "#cb1d63", + "12": "#621639" + }, + "alpha": { + "a1": "#ff005503", + "a2": "#e0004008", + "a3": "#ff005216", + "a4": "#f8005123", + "a5": "#e5004f31", + "a6": "#d0004b41", + "a7": "#bf004753", + "a8": "#b6004a6c", + "a9": "#e2005bc2", + "a10": "#d70056cb", + "a11": "#c4004fe2", + "a12": "#530026e9" + }, + "surface": "#fef5f8cc", + "indicator": "#e93d82", + "track": "#e93d82", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#191114", + "2": "#201318", + "3": "#381525", + "4": "#4d122f", + "5": "#5c1839", + "6": "#6d2545", + "7": "#873356", + "8": "#b0436e", + "9": "#e93d82", + "10": "#ee518a", + "11": "#ff92ad", + "12": "#fdd3e8" + }, + "alpha": { + "a1": "#f4126709", + "a2": "#f22f7a11", + "a3": "#fe2a8b2a", + "a4": "#fd158741", + "a5": "#fd278f51", + "a6": "#fe459763", + "a7": "#fd559b7f", + "a8": "#fe5b9bab", + "a9": "#fe418de8", + "a10": "#ff5693ed", + "a11": "#ff92ad", + "a12": "#ffd5eafd" + }, + "surface": "#2f151f80", + "indicator": "#e93d82", + "track": "#e93d82", + "contrast": "#ffffff" + } + }, + "pink": { + "light": { + "solid": { + "1": "#fffcfe", + "2": "#fef7fb", + "3": "#fee9f5", + "4": "#fbdcef", + "5": "#f6cee7", + "6": "#efbfdd", + "7": "#e7acd0", + "8": "#dd93c2", + "9": "#d6409f", + "10": "#cf3897", + "11": "#c2298a", + "12": "#651249" + }, + "alpha": { + "a1": "#ff00aa03", + "a2": "#e0008008", + "a3": "#f4008c16", + "a4": "#e2008b23", + "a5": "#d1008331", + "a6": "#c0007840", + "a7": "#b6006f53", + "a8": "#af006f6c", + "a9": "#c8007fbf", + "a10": "#c2007ac7", + "a11": "#b60074d6", + "a12": "#59003bed" + }, + "surface": "#fef5facc", + "indicator": "#d6409f", + "track": "#d6409f", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#191117", + "2": "#21121d", + "3": "#37172f", + "4": "#4b143d", + "5": "#591c47", + "6": "#692955", + "7": "#833869", + "8": "#a84885", + "9": "#d6409f", + "10": "#de51a8", + "11": "#ff8dcc", + "12": "#fdd1ea" + }, + "alpha": { + "a1": "#f412bc09", + "a2": "#f420bb12", + "a3": "#fe37cc29", + "a4": "#fc1ec43f", + "a5": "#fd35c24e", + "a6": "#fd51c75f", + "a7": "#fd62c87b", + "a8": "#ff68c8a2", + "a9": "#fe49bcd4", + "a10": "#ff5cc0dc", + "a11": "#ff8dcc", + "a12": "#ffd3ecfd" + }, + "surface": "#31132980", + "indicator": "#d6409f", + "track": "#d6409f", + "contrast": "#ffffff" + } + }, + "plum": { + "light": { + "solid": { + "1": "#fefcff", + "2": "#fdf7fd", + "3": "#fbebfb", + "4": "#f7def8", + "5": "#f2d1f3", + "6": "#e9c2ec", + "7": "#deade3", + "8": "#cf91d8", + "9": "#ab4aba", + "10": "#a144af", + "11": "#953ea3", + "12": "#53195d" + }, + "alpha": { + "a1": "#aa00ff03", + "a2": "#c000c008", + "a3": "#cc00cc14", + "a4": "#c200c921", + "a5": "#b700bd2e", + "a6": "#a400b03d", + "a7": "#9900a852", + "a8": "#9000a56e", + "a9": "#89009eb5", + "a10": "#7f0092bb", + "a11": "#730086c1", + "a12": "#40004be6" + }, + "surface": "#fdf5fdcc", + "indicator": "#ab4aba", + "track": "#ab4aba", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#181118", + "2": "#201320", + "3": "#351a35", + "4": "#451d47", + "5": "#512454", + "6": "#5e3061", + "7": "#734079", + "8": "#92549c", + "9": "#ab4aba", + "10": "#b658c4", + "11": "#e796f3", + "12": "#f4d4f4" + }, + "alpha": { + "a1": "#f112f108", + "a2": "#f22ff211", + "a3": "#fd4cfd27", + "a4": "#f646ff3a", + "a5": "#f455ff48", + "a6": "#f66dff56", + "a7": "#f07cfd70", + "a8": "#ee84ff95", + "a9": "#e961feb6", + "a10": "#ed70ffc0", + "a11": "#f19cfef3", + "a12": "#feddfef4" + }, + "surface": "#2f152f80", + "indicator": "#ab4aba", + "track": "#ab4aba", + "contrast": "#ffffff" + } + }, + "purple": { + "light": { + "solid": { + "1": "#fefcfe", + "2": "#fbf7fe", + "3": "#f7edfe", + "4": "#f2e2fc", + "5": "#ead5f9", + "6": "#e0c4f4", + "7": "#d1afec", + "8": "#be93e4", + "9": "#8e4ec6", + "10": "#8347b9", + "11": "#8145b5", + "12": "#402060" + }, + "alpha": { + "a1": "#aa00aa03", + "a2": "#8000e008", + "a3": "#8e00f112", + "a4": "#8d00e51d", + "a5": "#8000db2a", + "a6": "#7a01d03b", + "a7": "#6d00c350", + "a8": "#6600c06c", + "a9": "#5c00adb1", + "a10": "#53009eb8", + "a11": "#52009aba", + "a12": "#250049df" + }, + "surface": "#faf5fecc", + "indicator": "#8e4ec6", + "track": "#8e4ec6", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#18111b", + "2": "#1e1523", + "3": "#301c3b", + "4": "#3d224e", + "5": "#48295c", + "6": "#54346b", + "7": "#664282", + "8": "#8457aa", + "9": "#8e4ec6", + "10": "#9a5cd0", + "11": "#d19dff", + "12": "#ecd9fa" + }, + "alpha": { + "a1": "#b412f90b", + "a2": "#b744f714", + "a3": "#c150ff2d", + "a4": "#bb53fd42", + "a5": "#be5cfd51", + "a6": "#c16dfd61", + "a7": "#c378fd7a", + "a8": "#c47effa4", + "a9": "#b661ffc2", + "a10": "#bc6fffcd", + "a11": "#d19dff", + "a12": "#f1ddfffa" + }, + "surface": "#2b173580", + "indicator": "#8e4ec6", + "track": "#8e4ec6", + "contrast": "#ffffff" + } + }, + "violet": { + "light": { + "solid": { + "1": "#fdfcfe", + "2": "#faf8ff", + "3": "#f4f0fe", + "4": "#ebe4ff", + "5": "#e1d9ff", + "6": "#d4cafe", + "7": "#c2b5f5", + "8": "#aa99ec", + "9": "#6e56cf", + "10": "#654dc4", + "11": "#6550b9", + "12": "#2f265f" + }, + "alpha": { + "a1": "#5500aa03", + "a2": "#4900ff07", + "a3": "#4400ee0f", + "a4": "#4300ff1b", + "a5": "#3600ff26", + "a6": "#3100fb35", + "a7": "#2d01dd4a", + "a8": "#2b00d066", + "a9": "#2400b7a9", + "a10": "#2300abb2", + "a11": "#1f0099af", + "a12": "#0b0043d9" + }, + "surface": "#f9f6ffcc", + "indicator": "#6e56cf", + "track": "#6e56cf", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#14121f", + "2": "#1b1525", + "3": "#291f43", + "4": "#33255b", + "5": "#3c2e69", + "6": "#473876", + "7": "#56468b", + "8": "#6958ad", + "9": "#6e56cf", + "10": "#7d66d9", + "11": "#baa7ff", + "12": "#e2ddfe" + }, + "alpha": { + "a1": "#4422ff0f", + "a2": "#853ff916", + "a3": "#8354fe36", + "a4": "#7d51fd50", + "a5": "#845ffd5f", + "a6": "#8f6cfd6d", + "a7": "#9879ff83", + "a8": "#977dfea8", + "a9": "#8668ffcc", + "a10": "#9176fed7", + "a11": "#baa7ff", + "a12": "#e3defffe" + }, + "surface": "#25193980", + "indicator": "#6e56cf", + "track": "#6e56cf", + "contrast": "#ffffff" + } + }, + "iris": { + "light": { + "solid": { + "1": "#fdfdff", + "2": "#f8f8ff", + "3": "#f0f1fe", + "4": "#e6e7ff", + "5": "#dadcff", + "6": "#cbcdff", + "7": "#b8baf8", + "8": "#9b9ef0", + "9": "#5b5bd6", + "10": "#5151cd", + "11": "#5753c6", + "12": "#272962" + }, + "alpha": { + "a1": "#0000ff02", + "a2": "#0000ff07", + "a3": "#0011ee0f", + "a4": "#000bff19", + "a5": "#000eff25", + "a6": "#000aff34", + "a7": "#0008e647", + "a8": "#0008d964", + "a9": "#0000c0a4", + "a10": "#0000b6ae", + "a11": "#0600abac", + "a12": "#000246d8" + }, + "surface": "#f6f6ffcc", + "indicator": "#5b5bd6", + "track": "#5b5bd6", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#13131e", + "2": "#171625", + "3": "#202248", + "4": "#262a65", + "5": "#303374", + "6": "#3d3e82", + "7": "#4a4a95", + "8": "#5958b1", + "9": "#5b5bd6", + "10": "#6e6ade", + "11": "#b1a9ff", + "12": "#e0dffe" + }, + "alpha": { + "a1": "#3636fe0e", + "a2": "#564bf916", + "a3": "#525bff3b", + "a4": "#4d58ff5a", + "a5": "#5b62fd6b", + "a6": "#6d6ffd7a", + "a7": "#7777fe8e", + "a8": "#7b7afeac", + "a9": "#6a6afed4", + "a10": "#7d79ffdc", + "a11": "#b1a9ff", + "a12": "#e1e0fffe" + }, + "surface": "#1d1b3980", + "indicator": "#5b5bd6", + "track": "#5b5bd6", + "contrast": "#ffffff" + } + }, + "indigo": { + "light": { + "solid": { + "1": "#fdfdfe", + "2": "#f7f9ff", + "3": "#edf2fe", + "4": "#e1e9ff", + "5": "#d2deff", + "6": "#c1d0ff", + "7": "#abbdf9", + "8": "#8da4ef", + "9": "#3e63dd", + "10": "#3358d4", + "11": "#3a5bc7", + "12": "#1f2d5c" + }, + "alpha": { + "a1": "#00008002", + "a2": "#0040ff08", + "a3": "#0047f112", + "a4": "#0044ff1e", + "a5": "#0044ff2d", + "a6": "#003eff3e", + "a7": "#0037ed54", + "a8": "#0034dc72", + "a9": "#0031d2c1", + "a10": "#002ec9cc", + "a11": "#002bb7c5", + "a12": "#001046e0" + }, + "surface": "#f5f8ffcc", + "indicator": "#3e63dd", + "track": "#3e63dd", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#11131f", + "2": "#141726", + "3": "#182449", + "4": "#1d2e62", + "5": "#253974", + "6": "#304384", + "7": "#3a4f97", + "8": "#435db1", + "9": "#3e63dd", + "10": "#5472e4", + "11": "#9eb1ff", + "12": "#d6e1ff" + }, + "alpha": { + "a1": "#1133ff0f", + "a2": "#3354fa17", + "a3": "#2f62ff3c", + "a4": "#3566ff57", + "a5": "#4171fd6b", + "a6": "#5178fd7c", + "a7": "#5a7fff90", + "a8": "#5b81feac", + "a9": "#4671ffdb", + "a10": "#5c7efee3", + "a11": "#9eb1ff", + "a12": "#d6e1ff" + }, + "surface": "#171d3b80", + "indicator": "#3e63dd", + "track": "#3e63dd", + "contrast": "#ffffff" + } + }, + "blue": { + "light": { + "solid": { + "1": "#fbfdff", + "2": "#f4faff", + "3": "#e6f4fe", + "4": "#d5efff", + "5": "#c2e5ff", + "6": "#acd8fc", + "7": "#8ec8f6", + "8": "#5eb1ef", + "9": "#0090ff", + "10": "#0588f0", + "11": "#0d74ce", + "12": "#113264" + }, + "alpha": { + "a1": "#0080ff04", + "a2": "#008cff0b", + "a3": "#008ff519", + "a4": "#009eff2a", + "a5": "#0093ff3d", + "a6": "#0088f653", + "a7": "#0083eb71", + "a8": "#0084e6a1", + "a9": "#0090ff", + "a10": "#0086f0fa", + "a11": "#006dcbf2", + "a12": "#002359ee" + }, + "surface": "#f1f9ffcc", + "indicator": "#0090ff", + "track": "#0090ff", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#0d1520", + "2": "#111927", + "3": "#0d2847", + "4": "#003362", + "5": "#004074", + "6": "#104d87", + "7": "#205d9e", + "8": "#2870bd", + "9": "#0090ff", + "10": "#3b9eff", + "11": "#70b8ff", + "12": "#c2e6ff" + }, + "alpha": { + "a1": "#004df211", + "a2": "#1166fb18", + "a3": "#0077ff3a", + "a4": "#0075ff57", + "a5": "#0081fd6b", + "a6": "#0f89fd7f", + "a7": "#2a91fe98", + "a8": "#3094feb9", + "a9": "#0090ff", + "a10": "#3b9eff", + "a11": "#70b8ff", + "a12": "#c2e6ff" + }, + "surface": "#11213d80", + "indicator": "#0090ff", + "track": "#0090ff", + "contrast": "#ffffff" + } + }, + "cyan": { + "light": { + "solid": { + "1": "#fafdfe", + "2": "#f2fafb", + "3": "#def7f9", + "4": "#caf1f6", + "5": "#b5e9f0", + "6": "#9ddde7", + "7": "#7dcedc", + "8": "#3db9cf", + "9": "#00a2c7", + "10": "#0797b9", + "11": "#107d98", + "12": "#0d3c48" + }, + "alpha": { + "a1": "#0099cc05", + "a2": "#009db10d", + "a3": "#00c2d121", + "a4": "#00bcd435", + "a5": "#01b4cc4a", + "a6": "#00a7c162", + "a7": "#009fbb82", + "a8": "#00a3c0c2", + "a9": "#00a2c7", + "a10": "#0094b7f8", + "a11": "#007491ef", + "a12": "#00323ef2" + }, + "surface": "#eff9facc", + "indicator": "#00a2c7", + "track": "#00a2c7", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#0b161a", + "2": "#101b20", + "3": "#082c36", + "4": "#003848", + "5": "#004558", + "6": "#045468", + "7": "#12677e", + "8": "#11809c", + "9": "#00a2c7", + "10": "#23afd0", + "11": "#4ccce6", + "12": "#b6ecf7" + }, + "alpha": { + "a1": "#0091f70a", + "a2": "#02a7f211", + "a3": "#00befd28", + "a4": "#00baff3b", + "a5": "#00befd4d", + "a6": "#00c7fd5e", + "a7": "#14cdff75", + "a8": "#11cfff95", + "a9": "#00cfffc3", + "a10": "#28d6ffcd", + "a11": "#52e1fee5", + "a12": "#bbf3fef7" + }, + "surface": "#11252d80", + "indicator": "#00a2c7", + "track": "#00a2c7", + "contrast": "#ffffff" + } + }, + "teal": { + "light": { + "solid": { + "1": "#fafefd", + "2": "#f3fbf9", + "3": "#e0f8f3", + "4": "#ccf3ea", + "5": "#b8eae0", + "6": "#a1ded2", + "7": "#83cdc1", + "8": "#53b9ab", + "9": "#12a594", + "10": "#0d9b8a", + "11": "#008573", + "12": "#0d3d38" + }, + "alpha": { + "a1": "#00cc9905", + "a2": "#00aa800c", + "a3": "#00c69d1f", + "a4": "#00c39633", + "a5": "#00b49047", + "a6": "#00a6855e", + "a7": "#0099807c", + "a8": "#009783ac", + "a9": "#009e8ced", + "a10": "#009684f2", + "a11": "#008573", + "a12": "#00332df2" + }, + "surface": "#f0faf8cc", + "indicator": "#12a594", + "track": "#12a594", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#0d1514", + "2": "#111c1b", + "3": "#0d2d2a", + "4": "#023b37", + "5": "#084843", + "6": "#145750", + "7": "#1c6961", + "8": "#207e73", + "9": "#12a594", + "10": "#0eb39e", + "11": "#0bd8b6", + "12": "#adf0dd" + }, + "alpha": { + "a1": "#00deab05", + "a2": "#12fbe60c", + "a3": "#00ffe61e", + "a4": "#00ffe92d", + "a5": "#00ffea3b", + "a6": "#1cffe84b", + "a7": "#2efde85f", + "a8": "#32ffe775", + "a9": "#13ffe49f", + "a10": "#0dffe0ae", + "a11": "#0afed5d6", + "a12": "#b8ffebef" + }, + "surface": "#13272580", + "indicator": "#12a594", + "track": "#12a594", + "contrast": "#ffffff" + } + }, + "jade": { + "light": { + "solid": { + "1": "#fbfefd", + "2": "#f4fbf7", + "3": "#e6f7ed", + "4": "#d6f1e3", + "5": "#c3e9d7", + "6": "#acdec8", + "7": "#8bceb6", + "8": "#56ba9f", + "9": "#29a383", + "10": "#26997b", + "11": "#208368", + "12": "#1d3b31" + }, + "alpha": { + "a1": "#00c08004", + "a2": "#00a3460b", + "a3": "#00ae4819", + "a4": "#00a85129", + "a5": "#00a2553c", + "a6": "#009a5753", + "a7": "#00945f74", + "a8": "#00976ea9", + "a9": "#00916bd6", + "a10": "#008764d9", + "a11": "#007152df", + "a12": "#002217e2" + }, + "surface": "#f1faf5cc", + "indicator": "#29a383", + "track": "#29a383", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#0d1512", + "2": "#121c18", + "3": "#0f2e22", + "4": "#0b3b2c", + "5": "#114837", + "6": "#1b5745", + "7": "#246854", + "8": "#2a7e68", + "9": "#29a383", + "10": "#27b08b", + "11": "#1fd8a4", + "12": "#adf0d4" + }, + "alpha": { + "a1": "#00de4505", + "a2": "#27fba60c", + "a3": "#02f99920", + "a4": "#00ffaa2d", + "a5": "#11ffb63b", + "a6": "#34ffc24b", + "a7": "#45fdc75e", + "a8": "#48ffcf75", + "a9": "#38feca9d", + "a10": "#31fec7ab", + "a11": "#21fec0d6", + "a12": "#b8ffe1ef" + }, + "surface": "#13271f80", + "indicator": "#29a383", + "track": "#29a383", + "contrast": "#ffffff" + } + }, + "green": { + "light": { + "solid": { + "1": "#fbfefc", + "2": "#f4fbf6", + "3": "#e6f6eb", + "4": "#d6f1df", + "5": "#c4e8d1", + "6": "#adddc0", + "7": "#8eceaa", + "8": "#5bb98b", + "9": "#30a46c", + "10": "#2b9a66", + "11": "#218358", + "12": "#193b2d" + }, + "alpha": { + "a1": "#00c04004", + "a2": "#00a32f0b", + "a3": "#00a43319", + "a4": "#00a83829", + "a5": "#019c393b", + "a6": "#00963c52", + "a7": "#00914071", + "a8": "#00924ba4", + "a9": "#008f4acf", + "a10": "#008647d4", + "a11": "#00713fde", + "a12": "#002616e6" + }, + "surface": "#f1faf4cc", + "indicator": "#30a46c", + "track": "#30a46c", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#0e1512", + "2": "#121b17", + "3": "#132d21", + "4": "#113b29", + "5": "#174933", + "6": "#20573e", + "7": "#28684a", + "8": "#2f7c57", + "9": "#30a46c", + "10": "#33b074", + "11": "#3dd68c", + "12": "#b1f1cb" + }, + "alpha": { + "a1": "#00de4505", + "a2": "#29f99d0b", + "a3": "#22ff991e", + "a4": "#11ff992d", + "a5": "#2bffa23c", + "a6": "#44ffaa4b", + "a7": "#50fdac5e", + "a8": "#54ffad73", + "a9": "#44ffa49e", + "a10": "#43fea4ab", + "a11": "#46fea5d4", + "a12": "#bbffd7f0" + }, + "surface": "#15251d80", + "indicator": "#30a46c", + "track": "#30a46c", + "contrast": "#ffffff" + } + }, + "grass": { + "light": { + "solid": { + "1": "#fbfefb", + "2": "#f5fbf5", + "3": "#e9f6e9", + "4": "#daf1db", + "5": "#c9e8ca", + "6": "#b2ddb5", + "7": "#94ce9a", + "8": "#65ba74", + "9": "#46a758", + "10": "#3e9b4f", + "11": "#2a7e3b", + "12": "#203c25" + }, + "alpha": { + "a1": "#00c00004", + "a2": "#0099000a", + "a3": "#00970016", + "a4": "#009f0725", + "a5": "#00930536", + "a6": "#008f0a4d", + "a7": "#018b0f6b", + "a8": "#008d199a", + "a9": "#008619b9", + "a10": "#007b17c1", + "a11": "#006514d5", + "a12": "#002006df" + }, + "surface": "#f3faf3cc", + "indicator": "#46a758", + "track": "#46a758", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#0e1511", + "2": "#141a15", + "3": "#1b2a1e", + "4": "#1d3a24", + "5": "#25482d", + "6": "#2d5736", + "7": "#366740", + "8": "#3e7949", + "9": "#46a758", + "10": "#53b365", + "11": "#71d083", + "12": "#c2f0c2" + }, + "alpha": { + "a1": "#00de1205", + "a2": "#5ef7780a", + "a3": "#70fe8c1b", + "a4": "#57ff802c", + "a5": "#68ff8b3b", + "a6": "#71ff8f4b", + "a7": "#77fd925d", + "a8": "#77fd9070", + "a9": "#65ff82a1", + "a10": "#72ff8dae", + "a11": "#89ff9fcd", + "a12": "#ceffceef" + }, + "surface": "#19231b80", + "indicator": "#46a758", + "track": "#46a758", + "contrast": "#ffffff" + } + }, + "bronze": { + "light": { + "solid": { + "1": "#fdfcfc", + "2": "#fdf7f5", + "3": "#f6edea", + "4": "#efe4df", + "5": "#e7d9d3", + "6": "#dfcdc5", + "7": "#d3bcb3", + "8": "#c2a499", + "9": "#a18072", + "10": "#957468", + "11": "#7d5e54", + "12": "#43302b" + }, + "alpha": { + "a1": "#55000003", + "a2": "#cc33000a", + "a3": "#92250015", + "a4": "#80280020", + "a5": "#7423002c", + "a6": "#7324003a", + "a7": "#6c1f004c", + "a8": "#671c0066", + "a9": "#551a008d", + "a10": "#4c150097", + "a11": "#3d0f00ab", + "a12": "#1d0600d4" + }, + "surface": "#fdf5f3cc", + "indicator": "#a18072", + "track": "#a18072", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#141110", + "2": "#1c1917", + "3": "#262220", + "4": "#302a27", + "5": "#3b3330", + "6": "#493e3a", + "7": "#5a4c47", + "8": "#6f5f58", + "9": "#a18072", + "10": "#ae8c7e", + "11": "#d4b3a5", + "12": "#ede0d9" + }, + "alpha": { + "a1": "#d1110004", + "a2": "#fbbc910c", + "a3": "#faceb817", + "a4": "#facdb622", + "a5": "#ffd2c12d", + "a6": "#ffd1c03c", + "a7": "#fdd0c04f", + "a8": "#ffd6c565", + "a9": "#fec7b09b", + "a10": "#fecab5a9", + "a11": "#ffd7c6d1", + "a12": "#fff1e9ec" + }, + "surface": "#27211d80", + "indicator": "#a18072", + "track": "#a18072", + "contrast": "#ffffff" + } + }, + "gold": { + "light": { + "solid": { + "1": "#fdfdfc", + "2": "#faf9f2", + "3": "#f2f0e7", + "4": "#eae6db", + "5": "#e1dccf", + "6": "#d8d0bf", + "7": "#cbc0aa", + "8": "#b9a88d", + "9": "#978365", + "10": "#8c7a5e", + "11": "#71624b", + "12": "#3b352b" + }, + "alpha": { + "a1": "#55550003", + "a2": "#9d8a000d", + "a3": "#75600018", + "a4": "#6b4e0024", + "a5": "#60460030", + "a6": "#64440040", + "a7": "#63420055", + "a8": "#633d0072", + "a9": "#5332009a", + "a10": "#492d00a1", + "a11": "#362100b4", + "a12": "#130c00d4" + }, + "surface": "#f9f8efcc", + "indicator": "#978365", + "track": "#978365", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#121211", + "2": "#1b1a17", + "3": "#24231f", + "4": "#2d2b26", + "5": "#38352e", + "6": "#444039", + "7": "#544f46", + "8": "#696256", + "9": "#978365", + "10": "#a39073", + "11": "#cbb99f", + "12": "#e8e2d9" + }, + "alpha": { + "a1": "#91911102", + "a2": "#f9e29d0b", + "a3": "#f8ecbb15", + "a4": "#ffeec41e", + "a5": "#feecc22a", + "a6": "#feebcb37", + "a7": "#ffedcd48", + "a8": "#fdeaca5f", + "a9": "#ffdba690", + "a10": "#fedfb09d", + "a11": "#fee7c6c8", + "a12": "#fef7ede7" + }, + "surface": "#25231d80", + "indicator": "#978365", + "track": "#978365", + "contrast": "#ffffff" + } + }, + "brown": { + "light": { + "solid": { + "1": "#fefdfc", + "2": "#fcf9f6", + "3": "#f6eee7", + "4": "#f0e4d9", + "5": "#ebdaca", + "6": "#e4cdb7", + "7": "#dcbc9f", + "8": "#cea37e", + "9": "#ad7f58", + "10": "#a07553", + "11": "#815e46", + "12": "#3e332e" + }, + "alpha": { + "a1": "#aa550003", + "a2": "#aa550009", + "a3": "#a04b0018", + "a4": "#9b4a0026", + "a5": "#9f4d0035", + "a6": "#a04e0048", + "a7": "#a34e0060", + "a8": "#9f4a0081", + "a9": "#823c00a7", + "a10": "#723300ac", + "a11": "#522100b9", + "a12": "#140600d1" + }, + "surface": "#fbf8f4cc", + "indicator": "#ad7f58", + "track": "#ad7f58", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#12110f", + "2": "#1c1816", + "3": "#28211d", + "4": "#322922", + "5": "#3e3128", + "6": "#4d3c2f", + "7": "#614a39", + "8": "#7c5f46", + "9": "#ad7f58", + "10": "#b88c67", + "11": "#dbb594", + "12": "#f2e1ca" + }, + "alpha": { + "a1": "#91110002", + "a2": "#fba67c0c", + "a3": "#fcb58c19", + "a4": "#fbbb8a24", + "a5": "#fcb88931", + "a6": "#fdba8741", + "a7": "#ffbb8856", + "a8": "#ffbe8773", + "a9": "#feb87da8", + "a10": "#ffc18cb3", + "a11": "#fed1aad9", + "a12": "#feecd4f2" + }, + "surface": "#271f1b80", + "indicator": "#ad7f58", + "track": "#ad7f58", + "contrast": "#ffffff" + } + }, + "orange": { + "light": { + "solid": { + "1": "#fefcfb", + "2": "#fff7ed", + "3": "#ffefd6", + "4": "#ffdfb5", + "5": "#ffd19a", + "6": "#ffc182", + "7": "#f5ae73", + "8": "#ec9455", + "9": "#f76b15", + "10": "#ef5f00", + "11": "#cc4e00", + "12": "#582d1d" + }, + "alpha": { + "a1": "#c0400004", + "a2": "#ff8e0012", + "a3": "#ff9c0029", + "a4": "#ff91014a", + "a5": "#ff8b0065", + "a6": "#ff81007d", + "a7": "#ed6c008c", + "a8": "#e35f00aa", + "a9": "#f65e00ea", + "a10": "#ef5f00", + "a11": "#cc4e00", + "a12": "#431200e2" + }, + "surface": "#fff5e9cc", + "indicator": "#f76b15", + "track": "#f76b15", + "contrast": "#ffffff" + }, + "dark": { + "solid": { + "1": "#17120e", + "2": "#1e160f", + "3": "#331e0b", + "4": "#462100", + "5": "#562800", + "6": "#66350c", + "7": "#7e451d", + "8": "#a35829", + "9": "#f76b15", + "10": "#ff801f", + "11": "#ffa057", + "12": "#ffe0c2" + }, + "alpha": { + "a1": "#ec360007", + "a2": "#fe6d000e", + "a3": "#fb6a0025", + "a4": "#ff590039", + "a5": "#ff61004a", + "a6": "#fd75045c", + "a7": "#ff832c75", + "a8": "#fe84389d", + "a9": "#fe6d15f7", + "a10": "#ff801f", + "a11": "#ffa057", + "a12": "#ffe0c2" + }, + "surface": "#271d1380", + "indicator": "#f76b15", + "track": "#f76b15", + "contrast": "#ffffff" + } + }, + "amber": { + "light": { + "solid": { + "1": "#fefdfb", + "2": "#fefbe9", + "3": "#fff7c2", + "4": "#ffee9c", + "5": "#fbe577", + "6": "#f3d673", + "7": "#e9c162", + "8": "#e2a336", + "9": "#ffc53d", + "10": "#ffba18", + "11": "#ab6400", + "12": "#4f3422" + }, + "alpha": { + "a1": "#c0800004", + "a2": "#f4d10016", + "a3": "#ffde003d", + "a4": "#ffd40063", + "a5": "#f8cf0088", + "a6": "#eab5008c", + "a7": "#dc9b009d", + "a8": "#da8a00c9", + "a9": "#ffb300c2", + "a10": "#ffb300e7", + "a11": "#ab6400", + "a12": "#341500dd" + }, + "surface": "#fefae4cc", + "indicator": "#ffc53d", + "track": "#ffc53d", + "contrast": "#21201c" + }, + "dark": { + "solid": { + "1": "#16120c", + "2": "#1d180f", + "3": "#302008", + "4": "#3f2700", + "5": "#4d3000", + "6": "#5c3d05", + "7": "#714f19", + "8": "#8f6424", + "9": "#ffc53d", + "10": "#ffd60a", + "11": "#ffca16", + "12": "#ffe7b3" + }, + "alpha": { + "a1": "#e63c0006", + "a2": "#fd9b000d", + "a3": "#fa820022", + "a4": "#fc820032", + "a5": "#fd8b0041", + "a6": "#fd9b0051", + "a7": "#ffab2567", + "a8": "#ffae3587", + "a9": "#ffc53d", + "a10": "#ffd60a", + "a11": "#ffca16", + "a12": "#ffe7b3" + }, + "surface": "#271f1380", + "indicator": "#ffc53d", + "track": "#e2ac37", + "contrast": "#21201c" + } + }, + "yellow": { + "light": { + "solid": { + "1": "#fdfdf9", + "2": "#fefce9", + "3": "#fffab8", + "4": "#fff394", + "5": "#ffe770", + "6": "#f3d768", + "7": "#e4c767", + "8": "#d5ae39", + "9": "#ffe629", + "10": "#ffdc00", + "11": "#9e6c00", + "12": "#473b1f" + }, + "alpha": { + "a1": "#aaaa0006", + "a2": "#f4dd0016", + "a3": "#ffee0047", + "a4": "#ffe3016b", + "a5": "#ffd5008f", + "a6": "#ebbc0097", + "a7": "#d2a10098", + "a8": "#c99700c6", + "a9": "#ffe100d6", + "a10": "#ffdc00", + "a11": "#9e6c00", + "a12": "#2e2000e0" + }, + "surface": "#fefbe4cc", + "indicator": "#ffe629", + "track": "#ffe629", + "contrast": "#21201c" + }, + "dark": { + "solid": { + "1": "#14120b", + "2": "#1b180f", + "3": "#2d2305", + "4": "#362b00", + "5": "#433500", + "6": "#524202", + "7": "#665417", + "8": "#836a21", + "9": "#ffe629", + "10": "#ffff57", + "11": "#f5e147", + "12": "#f6eeb4" + }, + "alpha": { + "a1": "#d1510004", + "a2": "#f9b4000b", + "a3": "#ffaa001e", + "a4": "#fdb70028", + "a5": "#febb0036", + "a6": "#fec40046", + "a7": "#fdcb225c", + "a8": "#fdca327b", + "a9": "#ffe629", + "a10": "#ffff57", + "a11": "#fee949f5", + "a12": "#fef6baf6" + }, + "surface": "#231f1380", + "indicator": "#ffe629", + "track": "#d2b929", + "contrast": "#21201c" + } + }, + "lime": { + "light": { + "solid": { + "1": "#fcfdfa", + "2": "#f8faf3", + "3": "#eef6d6", + "4": "#e2f0bd", + "5": "#d3e7a6", + "6": "#c2da91", + "7": "#abc978", + "8": "#8db654", + "9": "#bdee63", + "10": "#b0e64c", + "11": "#5c7c2f", + "12": "#37401c" + }, + "alpha": { + "a1": "#66990005", + "a2": "#6b95000c", + "a3": "#96c80029", + "a4": "#8fc60042", + "a5": "#81bb0059", + "a6": "#72aa006e", + "a7": "#61990087", + "a8": "#559200ab", + "a9": "#93e4009c", + "a10": "#8fdc00b3", + "a11": "#375f00d0", + "a12": "#1e2900e3" + }, + "surface": "#f6f9f0cc", + "indicator": "#bdee63", + "track": "#bdee63", + "contrast": "#1d211c" + }, + "dark": { + "solid": { + "1": "#11130c", + "2": "#151a10", + "3": "#1f2917", + "4": "#29371d", + "5": "#334423", + "6": "#3d522a", + "7": "#496231", + "8": "#577538", + "9": "#bdee63", + "10": "#d4ff70", + "11": "#bde56c", + "12": "#e3f7ba" + }, + "alpha": { + "a1": "#11bb0003", + "a2": "#78f7000a", + "a3": "#9bfd4c1a", + "a4": "#a7fe5c29", + "a5": "#affe6537", + "a6": "#b2fe6d46", + "a7": "#b6ff6f57", + "a8": "#b6fd6d6c", + "a9": "#caff69ed", + "a10": "#d4ff70", + "a11": "#d1fe77e4", + "a12": "#e9febff7" + }, + "surface": "#1b211580", + "indicator": "#bdee63", + "track": "#98c254", + "contrast": "#1d211c" + } + }, + "mint": { + "light": { + "solid": { + "1": "#f9fefd", + "2": "#f2fbf9", + "3": "#ddf9f2", + "4": "#c8f4e9", + "5": "#b3ecde", + "6": "#9ce0d0", + "7": "#7ecfbd", + "8": "#4cbba5", + "9": "#86ead4", + "10": "#7de0cb", + "11": "#027864", + "12": "#16433c" + }, + "alpha": { + "a1": "#00d5aa06", + "a2": "#00b18a0d", + "a3": "#00d29e22", + "a4": "#00cc9937", + "a5": "#00c0914c", + "a6": "#00b08663", + "a7": "#00a17d81", + "a8": "#009e7fb3", + "a9": "#00d3a579", + "a10": "#00c39982", + "a11": "#007763fd", + "a12": "#00312ae9" + }, + "surface": "#effaf8cc", + "indicator": "#86ead4", + "track": "#86ead4", + "contrast": "#1a211e" + }, + "dark": { + "solid": { + "1": "#0e1515", + "2": "#0f1b1b", + "3": "#092c2b", + "4": "#003a38", + "5": "#004744", + "6": "#105650", + "7": "#1e685f", + "8": "#277f70", + "9": "#86ead4", + "10": "#a8f5e5", + "11": "#58d5ba", + "12": "#c4f5e1" + }, + "alpha": { + "a1": "#00dede05", + "a2": "#00f9f90b", + "a3": "#00fff61d", + "a4": "#00fff42c", + "a5": "#00fff23a", + "a6": "#0effeb4a", + "a7": "#34fde55e", + "a8": "#41ffdf76", + "a9": "#92ffe7e9", + "a10": "#aefeedf5", + "a11": "#67ffded2", + "a12": "#cbfee9f5" + }, + "surface": "#15272780", + "indicator": "#86ead4", + "track": "#65c3b0", + "contrast": "#1a211e" + } + }, + "sky": { + "light": { + "solid": { + "1": "#f9feff", + "2": "#f1fafd", + "3": "#e1f6fd", + "4": "#d1f0fa", + "5": "#bee7f5", + "6": "#a9daed", + "7": "#8dcae3", + "8": "#60b3d7", + "9": "#7ce2fe", + "10": "#74daf8", + "11": "#00749e", + "12": "#1d3e56" + }, + "alpha": { + "a1": "#00d5ff06", + "a2": "#00a4db0e", + "a3": "#00b3ee1e", + "a4": "#00ace42e", + "a5": "#00a1d841", + "a6": "#0092ca56", + "a7": "#0089c172", + "a8": "#0085bf9f", + "a9": "#00c7fe83", + "a10": "#00bcf38b", + "a11": "#00749e", + "a12": "#002540e2" + }, + "surface": "#eef9fdcc", + "indicator": "#7ce2fe", + "track": "#7ce2fe", + "contrast": "#1c2024" + }, + "dark": { + "solid": { + "1": "#0d141f", + "2": "#111a27", + "3": "#112840", + "4": "#113555", + "5": "#154467", + "6": "#1b537b", + "7": "#1f6692", + "8": "#197cae", + "9": "#7ce2fe", + "10": "#a8eeff", + "11": "#75c7f0", + "12": "#c2f3ff" + }, + "alpha": { + "a1": "#0044ff0f", + "a2": "#1171fb18", + "a3": "#1184fc33", + "a4": "#128fff49", + "a5": "#1c9dfd5d", + "a6": "#28a5ff72", + "a7": "#2badfe8b", + "a8": "#1db2fea9", + "a9": "#7ce3fffe", + "a10": "#a8eeff", + "a11": "#7cd3ffef", + "a12": "#c2f3ff" + }, + "surface": "#13233b80", + "indicator": "#7ce2fe", + "track": "#5bbde2", + "contrast": "#1c2024" + } + } + }, + "neutrals": { + "blackA": { + "alpha": { + "a1": "rgba(0, 0, 0, 0.05)", + "a2": "rgba(0, 0, 0, 0.1)", + "a3": "rgba(0, 0, 0, 0.15)", + "a4": "rgba(0, 0, 0, 0.2)", + "a5": "rgba(0, 0, 0, 0.3)", + "a6": "rgba(0, 0, 0, 0.4)", + "a7": "rgba(0, 0, 0, 0.5)", + "a8": "rgba(0, 0, 0, 0.6)", + "a9": "rgba(0, 0, 0, 0.7)", + "a10": "rgba(0, 0, 0, 0.8)", + "a11": "rgba(0, 0, 0, 0.9)", + "a12": "rgba(0, 0, 0, 0.95)" + } + }, + "whiteA": { + "alpha": { + "a1": "rgba(255, 255, 255, 0.05)", + "a2": "rgba(255, 255, 255, 0.1)", + "a3": "rgba(255, 255, 255, 0.15)", + "a4": "rgba(255, 255, 255, 0.2)", + "a5": "rgba(255, 255, 255, 0.3)", + "a6": "rgba(255, 255, 255, 0.4)", + "a7": "rgba(255, 255, 255, 0.5)", + "a8": "rgba(255, 255, 255, 0.6)", + "a9": "rgba(255, 255, 255, 0.7)", + "a10": "rgba(255, 255, 255, 0.8)", + "a11": "rgba(255, 255, 255, 0.9)", + "a12": "rgba(255, 255, 255, 0.95)" + } + } + } + } +} diff --git a/packages/remix/reference/radix_themes_3_3_0/README.md b/packages/remix/reference/radix_themes_3_3_0/README.md new file mode 100644 index 00000000..80c22b7c --- /dev/null +++ b/packages/remix/reference/radix_themes_3_3_0/README.md @@ -0,0 +1,28 @@ +# Fortal / Radix Themes 3.3.0 reference + +This directory pins the upstream `@radix-ui/themes@3.3.0` contract used by +Fortal. The package version, integrity hash, mapped families, public enum +domains and defaults, supported style props, interaction states, Flutter +boundaries, and reference-fixture paths live in `manifest.json`. + +`coverage_evidence.json` maps every manifest enum value and state to an exact +executable test case in the corresponding `coverage.tests` allowlist. The +parity checker rejects unknown keys, missing keys, stale test descriptions, +uncited test files, source drift, an inexact Naked beta.7 dependency, or an +undocumented approximation. Parameterized cases use their literal Dart test +description, such as +`${size.name} matches the pinned dimension and radius`. + +The only tracked parity approximation is the Dialog `modal: false` interaction +boundary recorded in `manifest.json`: it changes accessibility route scoping, +while the Navigator route remains pointer-modal. Resolved visual output has no +tolerance for that boundary. + +Run the contract gate from this package directory: + +```sh +fvm dart run tool/fortal_parity/check.dart +``` + +Chromium reference output is kept under `chromium/` and is never used as a +self-validating oracle for Flutter rendering. diff --git a/packages/remix/reference/radix_themes_3_3_0/chromium/README.md b/packages/remix/reference/radix_themes_3_3_0/chromium/README.md new file mode 100644 index 00000000..80efad63 --- /dev/null +++ b/packages/remix/reference/radix_themes_3_3_0/chromium/README.md @@ -0,0 +1,7 @@ +# Chromium reference fixtures + +These files are browser references for Fortal's mapped Radix Themes families. They are generated from the exact `@radix-ui/themes@3.3.0` npm artifact by the sibling `tool/fortal_parity/chromium` harness. + +Run `npm install && npm run generate` from that tool directory to refresh both the computed-style JSON and the 1440×1280 screenshot. The JSON records the npm integrity, Chromium version, viewport, color profile, and font-rendering flags used for the capture. + +Cross-engine parity compares resolved colors, geometry, layers, states, semantics, and visual intent; anti-aliasing and platform glyph rasterization may differ by at most one 8-bit channel at an edge pixel and are not compared byte-for-byte. diff --git a/packages/remix/reference/radix_themes_3_3_0/chromium/computed-styles.json b/packages/remix/reference/radix_themes_3_3_0/chromium/computed-styles.json new file mode 100644 index 00000000..b7f291b1 --- /dev/null +++ b/packages/remix/reference/radix_themes_3_3_0/chromium/computed-styles.json @@ -0,0 +1,560 @@ +{ + "source": { + "package": "@radix-ui/themes", + "version": "3.3.0", + "integrity": "sha512-I0/h2CRNTpYNB7Mi3xFIvSsQq5a108d7kK8dTO5zp5b9HR5QJXKag6B8tjpz2ITkVYkFdkGk45doNkSr7OxwNw==", + "tarball": "https://registry.npmjs.org/@radix-ui/themes/-/themes-3.3.0.tgz" + }, + "generator": { + "chromium": "Google Chrome 150.0.7871.127", + "viewport": { + "width": 1440, + "height": 1280, + "deviceScaleFactor": 1 + }, + "colorProfile": "srgb", + "fontRenderHinting": "none" + }, + "themeTokens": { + "--accent-1": "#fdfdfe", + "--accent-9": "#3e63dd", + "--accent-12": "#1f2d5c", + "--accent-a9": "#0031d2c1", + "--gray-1": "#fcfcfd", + "--gray-12": "#1c2024", + "--color-background": "white", + "--color-panel": "rgba(255, 255, 255, 0.7)", + "--radius-1": "calc(3px * 1 * 1)", + "--radius-3": "calc(6px * 1 * 1)", + "--radius-6": "calc(16px * 1 * 1)", + "--radius-full": "0px", + "--space-1": "calc(4px * 1)", + "--space-4": "calc(16px * 1)", + "--font-size-2": "calc(14px * 1)", + "--line-height-2": "calc(20px * 1)", + "--shadow-1": "inset 0 0 0 1px #0009321f, inset 0 1.5px 2px 0 #00005506, inset 0 1.5px 2px 0 rgba(0, 0, 0, 0.1)", + "--shadow-4": "0 0 0 1px color-mix(in oklab, #0000330f, #f0f0f3 25%), 0 8px 40px rgba(0, 0, 0, 0.05), 0 12px 32px -16px #0000330f" + }, + "probes": { + "avatar": { + "className": "rt-reset rt-AvatarRoot rt-r-size-3 rt-variant-soft", + "styles": { + "display": "flex", + "position": "static", + "width": "40px", + "height": "40px", + "minWidth": "auto", + "minHeight": "auto", + "padding": "0px", + "gap": "normal", + "borderWidth": "0px", + "borderColor": "rgb(28, 32, 36)", + "borderRadius": "6px", + "backgroundColor": "rgba(0, 0, 0, 0)", + "color": "rgb(28, 32, 36)", + "boxShadow": "none", + "opacity": "1", + "transform": "none", + "filter": "none", + "backdropFilter": "none", + "animationDuration": "0s", + "fontSize": "16px", + "lineHeight": "24px" + } + }, + "badge": { + "className": "rt-reset rt-Badge rt-r-size-1 rt-variant-soft", + "styles": { + "display": "flex", + "position": "static", + "width": "48.9062px", + "height": "20px", + "minWidth": "auto", + "minHeight": "auto", + "padding": "2px 6px", + "gap": "6px", + "borderWidth": "0px", + "borderColor": "rgba(0, 43, 183, 0.773)", + "borderRadius": "3px", + "backgroundColor": "rgba(0, 71, 241, 0.07)", + "color": "rgba(0, 43, 183, 0.773)", + "boxShadow": "none", + "opacity": "1", + "transform": "none", + "filter": "none", + "backdropFilter": "none", + "animationDuration": "0s", + "fontSize": "12px", + "lineHeight": "16px" + } + }, + "button": { + "className": "rt-reset rt-BaseButton rt-Button rt-r-size-2 rt-variant-solid", + "styles": { + "display": "flex", + "position": "static", + "width": "68.2031px", + "height": "32px", + "minWidth": "auto", + "minHeight": "auto", + "padding": "0px 12px", + "gap": "8px", + "borderWidth": "0px", + "borderColor": "rgb(255, 255, 255)", + "borderRadius": "4px", + "backgroundColor": "rgb(62, 99, 221)", + "color": "rgb(255, 255, 255)", + "boxShadow": "none", + "opacity": "1", + "transform": "none", + "filter": "none", + "backdropFilter": "none", + "animationDuration": "0s", + "fontSize": "14px", + "lineHeight": "20px" + } + }, + "callout": { + "className": "rt-CalloutRoot rt-r-size-2 rt-variant-soft", + "styles": { + "display": "grid", + "position": "static", + "width": "122.297px", + "height": "52px", + "minWidth": "auto", + "minHeight": "auto", + "padding": "16px", + "gap": "8px 12px", + "borderWidth": "0px", + "borderColor": "rgba(0, 43, 183, 0.773)", + "borderRadius": "8px", + "backgroundColor": "rgba(0, 71, 241, 0.07)", + "color": "rgba(0, 43, 183, 0.773)", + "boxShadow": "none", + "opacity": "1", + "transform": "none", + "filter": "none", + "backdropFilter": "none", + "animationDuration": "0s", + "fontSize": "16px", + "lineHeight": "24px" + } + }, + "card": { + "className": "rt-reset rt-BaseCard rt-Card rt-r-size-1 rt-variant-surface", + "styles": { + "display": "block", + "position": "relative", + "width": "136.719px", + "height": "48px", + "minWidth": "auto", + "minHeight": "auto", + "padding": "12px", + "gap": "normal", + "borderWidth": "0px", + "borderColor": "rgb(28, 32, 36)", + "borderRadius": "8px", + "backgroundColor": "rgba(0, 0, 0, 0)", + "color": "rgb(28, 32, 36)", + "boxShadow": "none", + "opacity": "1", + "transform": "none", + "filter": "none", + "backdropFilter": "none", + "animationDuration": "0s", + "fontSize": "16px", + "lineHeight": "24px" + } + }, + "checkbox": { + "className": "rt-reset rt-BaseCheckboxRoot rt-CheckboxRoot rt-r-size-2 rt-variant-surface", + "styles": { + "display": "flex", + "position": "relative", + "width": "16px", + "height": "16px", + "minWidth": "auto", + "minHeight": "auto", + "padding": "0px", + "gap": "normal", + "borderWidth": "0px", + "borderColor": "rgb(28, 32, 36)", + "borderRadius": "0px", + "backgroundColor": "rgba(0, 0, 0, 0)", + "color": "rgb(28, 32, 36)", + "boxShadow": "none", + "opacity": "1", + "transform": "none", + "filter": "none", + "backdropFilter": "none", + "animationDuration": "0s", + "fontSize": "16px", + "lineHeight": "24px" + } + }, + "dialog": { + "className": "rt-BaseDialogContent rt-DialogContent rt-r-size-3 rt-r-align-center fixture-dialog-content", + "styles": { + "display": "block", + "position": "relative", + "width": "240px", + "height": "72px", + "minWidth": "auto", + "minHeight": "auto", + "padding": "24px", + "gap": "normal", + "borderWidth": "0px", + "borderColor": "rgb(28, 32, 36)", + "borderRadius": "12px", + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgb(28, 32, 36)", + "boxShadow": "oklab(0.834355 -0.000572754 -0.0182446 / 0.294118) 0px 0px 0px 1px, rgba(0, 0, 0, 0.15) 0px 12px 60px 0px, rgba(0, 0, 85, 0.024) 0px 16px 64px 0px, rgba(0, 6, 46, 0.196) 0px 16px 36px -20px", + "opacity": "0", + "transform": "matrix(0.97, 0, 0, 0.97, 0, 5)", + "filter": "none", + "backdropFilter": "none", + "animationDuration": "0.2s", + "fontSize": "16px", + "lineHeight": "24px" + } + }, + "divider": { + "className": "rt-Separator rt-r-orientation-horizontal rt-r-size-4", + "styles": { + "display": "block", + "position": "static", + "width": "290px", + "height": "1px", + "minWidth": "auto", + "minHeight": "auto", + "padding": "0px", + "gap": "normal", + "borderWidth": "0px", + "borderColor": "rgb(28, 32, 36)", + "borderRadius": "0px", + "backgroundColor": "rgba(0, 62, 255, 0.243)", + "color": "rgb(28, 32, 36)", + "boxShadow": "none", + "opacity": "1", + "transform": "none", + "filter": "none", + "backdropFilter": "none", + "animationDuration": "0s", + "fontSize": "16px", + "lineHeight": "24px" + } + }, + "menu": { + "className": "rt-BaseMenuContent rt-DropdownMenuContent rt-r-size-2 rt-variant-solid fixture-menu", + "styles": { + "display": "flex", + "position": "relative", + "width": "220px", + "height": "81px", + "minWidth": "auto", + "minHeight": "auto", + "padding": "0px", + "gap": "normal", + "borderWidth": "0px", + "borderColor": "rgb(28, 32, 36)", + "borderRadius": "8px", + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgb(28, 32, 36)", + "boxShadow": "oklab(0.834355 -0.000572754 -0.0182446 / 0.294118) 0px 0px 0px 1px, rgba(0, 0, 0, 0.15) 0px 12px 60px 0px, rgba(0, 9, 50, 0.12) 0px 12px 32px -16px", + "opacity": "1", + "transform": "none", + "filter": "none", + "backdropFilter": "none", + "animationDuration": "0s", + "fontSize": "16px", + "lineHeight": "24px" + } + }, + "icon-button": { + "className": "rt-reset rt-BaseButton rt-IconButton rt-r-size-2 rt-variant-classic", + "styles": { + "display": "flex", + "position": "relative", + "width": "32px", + "height": "32px", + "minWidth": "auto", + "minHeight": "auto", + "padding": "0px", + "gap": "normal", + "borderWidth": "0px", + "borderColor": "rgb(255, 255, 255)", + "borderRadius": "4px", + "backgroundColor": "rgb(62, 99, 221)", + "color": "rgb(255, 255, 255)", + "boxShadow": "rgba(0, 0, 45, 0.09) 0px 0px 0px 1px inset, rgba(0, 0, 51, 0.06) 0px -2px 1px 0px inset, rgb(62, 99, 221) 0px 0px 0px 1px inset, rgba(255, 255, 255, 0.7) 0px 4px 2px -2px inset, rgba(255, 255, 255, 0.7) 0px 2px 1px -1px inset", + "opacity": "1", + "transform": "none", + "filter": "none", + "backdropFilter": "none", + "animationDuration": "0s", + "fontSize": "16px", + "lineHeight": "24px" + } + }, + "popover": { + "className": "rt-PopperContent rt-PopoverContent rt-r-size-2", + "styles": { + "display": "block", + "position": "relative", + "width": "151.422px", + "height": "56px", + "minWidth": "auto", + "minHeight": "auto", + "padding": "16px", + "gap": "normal", + "borderWidth": "0px", + "borderColor": "rgb(28, 32, 36)", + "borderRadius": "8px", + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgb(28, 32, 36)", + "boxShadow": "oklab(0.834355 -0.000572754 -0.0182446 / 0.294118) 0px 0px 0px 1px, rgba(0, 0, 0, 0.15) 0px 12px 60px 0px, rgba(0, 9, 50, 0.12) 0px 12px 32px -16px", + "opacity": "1", + "transform": "none", + "filter": "none", + "backdropFilter": "none", + "animationDuration": "0.16s", + "fontSize": "16px", + "lineHeight": "24px" + } + }, + "progress": { + "className": "rt-ProgressRoot rt-r-size-2 rt-variant-surface fixture-progress", + "styles": { + "display": "block", + "position": "relative", + "width": "240px", + "height": "6px", + "minWidth": "auto", + "minHeight": "auto", + "padding": "0px", + "gap": "normal", + "borderWidth": "0px", + "borderColor": "rgb(28, 32, 36)", + "borderRadius": "9999px", + "backgroundColor": "rgba(0, 0, 51, 0.06)", + "color": "rgb(28, 32, 36)", + "boxShadow": "none", + "opacity": "1", + "transform": "none", + "filter": "none", + "backdropFilter": "none", + "animationDuration": "0s", + "fontSize": "16px", + "lineHeight": "24px" + } + }, + "radio": { + "className": "rt-reset rt-BaseRadioRoot rt-RadioRoot rt-r-size-2 rt-variant-surface", + "styles": { + "display": "flex", + "position": "relative", + "width": "16px", + "height": "16px", + "minWidth": "auto", + "minHeight": "auto", + "padding": "0px", + "gap": "normal", + "borderWidth": "0px", + "borderColor": "rgb(28, 32, 36)", + "borderRadius": "0px", + "backgroundColor": "rgba(0, 0, 0, 0)", + "color": "rgb(28, 32, 36)", + "boxShadow": "none", + "opacity": "1", + "transform": "none", + "filter": "none", + "backdropFilter": "none", + "animationDuration": "0s", + "fontSize": "16px", + "lineHeight": "24px" + } + }, + "select": { + "className": "rt-reset rt-SelectTrigger rt-r-size-2 rt-variant-surface", + "styles": { + "display": "flex", + "position": "static", + "width": "95.25px", + "height": "32px", + "minWidth": "auto", + "minHeight": "auto", + "padding": "0px 12px", + "gap": "6px", + "borderWidth": "0px", + "borderColor": "rgb(28, 32, 36)", + "borderRadius": "4px", + "backgroundColor": "rgba(255, 255, 255, 0.85)", + "color": "rgb(28, 32, 36)", + "boxShadow": "rgba(0, 8, 48, 0.275) 0px 0px 0px 1px inset", + "opacity": "1", + "transform": "none", + "filter": "none", + "backdropFilter": "none", + "animationDuration": "0s", + "fontSize": "14px", + "lineHeight": "20px" + } + }, + "slider": { + "className": "rt-SliderRoot rt-r-size-2 rt-variant-surface fixture-slider", + "styles": { + "display": "flex", + "position": "relative", + "width": "240px", + "height": "8px", + "minWidth": "auto", + "minHeight": "auto", + "padding": "0px", + "gap": "normal", + "borderWidth": "0px", + "borderColor": "rgb(28, 32, 36)", + "borderRadius": "9999px", + "backgroundColor": "rgba(0, 0, 0, 0)", + "color": "rgb(28, 32, 36)", + "boxShadow": "none", + "opacity": "1", + "transform": "none", + "filter": "none", + "backdropFilter": "none", + "animationDuration": "0s", + "fontSize": "16px", + "lineHeight": "24px" + } + }, + "spinner": { + "className": "rt-Spinner rt-r-size-2", + "styles": { + "display": "block", + "position": "relative", + "width": "16px", + "height": "16px", + "minWidth": "auto", + "minHeight": "auto", + "padding": "0px", + "gap": "normal", + "borderWidth": "0px", + "borderColor": "rgb(28, 32, 36)", + "borderRadius": "0px", + "backgroundColor": "rgba(0, 0, 0, 0)", + "color": "rgb(28, 32, 36)", + "boxShadow": "none", + "opacity": "0.65", + "transform": "none", + "filter": "none", + "backdropFilter": "none", + "animationDuration": "0s", + "fontSize": "16px", + "lineHeight": "24px" + } + }, + "switch": { + "className": "rt-reset rt-SwitchRoot rt-r-size-2 rt-variant-surface", + "styles": { + "display": "flex", + "position": "relative", + "width": "35px", + "height": "20px", + "minWidth": "auto", + "minHeight": "auto", + "padding": "0px", + "gap": "normal", + "borderWidth": "0px", + "borderColor": "rgb(28, 32, 36)", + "borderRadius": "0px", + "backgroundColor": "rgba(0, 0, 0, 0)", + "color": "rgb(28, 32, 36)", + "boxShadow": "none", + "opacity": "1", + "transform": "none", + "filter": "none", + "backdropFilter": "none", + "animationDuration": "0s", + "fontSize": "16px", + "lineHeight": "24px" + } + }, + "tabs": { + "className": "rt-BaseTabList rt-TabsList rt-r-size-2", + "styles": { + "display": "flex", + "position": "static", + "width": "122.219px", + "height": "40px", + "minWidth": "auto", + "minHeight": "auto", + "padding": "0px", + "gap": "normal", + "borderWidth": "0px", + "borderColor": "rgb(28, 32, 36)", + "borderRadius": "0px", + "backgroundColor": "rgba(0, 0, 0, 0)", + "color": "rgb(28, 32, 36)", + "boxShadow": "rgba(0, 9, 50, 0.12) 0px -1px 0px 0px inset", + "opacity": "1", + "transform": "none", + "filter": "none", + "backdropFilter": "none", + "animationDuration": "0s", + "fontSize": "14px", + "lineHeight": "20px" + } + }, + "text-field": { + "className": "rt-TextFieldRoot rt-r-size-2 rt-variant-surface fixture-text-field", + "styles": { + "display": "flex", + "position": "static", + "width": "250px", + "height": "32px", + "minWidth": "auto", + "minHeight": "auto", + "padding": "1px", + "gap": "normal", + "borderWidth": "0px", + "borderColor": "rgb(28, 32, 36)", + "borderRadius": "4px", + "backgroundColor": "rgba(255, 255, 255, 0.85)", + "color": "rgb(28, 32, 36)", + "boxShadow": "rgba(0, 0, 47, 0.15) 0px 0px 0px 1px inset", + "opacity": "1", + "transform": "none", + "filter": "none", + "backdropFilter": "none", + "animationDuration": "0s", + "fontSize": "14px", + "lineHeight": "21px" + } + }, + "tooltip": { + "className": "rt-TooltipContent fixture-tooltip", + "styles": { + "display": "block", + "position": "relative", + "width": "101.422px", + "height": "48px", + "minWidth": "auto", + "minHeight": "auto", + "padding": "4px 8px", + "gap": "normal", + "borderWidth": "0px", + "borderColor": "rgb(28, 32, 36)", + "borderRadius": "4px", + "backgroundColor": "rgb(28, 32, 36)", + "color": "rgb(28, 32, 36)", + "boxShadow": "none", + "opacity": "0", + "transform": "matrix(0.97, 0, 0, 0.97, 0, 4)", + "filter": "none", + "backdropFilter": "none", + "animationDuration": "0.14s", + "fontSize": "16px", + "lineHeight": "24px" + } + } + } +} diff --git a/packages/remix/reference/radix_themes_3_3_0/chromium/families-light.png b/packages/remix/reference/radix_themes_3_3_0/chromium/families-light.png new file mode 100644 index 0000000000000000000000000000000000000000..74f7355f16896ba86f8acf90e6fc0666fcc700b7 GIT binary patch literal 85163 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 D-(#v# literal 0 HcmV?d00001 diff --git a/packages/remix/reference/radix_themes_3_3_0/coverage_evidence.json b/packages/remix/reference/radix_themes_3_3_0/coverage_evidence.json new file mode 100644 index 00000000..5692eca3 --- /dev/null +++ b/packages/remix/reference/radix_themes_3_3_0/coverage_evidence.json @@ -0,0 +1,1034 @@ +{ + "theme": [ + { + "test": "test/fortal/fortal_theme_resolution_test.dart", + "case": "exposes only the Radix Themes 3.3.0 theme values", + "covers": [ + "enum:appearance.inherit", + "enum:appearance.light", + "enum:appearance.dark", + "enum:accentColor.gray", + "enum:accentColor.gold", + "enum:accentColor.bronze", + "enum:accentColor.brown", + "enum:accentColor.yellow", + "enum:accentColor.amber", + "enum:accentColor.orange", + "enum:accentColor.tomato", + "enum:accentColor.red", + "enum:accentColor.ruby", + "enum:accentColor.crimson", + "enum:accentColor.pink", + "enum:accentColor.plum", + "enum:accentColor.purple", + "enum:accentColor.violet", + "enum:accentColor.iris", + "enum:accentColor.indigo", + "enum:accentColor.blue", + "enum:accentColor.cyan", + "enum:accentColor.teal", + "enum:accentColor.jade", + "enum:accentColor.green", + "enum:accentColor.grass", + "enum:accentColor.lime", + "enum:accentColor.mint", + "enum:accentColor.sky", + "enum:grayColor.auto", + "enum:grayColor.gray", + "enum:grayColor.mauve", + "enum:grayColor.slate", + "enum:grayColor.sage", + "enum:grayColor.olive", + "enum:grayColor.sand", + "enum:panelBackground.solid", + "enum:panelBackground.translucent", + "enum:radius.none", + "enum:radius.small", + "enum:radius.medium", + "enum:radius.large", + "enum:radius.full", + "enum:scaling.90%", + "enum:scaling.95%", + "enum:scaling.100%", + "enum:scaling.105%", + "enum:scaling.110%" + ] + }, + { + "test": "test/fortal/fortal_theme_resolution_test.dart", + "case": "observes platform brightness without Material", + "covers": ["state:rootPlatformLight", "state:rootPlatformDark"] + }, + { + "test": "test/fortal/fortal_theme_resolution_test.dart", + "case": "nested accent changes inherit gray unless auto is explicit", + "covers": ["state:nestedInherit", "state:nestedExplicitAuto"] + }, + { + "test": "test/fortal/fortal_theme_resolution_test.dart", + "case": "nested appearance and background defaults match scope boundaries", + "covers": ["state:paintedBackground"] + }, + { + "test": "test/fortal/fortal_theming_widget_test.dart", + "case": "FortalCard resolves translucent and solid panel surfaces", + "covers": ["state:translucentPanel", "state:solidPanel"] + } + ], + "avatar": [ + { + "test": "test/components/avatar/avatar_fortal_parity_test.dart", + "case": "${size.name} matches the pinned dimension and radius", + "covers": [ + "enum:size.size1", + "enum:size.size2", + "enum:size.size3", + "enum:size.size4", + "enum:size.size5", + "enum:size.size6", + "enum:size.size7", + "enum:size.size8", + "enum:size.size9" + ] + }, + { + "test": "test/components/avatar/avatar_fortal_parity_test.dart", + "case": "soft and solid resolve normal and high-contrast roles", + "covers": [ + "enum:variant.solid", + "enum:variant.soft", + "state:idle", + "state:highContrast" + ] + }, + { + "test": "test/components/avatar/avatar_widget_test.dart", + "case": "loaded background image replaces the fallback content", + "covers": ["state:loading", "state:loaded"] + }, + { + "test": "test/components/avatar/avatar_widget_test.dart", + "case": "background image errors preserve fallback and report once", + "covers": ["state:error"] + } + ], + "badge": [ + { + "test": "test/components/badge/badge_fortal_parity_test.dart", + "case": "${size.name} matches pinned metrics", + "covers": ["enum:size.size1", "enum:size.size2", "enum:size.size3"] + }, + { + "test": "test/components/badge/badge_fortal_parity_test.dart", + "case": "variants resolve exact fills, inset strokes, and contrast roles", + "covers": [ + "enum:variant.solid", + "enum:variant.soft", + "enum:variant.surface", + "enum:variant.outline", + "state:idle", + "state:highContrast" + ] + } + ], + "button": [ + { + "test": "test/components/button/button_style_test.dart", + "case": "each size resolves distinct layout metrics", + "covers": [ + "enum:size.size1", + "enum:size.size2", + "enum:size.size3", + "enum:size.size4" + ] + }, + { + "test": "test/components/button/button_style_test.dart", + "case": "resolves $variant variant", + "covers": [ + "enum:variant.classic", + "enum:variant.solid", + "enum:variant.soft", + "enum:variant.surface", + "enum:variant.outline", + "enum:variant.ghost" + ] + }, + { + "test": "test/components/button/button_style_test.dart", + "case": "high contrast selects the stronger button roles", + "covers": ["state:idle", "state:highContrast"] + }, + { + "test": "test/components/button/button_widget_test.dart", + "case": "reports hovered state", + "covers": ["state:hovered"] + }, + { + "test": "test/components/button/button_widget_test.dart", + "case": "reports pressed state", + "covers": ["state:pressed"] + }, + { + "test": "test/components/button/button_widget_test.dart", + "case": "reports focused state", + "covers": ["state:focused"] + }, + { + "test": "test/components/button/button_widget_test.dart", + "case": "reports disabled state when no callback is supplied", + "covers": ["state:disabled"] + }, + { + "test": "test/components/button/button_widget_test.dart", + "case": "loading preserves layout, hides visuals, and shows spinner", + "covers": ["state:loading"] + } + ], + "callout": [ + { + "test": "test/components/callout/callout_fortal_parity_test.dart", + "case": "${size.name} matches pinned metrics", + "covers": ["enum:size.size1", "enum:size.size2", "enum:size.size3"] + }, + { + "test": "test/components/callout/callout_fortal_parity_test.dart", + "case": "variants resolve pinned alpha fills and inset strokes", + "covers": [ + "enum:variant.soft", + "enum:variant.surface", + "enum:variant.outline", + "state:idle", + "state:highContrast" + ] + } + ], + "card": [ + { + "test": "test/components/card/card_fortal_parity_test.dart", + "case": "public contract has the pinned enum order and defaults", + "covers": [ + "enum:size.size1", + "enum:size.size2", + "enum:size.size3", + "enum:size.size4", + "enum:size.size5", + "enum:variant.surface", + "enum:variant.classic", + "enum:variant.ghost" + ] + }, + { + "test": "test/components/card/card_widget_test.dart", + "case": "is a passive semantic-preserving container by default", + "covers": ["state:idle", "state:passive"] + }, + { + "test": "test/components/card/card_fortal_parity_test.dart", + "case": "focused active and ghost compound states match CSS", + "covers": [ + "state:hovered", + "state:pressed", + "state:focused", + "state:open" + ] + }, + { + "test": "test/components/card/card_widget_test.dart", + "case": "a disabled interactive card does not invoke callbacks", + "covers": ["state:disabled"] + } + ], + "checkbox": [ + { + "test": "test/fortal/fortal_control_matrix_test.dart", + "case": "checkbox and radio sizes resolve exact control metrics", + "covers": ["enum:size.size1", "enum:size.size2", "enum:size.size3"] + }, + { + "test": "test/fortal/fortal_high_contrast_test.dart", + "case": "selected checkbox variants resolve stronger roles", + "covers": [ + "enum:variant.classic", + "enum:variant.surface", + "enum:variant.soft", + "state:highContrast" + ] + }, + { + "test": "test/components/checkbox/checkbox_widget_test.dart", + "case": "renders unchecked state", + "covers": ["state:unchecked"] + }, + { + "test": "test/components/checkbox/checkbox_widget_test.dart", + "case": "renders checked state", + "covers": ["state:checked"] + }, + { + "test": "test/components/checkbox/checkbox_widget_test.dart", + "case": "renders indeterminate state when tristate is true", + "covers": ["state:indeterminate"] + }, + { + "test": "test/components/checkbox/checkbox_widget_test.dart", + "case": "contains hovered state when hovered", + "covers": ["state:hovered"] + }, + { + "test": "test/components/checkbox/checkbox_widget_test.dart", + "case": "contains pressed state when pressed", + "covers": ["state:pressed"] + }, + { + "test": "test/components/checkbox/checkbox_widget_test.dart", + "case": "contains focused state when focused", + "covers": ["state:focused"] + }, + { + "test": "test/components/checkbox/checkbox_widget_test.dart", + "case": "contains disabled state when enabled is false", + "covers": ["state:disabled"] + } + ], + "dialog": [ + { + "test": "test/components/dialog/dialog_parity_test.dart", + "case": "resolves the exact size padding and radius matrix", + "covers": [ + "enum:size.size1", + "enum:size.size2", + "enum:size.size3", + "enum:size.size4" + ] + }, + { + "test": "test/components/dialog/dialog_parity_test.dart", + "case": "supports start and center viewport alignment", + "covers": ["enum:align.start", "enum:align.center"] + }, + { + "test": "test/components/dialog/dialog_widget_test.dart", + "case": "transitions through entering open exiting and closed states", + "covers": [ + "state:closed", + "state:open", + "state:entering", + "state:exiting" + ] + }, + { + "test": "test/components/dialog/dialog_widget_test.dart", + "case": "modal controls route-scoping accessibility semantics", + "covers": ["state:modal", "state:nonModal"] + } + ], + "divider": [ + { + "test": "test/components/divider/divider_widget_test.dart", + "case": "Fortal divider sizes resolve every orientation and length", + "covers": [ + "enum:size.size1", + "enum:size.size2", + "enum:size.size3", + "enum:size.size4", + "enum:orientation.horizontal", + "enum:orientation.vertical" + ] + }, + { + "test": "test/components/divider/divider_widget_test.dart", + "case": "keeps decorative dividers out of the semantics tree", + "covers": ["state:decorative"] + }, + { + "test": "test/components/divider/divider_widget_test.dart", + "case": "exposes non-decorative separator semantics", + "covers": ["state:semantic"] + } + ], + "menu": [ + { + "test": "test/components/menu/menu_style_test.dart", + "case": "Fortal exposes the complete Radix size and variant matrix", + "covers": [ + "enum:size.size1", + "enum:size.size2", + "enum:variant.solid", + "enum:variant.soft" + ] + }, + { + "test": "test/components/menu/menu_widget_test.dart", + "case": "arbitrary trigger opens ordered compositional items", + "covers": ["state:closed", "state:open"] + }, + { + "test": "test/components/menu/menu_style_test.dart", + "case": "Fortal item states resolve highlight submenu disabled and high contrast roles", + "covers": ["state:highlighted", "state:disabled", "state:highContrast"] + }, + { + "test": "test/components/menu/menu_widget_test.dart", + "case": "checkbox and radio items expose correct roles and callbacks", + "covers": ["state:checked", "state:unchecked"] + }, + { + "test": "test/components/menu/menu_widget_test.dart", + "case": "submenu hover waits 100 ms and nested action closes the root", + "covers": ["state:submenuOpen"] + }, + { + "test": "test/components/menu/menu_widget_test.dart", + "case": "submenu keyboard handoff follows LTR and restores focus", + "covers": ["state:escape", "state:focusRestored"] + }, + { + "test": "test/components/menu/menu_widget_test.dart", + "case": "submenu keyboard handoff reverses in RTL", + "covers": ["state:rtl"] + } + ], + "icon_button": [ + { + "test": "test/fortal/fortal_control_matrix_test.dart", + "case": "icon button sizes resolve exact square and icon metrics", + "covers": [ + "enum:size.size1", + "enum:size.size2", + "enum:size.size3", + "enum:size.size4" + ] + }, + { + "test": "test/fortal/fortal_high_contrast_test.dart", + "case": "icon button variants resolve stronger accent roles", + "covers": [ + "enum:variant.classic", + "enum:variant.solid", + "enum:variant.soft", + "enum:variant.surface", + "enum:variant.outline", + "enum:variant.ghost", + "state:idle", + "state:highContrast" + ] + }, + { + "test": "test/components/icon_button/icon_button_widget_test.dart", + "case": "reports hovered state", + "covers": ["state:hovered"] + }, + { + "test": "test/components/icon_button/icon_button_widget_test.dart", + "case": "reports pressed state", + "covers": ["state:pressed"] + }, + { + "test": "test/components/icon_button/icon_button_widget_test.dart", + "case": "reports focused state", + "covers": ["state:focused"] + }, + { + "test": "test/components/icon_button/icon_button_widget_test.dart", + "case": "reports disabled state when no callback is supplied", + "covers": ["state:disabled"] + }, + { + "test": "test/components/icon_button/icon_button_widget_test.dart", + "case": "loading preserves layout, hides content, and shows spinner", + "covers": ["state:loading"] + } + ], + "popover": [ + { + "test": "test/components/popover/popover_parity_test.dart", + "case": "resolves the exact size padding and radius matrix", + "covers": [ + "enum:size.size1", + "enum:size.size2", + "enum:size.size3", + "enum:size.size4" + ] + }, + { + "test": "test/components/popover/popover_widget_test.dart", + "case": "is closed by default and opens styled content on tap", + "covers": ["state:closed", "state:open"] + }, + { + "test": "test/components/popover/popover_parity_test.dart", + "case": "propagates hovered and open states into its trigger subtree", + "covers": ["state:hovered"] + }, + { + "test": "test/components/popover/popover_widget_test.dart", + "case": "closes on an outside tap and reports lifecycle changes", + "covers": ["state:outsideTap"] + }, + { + "test": "test/components/popover/popover_widget_test.dart", + "case": "Escape closes the popover and restores trigger focus", + "covers": ["state:escape", "state:focusRestored"] + }, + { + "test": "test/components/popover/popover_parity_test.dart", + "case": "positions against a separate keyed anchor", + "covers": ["state:separateAnchor"] + }, + { + "test": "test/components/popover/popover_parity_test.dart", + "case": "reports collisionFlipped and collisionShifted placement", + "covers": ["state:collisionFlipped", "state:collisionShifted"] + } + ], + "progress": [ + { + "test": "test/fortal/fortal_control_matrix_test.dart", + "case": "enum domains and defaults match Radix Themes 3.3.0", + "covers": [ + "enum:size.size1", + "enum:size.size2", + "enum:size.size3", + "enum:variant.classic", + "enum:variant.surface", + "enum:variant.soft" + ] + }, + { + "test": "test/components/progress/progress_widget_test.dart", + "case": "uses value divided by max for determinate width", + "covers": ["state:determinate"] + }, + { + "test": "test/components/progress/progress_widget_test.dart", + "case": "runs the indeterminate growth sequence once", + "covers": ["state:indeterminate"] + }, + { + "test": "test/components/progress/progress_widget_test.dart", + "case": "uses a stable completed frame when animations are disabled", + "covers": ["state:animationDisabled"] + }, + { + "test": "test/fortal/fortal_high_contrast_test.dart", + "case": "progress variants resolve accent12 indicators", + "covers": ["state:highContrast"] + } + ], + "radio": [ + { + "test": "test/fortal/fortal_control_matrix_test.dart", + "case": "checkbox and radio sizes resolve exact control metrics", + "covers": ["enum:size.size1", "enum:size.size2", "enum:size.size3"] + }, + { + "test": "test/fortal/fortal_high_contrast_test.dart", + "case": "selected radio variants resolve stronger roles", + "covers": [ + "enum:variant.classic", + "enum:variant.surface", + "enum:variant.soft", + "state:highContrast" + ] + }, + { + "test": "test/components/radio/radio_widget_test.dart", + "case": "renders radio as unselected when not matching groupValue", + "covers": ["state:unchecked"] + }, + { + "test": "test/components/radio/radio_widget_test.dart", + "case": "renders radio as selected when matching groupValue", + "covers": ["state:checked"] + }, + { + "test": "test/components/radio/radio_widget_test.dart", + "case": "reports radio hovered state", + "covers": ["state:hovered"] + }, + { + "test": "test/components/radio/radio_widget_test.dart", + "case": "reports radio pressed state", + "covers": ["state:pressed"] + }, + { + "test": "test/components/radio/radio_widget_test.dart", + "case": "reports radio focused state", + "covers": ["state:focused"] + }, + { + "test": "test/components/radio/radio_widget_test.dart", + "case": "reports radio disabled state", + "covers": ["state:disabled"] + }, + { + "test": "test/components/radio/radio_widget_test.dart", + "case": "provides group context for radio buttons", + "covers": ["state:group"] + } + ], + "select": [ + { + "test": "test/components/select/select_fortal_parity_test.dart", + "case": "${size.name} matches the pinned CSS metrics", + "covers": ["enum:size.size1", "enum:size.size2", "enum:size.size3"] + }, + { + "test": "test/components/select/select_parity_test.dart", + "case": "exposes the exact Radix trigger and content variant sets", + "covers": [ + "enum:triggerVariant.classic", + "enum:triggerVariant.surface", + "enum:triggerVariant.soft", + "enum:triggerVariant.ghost", + "enum:contentVariant.solid", + "enum:contentVariant.soft" + ] + }, + { + "test": "test/components/select/select_widget_test.dart", + "case": "reports open and close lifecycle around a selection", + "covers": ["state:closed", "state:open"] + }, + { + "test": "test/components/select/select_widget_test.dart", + "case": "renders placeholder, leading icon, and a stable down chevron", + "covers": ["state:placeholder"] + }, + { + "test": "test/components/select/select_widget_test.dart", + "case": "selection is controlled and closes content by default", + "covers": ["state:selected"] + }, + { + "test": "test/components/select/select_fortal_parity_test.dart", + "case": "content variants and disabled precedence match pinned roles", + "covers": ["state:highlighted", "state:disabled", "state:highContrast"] + }, + { + "test": "test/components/select/select_parity_test.dart", + "case": "exposes trigger, menu-item, label, and decorative boundaries", + "covers": ["state:grouped", "state:separated"] + }, + { + "test": "test/components/select/select_parity_test.dart", + "case": "controlled opening remains owner-authoritative", + "covers": ["state:ownerRejected"] + }, + { + "test": "test/components/select/select_parity_test.dart", + "case": "reports collisionFlipped and collisionShifted placement", + "covers": ["state:collisionFlipped", "state:collisionShifted"] + } + ], + "slider": [ + { + "test": "test/components/slider/slider_style_test.dart", + "case": "Fortal exposes the exact Radix size and variant matrix", + "covers": [ + "enum:size.size1", + "enum:size.size2", + "enum:size.size3", + "enum:variant.classic", + "enum:variant.surface", + "enum:variant.soft" + ] + }, + { + "test": "test/components/slider/slider_widget_test.dart", + "case": "vertical orientation grows bottom-up unless inverted", + "covers": [ + "enum:orientation.horizontal", + "enum:orientation.vertical", + "state:inverted" + ] + }, + { + "test": "test/components/slider/slider_widget_test.dart", + "case": "defaults match the Radix-shaped arbitrary-thumb contract", + "covers": ["state:idle"] + }, + { + "test": "test/components/slider/slider_widget_test.dart", + "case": "reports hover entry and exit", + "covers": ["state:hovered"] + }, + { + "test": "test/components/slider/slider_widget_test.dart", + "case": "reports complete drag lifecycle lists and aggregate states", + "covers": ["state:pressed"] + }, + { + "test": "test/components/slider/slider_widget_test.dart", + "case": "paints the focus overlay only around the focused thumb", + "covers": ["state:focused"] + }, + { + "test": "test/components/slider/slider_widget_test.dart", + "case": "disabled slider ignores pointer and hover input", + "covers": ["state:disabled"] + }, + { + "test": "test/fortal/fortal_high_contrast_test.dart", + "case": "slider variants resolve accent12 ranges", + "covers": ["state:highContrast"] + }, + { + "test": "test/components/slider/slider_widget_test.dart", + "case": "tap changes the nearest thumb and supports three thumbs", + "covers": ["state:multipleThumbs"] + }, + { + "test": "test/components/slider/slider_widget_test.dart", + "case": "RTL and inversion map both the thumb and filled origin", + "covers": ["state:rtl"] + }, + { + "test": "test/components/slider/slider_widget_test.dart", + "case": "owner rejection leaves visual values unchanged", + "covers": ["state:ownerRejected"] + } + ], + "spinner": [ + { + "test": "test/fortal/fortal_control_matrix_test.dart", + "case": "progress and spinner sizes resolve exact metrics", + "covers": ["enum:size.size1", "enum:size.size2", "enum:size.size3"] + }, + { + "test": "test/components/spinner/spinner_widget_test.dart", + "case": "preserves hidden child layout while loading", + "covers": ["state:loading", "state:withChild"] + }, + { + "test": "test/components/spinner/spinner_widget_test.dart", + "case": "returns its child directly when not loading", + "covers": ["state:notLoading"] + }, + { + "test": "test/components/spinner/spinner_widget_test.dart", + "case": "renders an eight-leaf spinner in inherited current color", + "covers": ["state:withoutChild"] + }, + { + "test": "test/components/spinner/spinner_widget_test.dart", + "case": "stops animation when accessible animations are disabled", + "covers": ["state:animationDisabled"] + } + ], + "switch": [ + { + "test": "test/fortal/fortal_control_matrix_test.dart", + "case": "switch sizes resolve exact track and thumb metrics", + "covers": ["enum:size.size1", "enum:size.size2", "enum:size.size3"] + }, + { + "test": "test/fortal/fortal_high_contrast_test.dart", + "case": "selected switch variants resolve accent12 tracks", + "covers": [ + "enum:variant.classic", + "enum:variant.surface", + "enum:variant.soft", + "state:highContrast" + ] + }, + { + "test": "test/components/switch/switch_widget_test.dart", + "case": "renders switch in unselected state", + "covers": ["state:unchecked"] + }, + { + "test": "test/components/switch/switch_widget_test.dart", + "case": "renders switch in selected state", + "covers": ["state:checked"] + }, + { + "test": "test/components/switch/switch_widget_test.dart", + "case": "reports switch hovered state", + "covers": ["state:hovered"] + }, + { + "test": "test/components/switch/switch_widget_test.dart", + "case": "reports switch pressed state", + "covers": ["state:pressed"] + }, + { + "test": "test/components/switch/switch_widget_test.dart", + "case": "reports switch focused state", + "covers": ["state:focused"] + }, + { + "test": "test/components/switch/switch_widget_test.dart", + "case": "reports switch disabled state", + "covers": ["state:disabled"] + } + ], + "tabs": [ + { + "test": "test/components/tabs/tabs_widget_test.dart", + "case": "Fortal size controls exact tab height", + "covers": ["enum:size.size1", "enum:size.size2"] + }, + { + "test": "test/components/tabs/tabs_parity_test.dart", + "case": "maps every wrap justify and orientation to rendered layout", + "covers": [ + "enum:wrap.nowrap", + "enum:wrap.wrap", + "enum:wrap.wrapReverse", + "enum:justify.start", + "enum:justify.center", + "enum:justify.end", + "enum:orientation.horizontal", + "enum:orientation.vertical" + ] + }, + { + "test": "test/components/tabs/tabs_widget_test.dart", + "case": "arrow focus automatically activates on the current contract", + "covers": ["enum:activationMode.automatic", "state:automatic"] + }, + { + "test": "test/components/tabs/tabs_widget_test.dart", + "case": "manual activation moves focus without selecting", + "covers": ["enum:activationMode.manual", "state:manual"] + }, + { + "test": "test/components/tabs/tabs_widget_test.dart", + "case": "controlled tabs select by tap and show the matching panel", + "covers": ["state:inactive", "state:active"] + }, + { + "test": "test/components/tabs/tabs_widget_test.dart", + "case": "tab builder exposes hovered pressed and focused states", + "covers": ["state:hovered", "state:pressed", "state:focused"] + }, + { + "test": "test/components/tabs/tabs_widget_test.dart", + "case": "disabled root and disabled tabs cannot change selection", + "covers": ["state:disabled"] + }, + { + "test": "test/components/tabs/tabs_widget_test.dart", + "case": "RTL horizontal arrows follow visual direction", + "covers": ["state:rtl"] + }, + { + "test": "test/fortal/fortal_high_contrast_test.dart", + "case": "selected tabs resolve an accent12 underline", + "covers": ["state:highContrast"] + } + ], + "text_field": [ + { + "test": "test/fortal/fortal_control_matrix_test.dart", + "case": "text field size and variant matrix resolves exact metrics", + "covers": [ + "enum:size.size1", + "enum:size.size2", + "enum:size.size3", + "enum:variant.classic", + "enum:variant.surface", + "enum:variant.soft", + "state:idle" + ] + }, + { + "test": "test/components/textfield/textfield_widget_test.dart", + "case": "reports text field hovered state", + "covers": ["state:hovered"] + }, + { + "test": "test/components/textfield/textfield_widget_test.dart", + "case": "keeps focus variants connected to NakedTextField state", + "covers": ["state:focused"] + }, + { + "test": "test/components/textfield/fortal_textfield_disabled_test.dart", + "case": "disabled soft textfield resolves a background and never the debug red", + "covers": ["state:disabled"] + }, + { + "test": "test/components/textfield/textfield_widget_test.dart", + "case": "respects readOnly flag", + "covers": ["state:readOnly"] + }, + { + "test": "test/components/textfield/textfield_widget_test.dart", + "case": "handles error state", + "covers": ["state:invalid"] + }, + { + "test": "test/components/textfield/textfield_widget_test.dart", + "case": "renders with leading and trailing widgets", + "covers": ["state:leading", "state:trailing"] + } + ], + "tooltip": [ + { + "test": "test/components/tooltip/tooltip_parity_test.dart", + "case": "delayed trigger hover opens and content hover holds the overlay", + "covers": [ + "state:closed", + "state:delayedOpen", + "state:triggerHovered", + "state:contentHovered" + ] + }, + { + "test": "test/components/tooltip/tooltip_parity_test.dart", + "case": "focus opens instantly and Escape closes", + "covers": ["state:instantOpen", "state:focused", "state:escape"] + }, + { + "test": "test/components/tooltip/tooltip_parity_test.dart", + "case": "touch long press opens and reports the trigger", + "covers": ["state:touch"] + }, + { + "test": "test/components/tooltip/tooltip_parity_test.dart", + "case": "outside tap requests and accepts close", + "covers": ["state:outsideTap"] + }, + { + "test": "test/components/tooltip/tooltip_parity_test.dart", + "case": "controlled opening remains owner-authoritative", + "covers": ["state:ownerRejected"] + }, + { + "test": "test/components/tooltip/tooltip_parity_test.dart", + "case": "a collision flip reverses the arrow side", + "covers": ["state:collisionFlipped"] + }, + { + "test": "test/components/tooltip/tooltip_parity_test.dart", + "case": "the arrow follows a collision shift and instant open", + "covers": ["state:collisionShifted"] + } + ], + "accordion": [ + { + "test": "test/components/accordion/accordion_style_test.dart", + "case": "all variant and size combinations produce complete styles", + "covers": [ + "enum:size.size1", + "enum:size.size2", + "enum:size.size3", + "enum:variant.surface", + "enum:variant.soft" + ] + }, + { + "test": "test/components/accordion/accordion_widget_test.dart", + "case": "shows add icon when collapsed and remove icon when expanded", + "covers": ["state:collapsed", "state:expanded"] + }, + { + "test": "test/components/accordion/accordion_widget_test.dart", + "case": "contains hovered state when hovered", + "covers": ["state:hovered"] + }, + { + "test": "test/components/accordion/accordion_widget_test.dart", + "case": "tracks pressed state without requiring a callback", + "covers": ["state:pressed"] + }, + { + "test": "test/components/accordion/accordion_widget_test.dart", + "case": "contains focused state when focused", + "covers": ["state:focused"] + }, + { + "test": "test/components/accordion/accordion_widget_test.dart", + "case": "contains disabled state when enabled is false", + "covers": ["state:disabled"] + } + ], + "toggle": [ + { + "test": "test/components/toggle/toggle_widget_test.dart", + "case": "recipe works for all variant/size combos", + "covers": [ + "enum:size.size1", + "enum:size.size2", + "enum:size.size3", + "enum:variant.ghost", + "enum:variant.outline" + ] + }, + { + "test": "test/components/toggle/toggle_widget_test.dart", + "case": "toggles between states", + "covers": ["state:off", "state:on"] + }, + { + "test": "test/components/toggle/toggle_widget_test.dart", + "case": "contains hovered state when hovered", + "covers": ["state:hovered"] + }, + { + "test": "test/components/toggle/toggle_widget_test.dart", + "case": "contains pressed state when pressed", + "covers": ["state:pressed"] + }, + { + "test": "test/components/toggle/toggle_widget_test.dart", + "case": "contains focused state when focused", + "covers": ["state:focused"] + }, + { + "test": "test/components/toggle/toggle_widget_test.dart", + "case": "contains disabled state when enabled is false", + "covers": ["state:disabled"] + }, + { + "test": "test/fortal/fortal_high_contrast_test.dart", + "case": "selected toggle variants resolve accent12 foregrounds", + "covers": ["state:highContrast"] + } + ], + "toggle_group": [ + { + "test": "test/components/toggle_group/toggle_group_style_test.dart", + "case": "all Fortal variant and size combinations resolve", + "covers": [ + "enum:size.size1", + "enum:size.size2", + "enum:size.size3", + "enum:variant.soft", + "enum:variant.surface" + ] + }, + { + "test": "test/components/toggle_group/toggle_group_widget_test.dart", + "case": "styleSpec preserves vertical layout orientation", + "covers": ["enum:orientation.horizontal", "enum:orientation.vertical"] + }, + { + "test": "test/components/toggle_group/toggle_group_widget_test.dart", + "case": "selected style is removed when selection changes", + "covers": ["state:unselected", "state:selected"] + }, + { + "test": "test/components/toggle_group/toggle_group_style_test.dart", + "case": "Fortal hover and focus resolve exact interaction roles", + "covers": ["state:hovered", "state:focused"] + }, + { + "test": "test/components/toggle_group/toggle_group_style_test.dart", + "case": "Fortal pressed and disabled precedence is deterministic", + "covers": ["state:pressed", "state:disabled"] + }, + { + "test": "test/components/toggle_group/toggle_group_style_test.dart", + "case": "Fortal high contrast uses accent12 for selected foreground", + "covers": ["state:highContrast"] + }, + { + "test": "test/components/toggle_group/toggle_group_widget_test.dart", + "case": "RTL inverts horizontal arrow direction", + "covers": ["state:rtl"] + } + ] +} diff --git a/packages/remix/reference/radix_themes_3_3_0/manifest.json b/packages/remix/reference/radix_themes_3_3_0/manifest.json new file mode 100644 index 00000000..e34a1369 --- /dev/null +++ b/packages/remix/reference/radix_themes_3_3_0/manifest.json @@ -0,0 +1,4243 @@ +{ + "$schema": "./manifest.schema.json", + "contractVersion": 1, + "coverageEvidence": "reference/radix_themes_3_3_0/coverage_evidence.json", + "source": { + "package": "@radix-ui/themes", + "version": "3.3.0", + "integrity": "sha512-I0/h2CRNTpYNB7Mi3xFIvSsQq5a108d7kK8dTO5zp5b9HR5QJXKag6B8tjpz2ITkVYkFdkGk45doNkSr7OxwNw==", + "tarball": "https://registry.npmjs.org/@radix-ui/themes/-/themes-3.3.0.tgz" + }, + "theme": { + "enums": { + "accent": { + "values": [ + "gray", + "mauve", + "slate", + "sage", + "olive", + "sand", + "gold", + "bronze", + "brown", + "yellow", + "amber", + "orange", + "tomato", + "red", + "ruby", + "crimson", + "pink", + "plum", + "purple", + "violet", + "iris", + "indigo", + "blue", + "cyan", + "teal", + "jade", + "green", + "grass", + "lime", + "mint", + "sky" + ], + "default": "indigo" + }, + "gray": { + "values": [ + "gray", + "mauve", + "slate", + "sage", + "olive", + "sand" + ], + "default": "slate" + }, + "brightness": { + "values": [ + "light", + "dark" + ], + "default": "light" + }, + "panelBackground": { + "values": [ + "solid", + "translucent" + ], + "default": "translucent" + }, + "radius": { + "values": [ + "none", + "small", + "medium", + "large", + "full" + ], + "default": "medium" + }, + "scaling": { + "values": [ + "90%", + "95%", + "100%", + "105%", + "110%" + ], + "default": "100%" + } + }, + "resolvedRootDefaults": { + "accent": "indigo", + "gray": "slate", + "brightness": "platform brightness", + "panelBackground": "translucent", + "radius": "medium", + "scaling": "100%", + "hasBackground": true, + "panelBlurLogicalPixels": 64 + }, + "sourceFiles": [ + "src/components/theme.props.tsx", + "src/components/theme.tsx", + "src/helpers/get-matching-gray-color.ts", + "src/styles/tokens/radius.css", + "src/styles/tokens/scaling.css", + "src/styles/tokens/shadow.css" + ], + "upstreamInventory": { + "enums": { + "appearance": { + "values": [ + "inherit", + "light", + "dark" + ], + "default": "inherit" + }, + "accentColor": { + "values": [ + "gray", + "gold", + "bronze", + "brown", + "yellow", + "amber", + "orange", + "tomato", + "red", + "ruby", + "crimson", + "pink", + "plum", + "purple", + "violet", + "iris", + "indigo", + "blue", + "cyan", + "teal", + "jade", + "green", + "grass", + "lime", + "mint", + "sky" + ], + "default": "indigo" + }, + "grayColor": { + "values": [ + "auto", + "gray", + "mauve", + "slate", + "sage", + "olive", + "sand" + ], + "default": "auto" + }, + "panelBackground": { + "values": [ + "solid", + "translucent" + ], + "default": "translucent" + }, + "radius": { + "values": [ + "none", + "small", + "medium", + "large", + "full" + ], + "default": "medium" + }, + "scaling": { + "values": [ + "90%", + "95%", + "100%", + "105%", + "110%" + ], + "default": "100%" + } + }, + "autoGray": { + "gray": "gray", + "gold": "sand", + "bronze": "sand", + "brown": "sand", + "yellow": "sand", + "amber": "sand", + "orange": "sand", + "tomato": "mauve", + "red": "mauve", + "ruby": "mauve", + "crimson": "mauve", + "pink": "mauve", + "plum": "mauve", + "purple": "mauve", + "violet": "mauve", + "iris": "slate", + "indigo": "slate", + "blue": "slate", + "cyan": "slate", + "teal": "sage", + "jade": "sage", + "green": "sage", + "grass": "olive", + "lime": "olive", + "mint": "sage", + "sky": "slate" + }, + "states": [ + "rootPlatformLight", + "rootPlatformDark", + "nestedInherit", + "nestedExplicitAuto", + "paintedBackground", + "translucentPanel", + "solidPanel" + ] + }, + "supportedVisualStates": [ + "rootDefaultLight", + "explicitDark", + "nestedInherit", + "paintedBackground", + "translucentPanel", + "solidPanel" + ], + "states": [ + "rootDefaultLight", + "explicitDark", + "nestedInherit", + "paintedBackground", + "translucentPanel", + "solidPanel" + ], + "coverage": { + "enums": [ + "accent.gray", + "accent.mauve", + "accent.slate", + "accent.sage", + "accent.olive", + "accent.sand", + "accent.gold", + "accent.bronze", + "accent.brown", + "accent.yellow", + "accent.amber", + "accent.orange", + "accent.tomato", + "accent.red", + "accent.ruby", + "accent.crimson", + "accent.pink", + "accent.plum", + "accent.purple", + "accent.violet", + "accent.iris", + "accent.indigo", + "accent.blue", + "accent.cyan", + "accent.teal", + "accent.jade", + "accent.green", + "accent.grass", + "accent.lime", + "accent.mint", + "accent.sky", + "gray.gray", + "gray.mauve", + "gray.slate", + "gray.sage", + "gray.olive", + "gray.sand", + "brightness.light", + "brightness.dark", + "panelBackground.solid", + "panelBackground.translucent", + "radius.none", + "radius.small", + "radius.medium", + "radius.large", + "radius.full", + "scaling.90%", + "scaling.95%", + "scaling.100%", + "scaling.105%", + "scaling.110%" + ], + "states": [ + "rootDefaultLight", + "explicitDark", + "nestedInherit", + "paintedBackground", + "translucentPanel", + "solidPanel" + ], + "tests": [ + "test/fortal/fortal_theme_resolution_test.dart", + "test/fortal/fortal_tokens_test.dart" + ] + } + }, + "commonFlutterExceptions": [ + { + "upstream": "asChild and HTML element polymorphism", + "flutter": "Widget composition and builder APIs", + "reason": "Flutter has no DOM element or Slot cloning model." + }, + { + "upstream": "CSS margin props and responsive-object syntax", + "flutter": "Parent layout widgets and concrete enum values", + "reason": "Layout ownership and responsive resolution are Flutter-native concerns." + }, + { + "upstream": "CSS pixels and browser rasterization", + "flutter": "Logical pixels and Flutter engine rasterization", + "reason": "Resolved geometry and colors are normative; edge anti-aliasing may differ by one 8-bit channel." + } + ], + "families": [ + { + "id": "avatar", + "fortalType": "FortalAvatar", + "radixComponent": "Avatar", + "parity": "mapped", + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3", + "size4", + "size5", + "size6", + "size7", + "size8", + "size9" + ], + "default": "size3" + }, + "variant": { + "values": [ + "solid", + "soft" + ], + "default": "soft" + } + }, + "defaults": { + "highContrast": false + }, + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ], + "states": [ + "idle", + "loading", + "loaded", + "error", + "highContrast" + ], + "sourceFiles": [ + "src/components/avatar.props.tsx", + "src/components/avatar.css", + "src/components/avatar.tsx" + ], + "sourceSelectors": [ + ".rt-AvatarRoot", + ".rt-AvatarImage", + ".rt-AvatarFallback" + ], + "flutterExceptions": [ + "ImageProvider and Widget fallback replace DOM src/fallback nodes; no ring prop is exposed." + ], + "coverage": { + "enums": [ + "size.size1", + "size.size2", + "size.size3", + "size.size4", + "size.size5", + "size.size6", + "size.size7", + "size.size8", + "size.size9", + "variant.solid", + "variant.soft" + ], + "states": [ + "idle", + "loading", + "loaded", + "error", + "highContrast" + ], + "tests": [ + "test/components/avatar/avatar_fortal_parity_test.dart", + "test/components/avatar/avatar_widget_test.dart" + ] + }, + "upstreamInventory": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3", + "size4", + "size5", + "size6", + "size7", + "size8", + "size9" + ], + "default": "size3" + }, + "variant": { + "values": [ + "solid", + "soft" + ], + "default": "soft" + } + }, + "states": [ + "idle", + "loading", + "loaded", + "error", + "highContrast" + ], + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ] + }, + "supportedVisualStates": [ + "idle", + "loading", + "loaded", + "error", + "highContrast" + ], + "deferredCapabilities": [], + "visualMapping": { + "color": "FortalScope.accent", + "radius": "FortalScope.radius", + "highContrast": "direct fortalXStyler recipe", + "variant": "generated Fortal constructor", + "size": "generated Fortal constructor" + } + }, + { + "id": "badge", + "fortalType": "FortalBadge", + "radixComponent": "Badge", + "parity": "mapped", + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size1" + }, + "variant": { + "values": [ + "solid", + "soft", + "surface", + "outline" + ], + "default": "soft" + } + }, + "defaults": { + "highContrast": false + }, + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ], + "states": [ + "idle", + "highContrast" + ], + "sourceFiles": [ + "src/components/badge.props.tsx", + "src/components/badge.css" + ], + "sourceSelectors": [ + ".rt-Badge" + ], + "flutterExceptions": [ + "Arbitrary Widget content uses DefaultTextStyle and IconTheme; there is no dedicated icon API." + ], + "coverage": { + "enums": [ + "size.size1", + "size.size2", + "size.size3", + "variant.solid", + "variant.soft", + "variant.surface", + "variant.outline" + ], + "states": [ + "idle", + "highContrast" + ], + "tests": [ + "test/components/badge/badge_fortal_parity_test.dart", + "test/components/badge/badge_widget_test.dart", + "test/fortal/fortal_high_contrast_test.dart" + ] + }, + "upstreamInventory": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size1" + }, + "variant": { + "values": [ + "solid", + "soft", + "surface", + "outline" + ], + "default": "soft" + } + }, + "states": [ + "idle", + "highContrast" + ], + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ] + }, + "supportedVisualStates": [ + "idle", + "highContrast" + ], + "deferredCapabilities": [], + "visualMapping": { + "color": "FortalScope.accent", + "radius": "FortalScope.radius", + "highContrast": "direct fortalXStyler recipe", + "variant": "generated Fortal constructor", + "size": "generated Fortal constructor" + } + }, + { + "id": "button", + "fortalType": "FortalButton", + "radixComponent": "Button", + "parity": "mapped", + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3", + "size4" + ], + "default": "size2" + }, + "variant": { + "values": [ + "classic", + "solid", + "soft", + "surface", + "outline", + "ghost" + ], + "default": "solid" + } + }, + "defaults": { + "highContrast": false, + "loading": false, + "enabled": true + }, + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ], + "states": [ + "idle", + "hovered", + "pressed", + "focused", + "disabled", + "loading", + "highContrast" + ], + "sourceFiles": [ + "src/components/_internal/base-button.props.ts", + "src/components/_internal/base-button.css", + "src/components/button.css" + ], + "sourceSelectors": [ + ".rt-BaseButton", + ".rt-Button", + ".rt-loading", + ".rt-high-contrast" + ], + "flutterExceptions": [ + "The visual recipe maps Radix size, variant, and states onto the established required-label Remix button; arbitrary child content is deferred." + ], + "coverage": { + "enums": [ + "size.size1", + "size.size2", + "size.size3", + "size.size4", + "variant.classic", + "variant.solid", + "variant.soft", + "variant.surface", + "variant.outline", + "variant.ghost" + ], + "states": [ + "idle", + "hovered", + "pressed", + "focused", + "disabled", + "loading", + "highContrast" + ], + "tests": [ + "test/components/button/button_widget_test.dart", + "test/components/button/button_style_test.dart", + "test/fortal/fortal_high_contrast_test.dart" + ] + }, + "upstreamInventory": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3", + "size4" + ], + "default": "size2" + }, + "variant": { + "values": [ + "classic", + "solid", + "soft", + "surface", + "outline", + "ghost" + ], + "default": "solid" + } + }, + "states": [ + "idle", + "hovered", + "pressed", + "focused", + "disabled", + "loading", + "highContrast" + ], + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ] + }, + "supportedVisualStates": [ + "idle", + "hovered", + "pressed", + "focused", + "disabled", + "loading", + "highContrast" + ], + "deferredCapabilities": [ + "arbitrary child content" + ], + "visualMapping": { + "color": "FortalScope.accent", + "radius": "FortalScope.radius", + "highContrast": "direct fortalXStyler recipe", + "variant": "generated Fortal constructor", + "size": "generated Fortal constructor" + } + }, + { + "id": "callout", + "fortalType": "FortalCallout", + "radixComponent": "Callout.Root", + "parity": "mapped", + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "soft", + "surface", + "outline" + ], + "default": "soft" + } + }, + "defaults": { + "highContrast": false + }, + "supportedStyleProps": [ + "color", + "highContrast" + ], + "states": [ + "idle", + "highContrast" + ], + "sourceFiles": [ + "src/components/callout.props.tsx", + "src/components/callout.css" + ], + "sourceSelectors": [ + ".rt-CalloutRoot", + ".rt-CalloutIcon", + ".rt-CalloutText" + ], + "flutterExceptions": [ + "Widget icon/content slots replace the upstream compound React nodes." + ], + "coverage": { + "enums": [ + "size.size1", + "size.size2", + "size.size3", + "variant.soft", + "variant.surface", + "variant.outline" + ], + "states": [ + "idle", + "highContrast" + ], + "tests": [ + "test/components/callout/callout_fortal_parity_test.dart", + "test/components/callout/callout_widget_test.dart", + "test/fortal/fortal_high_contrast_test.dart" + ] + }, + "upstreamInventory": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "soft", + "surface", + "outline" + ], + "default": "soft" + } + }, + "states": [ + "idle", + "highContrast" + ], + "supportedStyleProps": [ + "color", + "highContrast" + ] + }, + "supportedVisualStates": [ + "idle", + "highContrast" + ], + "deferredCapabilities": [], + "visualMapping": { + "color": "FortalScope.accent", + "radius": "FortalScope.radius", + "highContrast": "direct fortalXStyler recipe", + "variant": "generated Fortal constructor", + "size": "generated Fortal constructor" + } + }, + { + "id": "card", + "fortalType": "FortalCard", + "radixComponent": "Card", + "parity": "mapped", + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3", + "size4", + "size5" + ], + "default": "size1" + }, + "variant": { + "values": [ + "surface", + "classic", + "ghost" + ], + "default": "surface" + } + }, + "defaults": { + "enabled": true + }, + "supportedStyleProps": [], + "states": [ + "idle", + "passive" + ], + "sourceFiles": [ + "src/components/card.props.tsx", + "src/components/card.css", + "src/components/_internal/base-card.css" + ], + "sourceSelectors": [ + ".rt-BaseCard", + ".rt-Card" + ], + "flutterExceptions": [ + "The visual recipe maps Radix size and variant onto the established passive Remix card." + ], + "coverage": { + "enums": [ + "size.size1", + "size.size2", + "size.size3", + "size.size4", + "size.size5", + "variant.surface", + "variant.classic", + "variant.ghost" + ], + "states": [ + "idle", + "passive" + ], + "tests": [ + "test/components/card/card_fortal_parity_test.dart", + "test/components/card/card_widget_test.dart" + ] + }, + "upstreamInventory": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3", + "size4", + "size5" + ], + "default": "size1" + }, + "variant": { + "values": [ + "surface", + "classic", + "ghost" + ], + "default": "surface" + } + }, + "states": [ + "idle", + "hovered", + "pressed", + "focused", + "open", + "disabled", + "passive" + ], + "supportedStyleProps": [] + }, + "supportedVisualStates": [ + "idle", + "passive" + ], + "deferredCapabilities": [ + "interactive card behavior" + ], + "visualMapping": { + "color": "FortalScope.accent", + "radius": "FortalScope.radius", + "highContrast": "direct fortalXStyler recipe", + "variant": "generated Fortal constructor", + "size": "generated Fortal constructor" + } + }, + { + "id": "checkbox", + "fortalType": "FortalCheckbox", + "radixComponent": "Checkbox", + "parity": "mapped", + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "classic", + "surface", + "soft" + ], + "default": "surface" + } + }, + "defaults": { + "selected": false, + "tristate": false, + "highContrast": false, + "enabled": true + }, + "supportedStyleProps": [ + "color", + "highContrast" + ], + "states": [ + "unchecked", + "checked", + "indeterminate", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast" + ], + "sourceFiles": [ + "src/components/_internal/base-checkbox.props.ts", + "src/components/_internal/base-checkbox.css", + "src/components/checkbox.css" + ], + "sourceSelectors": [ + ".rt-BaseCheckboxRoot", + ".rt-CheckboxRoot", + ".rt-CheckboxIndicator" + ], + "flutterExceptions": [ + "Nullable bool plus tristate maps the Radix indeterminate checked state." + ], + "coverage": { + "enums": [ + "size.size1", + "size.size2", + "size.size3", + "variant.classic", + "variant.surface", + "variant.soft" + ], + "states": [ + "unchecked", + "checked", + "indeterminate", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast" + ], + "tests": [ + "test/components/checkbox/checkbox_widget_test.dart", + "test/components/checkbox/checkbox_style_test.dart", + "test/fortal/fortal_control_matrix_test.dart", + "test/fortal/fortal_high_contrast_test.dart" + ] + }, + "upstreamInventory": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "classic", + "surface", + "soft" + ], + "default": "surface" + } + }, + "states": [ + "unchecked", + "checked", + "indeterminate", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast" + ], + "supportedStyleProps": [ + "color", + "highContrast" + ] + }, + "supportedVisualStates": [ + "unchecked", + "checked", + "indeterminate", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast" + ], + "deferredCapabilities": [], + "visualMapping": { + "color": "FortalScope.accent", + "radius": "FortalScope.radius", + "highContrast": "direct fortalXStyler recipe", + "variant": "generated Fortal constructor", + "size": "generated Fortal constructor" + } + }, + { + "id": "dialog", + "fortalType": "FortalDialog", + "radixComponent": "Dialog.Content", + "parity": "mapped", + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3", + "size4" + ], + "default": "size3" + }, + "align": { + "values": [ + "start", + "center" + ], + "default": "center" + } + }, + "defaults": { + "width": "infinity", + "maxWidth": 600, + "modal": true + }, + "supportedStyleProps": [], + "states": [ + "closed", + "open", + "modal" + ], + "sourceFiles": [ + "src/components/dialog.props.tsx", + "src/components/dialog.css", + "src/components/_internal/base-dialog.css" + ], + "sourceSelectors": [ + ".rt-BaseDialogContent", + ".rt-DialogContent", + ".rt-DialogOverlay" + ], + "flutterExceptions": [ + "Logical-pixel BoxConstraints replace CSS width and height strings; inherited Fortal and Mix scopes are replayed through Navigator overlays; modal controls accessibility route scoping while the Flutter Navigator route remains pointer-modal." + ], + "coverage": { + "enums": [ + "size.size1", + "size.size2", + "size.size3", + "size.size4", + "align.start", + "align.center" + ], + "states": [ + "closed", + "open", + "modal" + ], + "tests": [ + "test/components/dialog/dialog_widget_test.dart" + ] + }, + "upstreamInventory": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3", + "size4" + ], + "default": "size3" + }, + "align": { + "values": [ + "start", + "center" + ], + "default": "center" + } + }, + "states": [ + "closed", + "open", + "entering", + "exiting", + "modal", + "nonModal" + ], + "supportedStyleProps": [] + }, + "supportedVisualStates": [ + "closed", + "open", + "modal" + ], + "deferredCapabilities": [], + "visualMapping": { + "color": "FortalScope.accent", + "radius": "FortalScope.radius", + "highContrast": "direct fortalXStyler recipe", + "variant": "generated Fortal constructor", + "size": "generated Fortal constructor" + } + }, + { + "id": "divider", + "fortalType": "FortalDivider", + "radixComponent": "Separator", + "parity": "mapped", + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3", + "size4" + ], + "default": "size1" + }, + "orientation": { + "values": [ + "horizontal", + "vertical" + ], + "default": "horizontal" + } + }, + "defaults": { + "color": "gray", + "decorative": true, + "thickness": 1, + "lengths": [ + 16, + 32, + 64, + "full" + ] + }, + "supportedStyleProps": [ + "color" + ], + "states": [ + "decorative", + "semantic" + ], + "sourceFiles": [ + "src/components/separator.props.tsx", + "src/components/separator.css" + ], + "sourceSelectors": [ + ".rt-Separator" + ], + "flutterExceptions": [ + "Axis and separator Semantics replace DOM orientation and role attributes." + ], + "coverage": { + "enums": [ + "size.size1", + "size.size2", + "size.size3", + "size.size4", + "orientation.horizontal", + "orientation.vertical" + ], + "states": [ + "decorative", + "semantic" + ], + "tests": [ + "test/components/divider/divider_widget_test.dart", + "test/components/divider/divider_style_test.dart" + ] + }, + "upstreamInventory": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3", + "size4" + ], + "default": "size1" + }, + "orientation": { + "values": [ + "horizontal", + "vertical" + ], + "default": "horizontal" + } + }, + "states": [ + "decorative", + "semantic" + ], + "supportedStyleProps": [ + "color" + ] + }, + "supportedVisualStates": [ + "decorative", + "semantic" + ], + "deferredCapabilities": [], + "visualMapping": { + "color": "FortalScope.accent", + "radius": "FortalScope.radius", + "highContrast": "direct fortalXStyler recipe", + "variant": "generated Fortal constructor", + "size": "generated Fortal constructor" + } + }, + { + "id": "menu", + "fortalType": "FortalMenu", + "radixComponent": "DropdownMenu", + "parity": "mapped", + "enums": { + "size": { + "values": [ + "size1", + "size2" + ], + "default": "size2" + }, + "variant": { + "values": [ + "solid", + "soft" + ], + "default": "solid" + } + }, + "defaults": { + "highContrast": false, + "submenuOpenDelayMilliseconds": 100 + }, + "supportedStyleProps": [ + "color", + "highContrast" + ], + "states": [ + "closed", + "open", + "highlighted", + "disabled", + "escape", + "focusRestored", + "highContrast" + ], + "sourceFiles": [ + "src/components/_internal/base-menu.props.ts", + "src/components/_internal/base-menu.css", + "src/components/dropdown-menu.css" + ], + "sourceSelectors": [ + ".rt-BaseMenuContent", + ".rt-DropdownMenuContent", + ".rt-BaseMenuItem", + ".rt-BaseMenuCheckboxItem", + ".rt-BaseMenuRadioItem", + ".rt-BaseMenuSubTrigger" + ], + "flutterExceptions": [ + "The visual recipe maps supported Radix states onto the established data-driven RemixMenuItem list; compound menu items are deferred." + ], + "coverage": { + "enums": [ + "size.size1", + "size.size2", + "variant.solid", + "variant.soft" + ], + "states": [ + "closed", + "open", + "highlighted", + "disabled", + "escape", + "focusRestored", + "highContrast" + ], + "tests": [ + "test/components/menu/menu_widget_test.dart", + "test/components/menu/menu_style_test.dart" + ] + }, + "upstreamInventory": { + "enums": { + "size": { + "values": [ + "size1", + "size2" + ], + "default": "size2" + }, + "variant": { + "values": [ + "solid", + "soft" + ], + "default": "solid" + } + }, + "states": [ + "closed", + "open", + "highlighted", + "disabled", + "checked", + "unchecked", + "submenuOpen", + "rtl", + "escape", + "focusRestored", + "highContrast" + ], + "supportedStyleProps": [ + "color", + "highContrast" + ] + }, + "supportedVisualStates": [ + "closed", + "open", + "highlighted", + "disabled", + "escape", + "focusRestored", + "highContrast" + ], + "deferredCapabilities": [ + "compositional items", + "checkbox items", + "radio items", + "submenus" + ], + "visualMapping": { + "color": "FortalScope.accent", + "radius": "FortalScope.radius", + "highContrast": "direct fortalXStyler recipe", + "variant": "generated Fortal constructor", + "size": "generated Fortal constructor" + } + }, + { + "id": "icon_button", + "fortalType": "FortalIconButton", + "radixComponent": "IconButton", + "parity": "mapped", + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3", + "size4" + ], + "default": "size2" + }, + "variant": { + "values": [ + "classic", + "solid", + "soft", + "surface", + "outline", + "ghost" + ], + "default": "solid" + } + }, + "defaults": { + "highContrast": false, + "loading": false, + "enabled": true, + "semanticLabel": "required" + }, + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ], + "states": [ + "idle", + "hovered", + "pressed", + "focused", + "disabled", + "loading", + "highContrast" + ], + "sourceFiles": [ + "src/components/icon-button.props.tsx", + "src/components/icon-button.css", + "src/components/_internal/base-button.css" + ], + "sourceSelectors": [ + ".rt-BaseButton", + ".rt-IconButton" + ], + "flutterExceptions": [ + "A required semanticLabel supplies the accessible name that icon-only DOM buttons receive from aria-label." + ], + "coverage": { + "enums": [ + "size.size1", + "size.size2", + "size.size3", + "size.size4", + "variant.classic", + "variant.solid", + "variant.soft", + "variant.surface", + "variant.outline", + "variant.ghost" + ], + "states": [ + "idle", + "hovered", + "pressed", + "focused", + "disabled", + "loading", + "highContrast" + ], + "tests": [ + "test/components/icon_button/icon_button_widget_test.dart", + "test/components/icon_button/icon_button_style_test.dart", + "test/fortal/fortal_control_matrix_test.dart", + "test/fortal/fortal_high_contrast_test.dart" + ] + }, + "upstreamInventory": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3", + "size4" + ], + "default": "size2" + }, + "variant": { + "values": [ + "classic", + "solid", + "soft", + "surface", + "outline", + "ghost" + ], + "default": "solid" + } + }, + "states": [ + "idle", + "hovered", + "pressed", + "focused", + "disabled", + "loading", + "highContrast" + ], + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ] + }, + "supportedVisualStates": [ + "idle", + "hovered", + "pressed", + "focused", + "disabled", + "loading", + "highContrast" + ], + "deferredCapabilities": [], + "visualMapping": { + "color": "FortalScope.accent", + "radius": "FortalScope.radius", + "highContrast": "direct fortalXStyler recipe", + "variant": "generated Fortal constructor", + "size": "generated Fortal constructor" + } + }, + { + "id": "popover", + "fortalType": "FortalPopover", + "radixComponent": "Popover.Content", + "parity": "mapped", + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3", + "size4" + ], + "default": "size2" + } + }, + "defaults": { + "maxWidth": 480, + "arrow": false + }, + "supportedStyleProps": [], + "states": [ + "closed", + "open", + "escape", + "outsideTap", + "focusRestored" + ], + "sourceFiles": [ + "src/components/popover.props.tsx", + "src/components/popover.css" + ], + "sourceSelectors": [ + ".rt-PopoverContent", + ".rt-PopperContent" + ], + "flutterExceptions": [ + "A separate anchor Widget and OverlayPositionConfig replace Radix anchor and collision props; Fortal intentionally renders no Popover arrow." + ], + "coverage": { + "enums": [ + "size.size1", + "size.size2", + "size.size3", + "size.size4" + ], + "states": [ + "closed", + "open", + "escape", + "outsideTap", + "focusRestored" + ], + "tests": [ + "test/components/popover/popover_widget_test.dart" + ] + }, + "upstreamInventory": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3", + "size4" + ], + "default": "size2" + } + }, + "states": [ + "closed", + "open", + "hovered", + "escape", + "outsideTap", + "focusRestored", + "separateAnchor", + "collisionFlipped", + "collisionShifted" + ], + "supportedStyleProps": [] + }, + "supportedVisualStates": [ + "closed", + "open", + "escape", + "outsideTap", + "focusRestored" + ], + "deferredCapabilities": [], + "visualMapping": { + "color": "FortalScope.accent", + "radius": "FortalScope.radius", + "highContrast": "direct fortalXStyler recipe", + "variant": "generated Fortal constructor", + "size": "generated Fortal constructor" + } + }, + { + "id": "progress", + "fortalType": "FortalProgress", + "radixComponent": "Progress", + "parity": "mapped", + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "classic", + "surface", + "soft" + ], + "default": "surface" + } + }, + "defaults": { + "value": null, + "max": 100, + "durationMilliseconds": 5000, + "highContrast": false + }, + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ], + "states": [ + "determinate", + "highContrast" + ], + "sourceFiles": [ + "src/components/progress.props.tsx", + "src/components/progress.css", + "src/components/progress.tsx" + ], + "sourceSelectors": [ + ".rt-ProgressRoot", + ".rt-ProgressIndicator" + ], + "flutterExceptions": [ + "The visual recipe maps Radix styling onto the established normalized determinate progress value." + ], + "coverage": { + "enums": [ + "size.size1", + "size.size2", + "size.size3", + "variant.classic", + "variant.surface", + "variant.soft" + ], + "states": [ + "determinate", + "highContrast" + ], + "tests": [ + "test/components/progress/progress_widget_test.dart", + "test/components/progress/progress_style_test.dart", + "test/fortal/fortal_control_matrix_test.dart", + "test/fortal/fortal_high_contrast_test.dart" + ] + }, + "upstreamInventory": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "classic", + "surface", + "soft" + ], + "default": "surface" + } + }, + "states": [ + "determinate", + "indeterminate", + "animationDisabled", + "highContrast" + ], + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ] + }, + "supportedVisualStates": [ + "determinate", + "highContrast" + ], + "deferredCapabilities": [ + "indeterminate mode", + "custom maximum", + "animation controls" + ], + "visualMapping": { + "color": "FortalScope.accent", + "radius": "FortalScope.radius", + "highContrast": "direct fortalXStyler recipe", + "variant": "generated Fortal constructor", + "size": "generated Fortal constructor" + } + }, + { + "id": "radio", + "fortalType": "FortalRadio", + "radixComponent": "RadioGroup.Item", + "parity": "mapped", + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "classic", + "surface", + "soft" + ], + "default": "surface" + } + }, + "defaults": { + "highContrast": false, + "enabled": true + }, + "supportedStyleProps": [ + "color", + "highContrast" + ], + "states": [ + "unchecked", + "checked", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast", + "group" + ], + "sourceFiles": [ + "src/components/_internal/base-radio.props.ts", + "src/components/_internal/base-radio.css", + "src/components/radio.css" + ], + "sourceSelectors": [ + ".rt-BaseRadioRoot", + ".rt-RadioRoot", + ".rt-RadioIndicator" + ], + "flutterExceptions": [ + "FortalRadioGroup provides typed Flutter group semantics and selection coordination." + ], + "coverage": { + "enums": [ + "size.size1", + "size.size2", + "size.size3", + "variant.classic", + "variant.surface", + "variant.soft" + ], + "states": [ + "unchecked", + "checked", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast", + "group" + ], + "tests": [ + "test/components/radio/radio_widget_test.dart", + "test/components/radio/radio_style_test.dart", + "test/fortal/fortal_control_matrix_test.dart", + "test/fortal/fortal_high_contrast_test.dart" + ] + }, + "upstreamInventory": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "classic", + "surface", + "soft" + ], + "default": "surface" + } + }, + "states": [ + "unchecked", + "checked", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast", + "group" + ], + "supportedStyleProps": [ + "color", + "highContrast" + ] + }, + "supportedVisualStates": [ + "unchecked", + "checked", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast", + "group" + ], + "deferredCapabilities": [], + "visualMapping": { + "color": "FortalScope.accent", + "radius": "FortalScope.radius", + "highContrast": "direct fortalXStyler recipe", + "variant": "generated Fortal constructor", + "size": "generated Fortal constructor" + } + }, + { + "id": "select", + "fortalType": "FortalSelect", + "radixComponent": "Select", + "parity": "mapped", + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "surface", + "soft", + "ghost" + ], + "default": "surface" + } + }, + "defaults": { + "contentHighContrast": false, + "open": null + }, + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ], + "states": [ + "closed", + "open", + "placeholder", + "selected", + "highlighted", + "disabled", + "highContrast" + ], + "sourceFiles": [ + "src/components/select.props.tsx", + "src/components/select.css", + "src/components/select.tsx" + ], + "sourceSelectors": [ + ".rt-SelectTrigger", + ".rt-SelectContent", + ".rt-SelectItem", + ".rt-SelectLabel", + ".rt-SelectSeparator" + ], + "flutterExceptions": [ + "The visual recipe maps supported Radix states onto the established data-driven RemixSelectItem list and single historical variant." + ], + "coverage": { + "enums": [ + "size.size1", + "size.size2", + "size.size3", + "variant.surface", + "variant.soft", + "variant.ghost" + ], + "states": [ + "closed", + "open", + "placeholder", + "selected", + "highlighted", + "disabled", + "highContrast" + ], + "tests": [ + "test/components/select/select_fortal_parity_test.dart", + "test/components/select/select_widget_test.dart", + "test/fortal/fortal_high_contrast_test.dart" + ] + }, + "upstreamInventory": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "triggerVariant": { + "values": [ + "classic", + "surface", + "soft", + "ghost" + ], + "default": "surface" + }, + "contentVariant": { + "values": [ + "solid", + "soft" + ], + "default": "solid" + } + }, + "states": [ + "closed", + "open", + "placeholder", + "selected", + "highlighted", + "disabled", + "grouped", + "separated", + "ownerRejected", + "collisionFlipped", + "collisionShifted", + "highContrast" + ], + "supportedStyleProps": [ + "triggerColor", + "triggerRadius", + "contentColor", + "contentHighContrast" + ] + }, + "supportedVisualStates": [ + "closed", + "open", + "placeholder", + "selected", + "highlighted", + "disabled", + "highContrast" + ], + "deferredCapabilities": [ + "structured content", + "independent trigger/content variants" + ], + "visualMapping": { + "color": "FortalScope.accent", + "radius": "FortalScope.radius", + "highContrast": "direct fortalXStyler recipe", + "variant": "generated Fortal constructor", + "size": "generated Fortal constructor" + } + }, + { + "id": "slider", + "fortalType": "FortalSlider", + "radixComponent": "Slider", + "parity": "mapped", + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "classic", + "surface", + "soft" + ], + "default": "surface" + } + }, + "defaults": { + "min": 0, + "max": 100, + "step": 1, + "minSpacing": 0, + "inverted": false, + "highContrast": false + }, + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ], + "states": [ + "idle", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast", + "rtl" + ], + "sourceFiles": [ + "src/components/slider.props.tsx", + "src/components/slider.css", + "src/components/slider.tsx" + ], + "sourceSelectors": [ + ".rt-SliderRoot", + ".rt-SliderTrack", + ".rt-SliderRange", + ".rt-SliderThumb" + ], + "flutterExceptions": [ + "The visual recipe maps Radix styling onto the established scalar horizontal slider; range and multi-thumb behavior is deferred." + ], + "coverage": { + "enums": [ + "size.size1", + "size.size2", + "size.size3", + "variant.classic", + "variant.surface", + "variant.soft" + ], + "states": [ + "idle", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast", + "rtl" + ], + "tests": [ + "test/components/slider/slider_widget_test.dart", + "test/components/slider/slider_style_test.dart", + "test/fortal/fortal_high_contrast_test.dart" + ] + }, + "upstreamInventory": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "classic", + "surface", + "soft" + ], + "default": "surface" + }, + "orientation": { + "values": [ + "horizontal", + "vertical" + ], + "default": "horizontal" + } + }, + "states": [ + "idle", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast", + "multipleThumbs", + "rtl", + "inverted", + "ownerRejected" + ], + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ] + }, + "supportedVisualStates": [ + "idle", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast", + "rtl" + ], + "deferredCapabilities": [ + "multiple thumbs", + "range values", + "vertical orientation", + "inversion" + ], + "visualMapping": { + "color": "FortalScope.accent", + "radius": "FortalScope.radius", + "highContrast": "direct fortalXStyler recipe", + "variant": "generated Fortal constructor", + "size": "generated Fortal constructor" + } + }, + { + "id": "spinner", + "fortalType": "FortalSpinner", + "radixComponent": "Spinner", + "parity": "mapped", + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + } + }, + "defaults": { + "loading": true, + "leafCount": 8, + "color": "currentColor" + }, + "supportedStyleProps": [], + "states": [ + "spinning", + "animationDisabled" + ], + "sourceFiles": [ + "src/components/spinner.props.tsx", + "src/components/spinner.css", + "src/components/spinner.tsx" + ], + "sourceSelectors": [ + ".rt-Spinner", + ".rt-SpinnerLeaf" + ], + "flutterExceptions": [ + "A leaf painter maps the Radix spinner geometry; loading gates and child overlays are deferred." + ], + "coverage": { + "enums": [ + "size.size1", + "size.size2", + "size.size3" + ], + "states": [ + "spinning", + "animationDisabled" + ], + "tests": [ + "test/components/spinner/spinner_widget_test.dart", + "test/components/spinner/spinner_style_test.dart", + "test/fortal/fortal_control_matrix_test.dart" + ] + }, + "upstreamInventory": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + } + }, + "states": [ + "loading", + "notLoading", + "withChild", + "withoutChild", + "animationDisabled" + ], + "supportedStyleProps": [] + }, + "supportedVisualStates": [ + "spinning", + "animationDisabled" + ], + "deferredCapabilities": [ + "loading gate", + "child overlay" + ], + "visualMapping": { + "color": "FortalScope.accent", + "radius": "FortalScope.radius", + "highContrast": "direct fortalXStyler recipe", + "variant": "generated Fortal constructor", + "size": "generated Fortal constructor" + } + }, + { + "id": "switch", + "fortalType": "FortalSwitch", + "radixComponent": "Switch", + "parity": "mapped", + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "classic", + "surface", + "soft" + ], + "default": "surface" + } + }, + "defaults": { + "selected": false, + "highContrast": false, + "enabled": true + }, + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ], + "states": [ + "unchecked", + "checked", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast" + ], + "sourceFiles": [ + "src/components/switch.props.tsx", + "src/components/switch.css", + "src/components/switch.tsx" + ], + "sourceSelectors": [ + ".rt-SwitchRoot", + ".rt-SwitchThumb" + ], + "flutterExceptions": [ + "Boolean selection and Flutter focus/feedback APIs replace DOM checked attributes and events." + ], + "coverage": { + "enums": [ + "size.size1", + "size.size2", + "size.size3", + "variant.classic", + "variant.surface", + "variant.soft" + ], + "states": [ + "unchecked", + "checked", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast" + ], + "tests": [ + "test/components/switch/switch_widget_test.dart", + "test/components/switch/switch_style_test.dart", + "test/fortal/fortal_control_matrix_test.dart", + "test/fortal/fortal_high_contrast_test.dart" + ] + }, + "upstreamInventory": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "classic", + "surface", + "soft" + ], + "default": "surface" + } + }, + "states": [ + "unchecked", + "checked", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast" + ], + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ] + }, + "supportedVisualStates": [ + "unchecked", + "checked", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast" + ], + "deferredCapabilities": [], + "visualMapping": { + "color": "FortalScope.accent", + "radius": "FortalScope.radius", + "highContrast": "direct fortalXStyler recipe", + "variant": "generated Fortal constructor", + "size": "generated Fortal constructor" + } + }, + { + "id": "tabs", + "fortalType": "FortalTabBar", + "radixComponent": "Tabs", + "parity": "mapped", + "enums": { + "size": { + "values": [ + "size1", + "size2" + ], + "default": "size2" + }, + "orientation": { + "values": [ + "horizontal", + "vertical" + ], + "default": "horizontal" + }, + "activationMode": { + "values": [ + "automatic", + "manual" + ], + "default": "automatic" + } + }, + "defaults": { + "highContrast": false + }, + "supportedStyleProps": [ + "color", + "highContrast" + ], + "states": [ + "inactive", + "active", + "hovered", + "pressed", + "focused", + "disabled", + "automatic", + "manual", + "rtl", + "highContrast" + ], + "sourceFiles": [ + "src/components/_internal/base-tab-list.props.ts", + "src/components/_internal/base-tab-list.css", + "src/components/tabs.css" + ], + "sourceSelectors": [ + ".rt-BaseTabList", + ".rt-TabsList", + ".rt-BaseTabListTrigger", + ".rt-TabsTrigger", + ".rt-TabsContent" + ], + "flutterExceptions": [ + "The visual recipe maps Radix tab states onto the established single-child TabBar." + ], + "coverage": { + "enums": [ + "size.size1", + "size.size2", + "orientation.horizontal", + "orientation.vertical", + "activationMode.automatic", + "activationMode.manual" + ], + "states": [ + "inactive", + "active", + "hovered", + "pressed", + "focused", + "disabled", + "automatic", + "manual", + "rtl", + "highContrast" + ], + "tests": [ + "test/components/tabs/tabs_widget_test.dart", + "test/components/tabs/tabs_style_test.dart", + "test/fortal/fortal_high_contrast_test.dart" + ] + }, + "upstreamInventory": { + "enums": { + "size": { + "values": [ + "size1", + "size2" + ], + "default": "size2" + }, + "wrap": { + "values": [ + "nowrap", + "wrap", + "wrapReverse" + ], + "default": "nowrap" + }, + "justify": { + "values": [ + "start", + "center", + "end" + ], + "default": "start" + }, + "orientation": { + "values": [ + "horizontal", + "vertical" + ], + "default": "horizontal" + }, + "activationMode": { + "values": [ + "automatic", + "manual" + ], + "default": "automatic" + } + }, + "states": [ + "inactive", + "active", + "hovered", + "pressed", + "focused", + "disabled", + "automatic", + "manual", + "rtl", + "highContrast" + ], + "supportedStyleProps": [ + "color", + "highContrast" + ] + }, + "supportedVisualStates": [ + "inactive", + "active", + "hovered", + "pressed", + "focused", + "disabled", + "automatic", + "manual", + "rtl", + "highContrast" + ], + "deferredCapabilities": [ + "wrapping tab bars", + "tab-bar justification" + ], + "visualMapping": { + "color": "FortalScope.accent", + "radius": "FortalScope.radius", + "highContrast": "direct fortalXStyler recipe", + "variant": "generated Fortal constructor", + "size": "generated Fortal constructor" + } + }, + { + "id": "text_field", + "fortalType": "FortalTextField", + "radixComponent": "TextField.Root", + "parity": "mapped", + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "classic", + "surface", + "soft" + ], + "default": "surface" + } + }, + "defaults": { + "enabled": true, + "readOnly": false, + "error": false + }, + "supportedStyleProps": [ + "color", + "radius" + ], + "states": [ + "idle", + "hovered", + "focused", + "disabled", + "readOnly", + "invalid", + "leading", + "trailing" + ], + "sourceFiles": [ + "src/components/text-field.props.tsx", + "src/components/text-field.css", + "src/components/text-field.tsx" + ], + "sourceSelectors": [ + ".rt-TextFieldRoot", + ".rt-TextFieldInput", + ".rt-TextFieldSlot" + ], + "flutterExceptions": [ + "error is a documented Fortal extension and must set invalid semantics plus red error/focus roles; Flutter text editing APIs replace input DOM props." + ], + "coverage": { + "enums": [ + "size.size1", + "size.size2", + "size.size3", + "variant.classic", + "variant.surface", + "variant.soft" + ], + "states": [ + "idle", + "hovered", + "focused", + "disabled", + "readOnly", + "invalid", + "leading", + "trailing" + ], + "tests": [ + "test/components/textfield/textfield_widget_test.dart", + "test/components/textfield/fortal_textfield_disabled_test.dart", + "test/fortal/fortal_control_matrix_test.dart" + ] + }, + "upstreamInventory": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "classic", + "surface", + "soft" + ], + "default": "surface" + } + }, + "states": [ + "idle", + "hovered", + "focused", + "disabled", + "readOnly", + "invalid", + "leading", + "trailing" + ], + "supportedStyleProps": [ + "color", + "radius" + ] + }, + "supportedVisualStates": [ + "idle", + "hovered", + "focused", + "disabled", + "readOnly", + "invalid", + "leading", + "trailing" + ], + "deferredCapabilities": [], + "visualMapping": { + "color": "FortalScope.accent", + "radius": "FortalScope.radius", + "highContrast": "direct fortalXStyler recipe", + "variant": "generated Fortal constructor", + "size": "generated Fortal constructor" + } + }, + { + "id": "tooltip", + "fortalType": "FortalTooltip", + "radixComponent": "Tooltip", + "parity": "mapped", + "enums": {}, + "defaults": { + "maxWidth": 360, + "delayMilliseconds": 200, + "sideOffset": 4, + "collisionPadding": 10, + "disableHoverableContent": false, + "arrow": true + }, + "supportedStyleProps": [], + "states": [ + "closed", + "delayedOpen", + "triggerHovered", + "focused", + "touch", + "escape" + ], + "sourceFiles": [ + "src/components/tooltip.props.tsx", + "src/components/tooltip.css", + "src/components/tooltip.tsx" + ], + "sourceSelectors": [ + ".rt-TooltipContent", + ".rt-TooltipText", + ".rt-TooltipArrow" + ], + "flutterExceptions": [ + "OverlayPositionConfig and resolved OverlayPlacement drive a CustomPaint arrow after collision resolution; width constraints use logical pixels." + ], + "coverage": { + "enums": [], + "states": [ + "closed", + "delayedOpen", + "triggerHovered", + "focused", + "touch", + "escape" + ], + "tests": [ + "test/components/tooltip/tooltip_widget_test.dart", + "test/components/tooltip/tooltip_timing_test.dart" + ] + }, + "upstreamInventory": { + "enums": {}, + "states": [ + "closed", + "delayedOpen", + "instantOpen", + "triggerHovered", + "contentHovered", + "focused", + "touch", + "escape", + "outsideTap", + "ownerRejected", + "collisionFlipped", + "collisionShifted" + ], + "supportedStyleProps": [] + }, + "supportedVisualStates": [ + "closed", + "delayedOpen", + "triggerHovered", + "focused", + "touch", + "escape" + ], + "deferredCapabilities": [], + "visualMapping": { + "color": "FortalScope.accent", + "radius": "FortalScope.radius", + "highContrast": "direct fortalXStyler recipe", + "variant": "generated Fortal constructor", + "size": "generated Fortal constructor" + } + }, + { + "id": "accordion", + "fortalType": "FortalAccordion", + "radixComponent": null, + "parity": "extension", + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "surface", + "soft" + ], + "default": "surface" + } + }, + "defaults": { + "enabled": true + }, + "supportedStyleProps": [ + "color", + "radius" + ], + "states": [ + "collapsed", + "expanded", + "hovered", + "pressed", + "focused", + "disabled" + ], + "sourceFiles": [], + "sourceSelectors": [], + "flutterExceptions": [ + "Fortal extension: excluded from the Radix parity score and styled from shared Fortal tokens." + ], + "coverage": { + "enums": [ + "size.size1", + "size.size2", + "size.size3", + "variant.surface", + "variant.soft" + ], + "states": [ + "collapsed", + "expanded", + "hovered", + "pressed", + "focused", + "disabled" + ], + "tests": [ + "test/components/accordion/accordion_widget_test.dart", + "test/components/accordion/accordion_style_test.dart" + ] + }, + "upstreamInventory": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "surface", + "soft" + ], + "default": "surface" + } + }, + "states": [ + "collapsed", + "expanded", + "hovered", + "pressed", + "focused", + "disabled" + ], + "supportedStyleProps": [ + "color", + "radius" + ] + }, + "supportedVisualStates": [ + "collapsed", + "expanded", + "hovered", + "pressed", + "focused", + "disabled" + ], + "deferredCapabilities": [], + "visualMapping": { + "color": "FortalScope.accent", + "radius": "FortalScope.radius", + "highContrast": "direct fortalXStyler recipe", + "variant": "generated Fortal constructor", + "size": "generated Fortal constructor" + } + }, + { + "id": "toggle", + "fortalType": "FortalToggle", + "radixComponent": null, + "parity": "extension", + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "ghost", + "outline" + ], + "default": "ghost" + } + }, + "defaults": { + "selected": false, + "enabled": true, + "highContrast": false + }, + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ], + "states": [ + "off", + "on", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast" + ], + "sourceFiles": [], + "sourceSelectors": [], + "flutterExceptions": [ + "Fortal extension: excluded from the Radix parity score and styled from shared Fortal tokens." + ], + "coverage": { + "enums": [ + "size.size1", + "size.size2", + "size.size3", + "variant.ghost", + "variant.outline" + ], + "states": [ + "off", + "on", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast" + ], + "tests": [ + "test/components/toggle/toggle_widget_test.dart", + "test/components/toggle/toggle_style_test.dart", + "test/fortal/fortal_high_contrast_test.dart" + ] + }, + "upstreamInventory": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "ghost", + "outline" + ], + "default": "ghost" + } + }, + "states": [ + "off", + "on", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast" + ], + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ] + }, + "supportedVisualStates": [ + "off", + "on", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast" + ], + "deferredCapabilities": [], + "visualMapping": { + "color": "FortalScope.accent", + "radius": "FortalScope.radius", + "highContrast": "direct fortalXStyler recipe", + "variant": "generated Fortal constructor", + "size": "generated Fortal constructor" + } + }, + { + "id": "toggle_group", + "fortalType": "FortalToggleGroup", + "radixComponent": null, + "parity": "extension", + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "soft", + "surface" + ], + "default": "soft" + }, + "orientation": { + "values": [ + "horizontal", + "vertical" + ], + "default": "horizontal" + } + }, + "defaults": { + "enabled": true, + "loop": true, + "highContrast": false + }, + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ], + "states": [ + "unselected", + "selected", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast", + "rtl" + ], + "sourceFiles": [], + "sourceSelectors": [], + "flutterExceptions": [ + "Fortal extension: excluded from the Radix parity score and styled from shared Fortal tokens." + ], + "coverage": { + "enums": [ + "size.size1", + "size.size2", + "size.size3", + "variant.soft", + "variant.surface", + "orientation.horizontal", + "orientation.vertical" + ], + "states": [ + "unselected", + "selected", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast", + "rtl" + ], + "tests": [ + "test/components/toggle_group/toggle_group_widget_test.dart", + "test/components/toggle_group/toggle_group_style_test.dart" + ] + }, + "upstreamInventory": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "soft", + "surface" + ], + "default": "soft" + }, + "orientation": { + "values": [ + "horizontal", + "vertical" + ], + "default": "horizontal" + } + }, + "states": [ + "unselected", + "selected", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast", + "rtl" + ], + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ] + }, + "supportedVisualStates": [ + "unselected", + "selected", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast", + "rtl" + ], + "deferredCapabilities": [], + "visualMapping": { + "color": "FortalScope.accent", + "radius": "FortalScope.radius", + "highContrast": "direct fortalXStyler recipe", + "variant": "generated Fortal constructor", + "size": "generated Fortal constructor" + } + } + ], + "referenceFixtures": { + "computedStyles": "reference/radix_themes_3_3_0/chromium/computed-styles.json", + "screenshot": "reference/radix_themes_3_3_0/chromium/families-light.png", + "generator": "tool/fortal_parity/chromium/generate.mjs", + "fixture": "tool/fortal_parity/chromium/fixture.html" + }, + "approximations": [ + { + "family": "dialog", + "upstream": "Radix Dialog with modal=false permits background pointer interaction while the content is open.", + "flutter": "RemixDialog with modal=false disables modal accessibility route scoping, but showRemixDialog remains a RawDialogRoute whose barrier blocks background pointer input.", + "reason": "Flutter Navigator dialog routes are modal routes; non-modal pointer interaction requires a separate OverlayEntry lifecycle rather than the approved dialog route API.", + "tolerance": "Interaction-model boundary only; no visual tolerance. The route barrier and both accessibility modes are covered by dialog_widget_test.dart." + } + ], + "upstreamInventory": { + "theme": { + "enums": { + "appearance": { + "values": [ + "inherit", + "light", + "dark" + ], + "default": "inherit" + }, + "accentColor": { + "values": [ + "gray", + "gold", + "bronze", + "brown", + "yellow", + "amber", + "orange", + "tomato", + "red", + "ruby", + "crimson", + "pink", + "plum", + "purple", + "violet", + "iris", + "indigo", + "blue", + "cyan", + "teal", + "jade", + "green", + "grass", + "lime", + "mint", + "sky" + ], + "default": "indigo" + }, + "grayColor": { + "values": [ + "auto", + "gray", + "mauve", + "slate", + "sage", + "olive", + "sand" + ], + "default": "auto" + }, + "panelBackground": { + "values": [ + "solid", + "translucent" + ], + "default": "translucent" + }, + "radius": { + "values": [ + "none", + "small", + "medium", + "large", + "full" + ], + "default": "medium" + }, + "scaling": { + "values": [ + "90%", + "95%", + "100%", + "105%", + "110%" + ], + "default": "100%" + } + }, + "resolvedRootDefaults": { + "appearance": "platform brightness", + "accentColor": "indigo", + "grayColor": "slate", + "panelBackground": "translucent", + "radius": "medium", + "scaling": "100%", + "hasBackground": true, + "panelBlurLogicalPixels": 64 + }, + "autoGray": { + "gray": "gray", + "gold": "sand", + "bronze": "sand", + "brown": "sand", + "yellow": "sand", + "amber": "sand", + "orange": "sand", + "tomato": "mauve", + "red": "mauve", + "ruby": "mauve", + "crimson": "mauve", + "pink": "mauve", + "plum": "mauve", + "purple": "mauve", + "violet": "mauve", + "iris": "slate", + "indigo": "slate", + "blue": "slate", + "cyan": "slate", + "teal": "sage", + "jade": "sage", + "green": "sage", + "grass": "olive", + "lime": "olive", + "mint": "sage", + "sky": "slate" + }, + "sourceFiles": [ + "src/components/theme.props.tsx", + "src/components/theme.tsx", + "src/helpers/get-matching-gray-color.ts", + "src/styles/tokens/radius.css", + "src/styles/tokens/scaling.css", + "src/styles/tokens/shadow.css" + ], + "states": [ + "rootPlatformLight", + "rootPlatformDark", + "nestedInherit", + "nestedExplicitAuto", + "paintedBackground", + "translucentPanel", + "solidPanel" + ], + "coverage": { + "enums": [ + "appearance.inherit", + "appearance.light", + "appearance.dark", + "accentColor.gray", + "accentColor.gold", + "accentColor.bronze", + "accentColor.brown", + "accentColor.yellow", + "accentColor.amber", + "accentColor.orange", + "accentColor.tomato", + "accentColor.red", + "accentColor.ruby", + "accentColor.crimson", + "accentColor.pink", + "accentColor.plum", + "accentColor.purple", + "accentColor.violet", + "accentColor.iris", + "accentColor.indigo", + "accentColor.blue", + "accentColor.cyan", + "accentColor.teal", + "accentColor.jade", + "accentColor.green", + "accentColor.grass", + "accentColor.lime", + "accentColor.mint", + "accentColor.sky", + "grayColor.auto", + "grayColor.gray", + "grayColor.mauve", + "grayColor.slate", + "grayColor.sage", + "grayColor.olive", + "grayColor.sand", + "panelBackground.solid", + "panelBackground.translucent", + "radius.none", + "radius.small", + "radius.medium", + "radius.large", + "radius.full", + "scaling.90%", + "scaling.95%", + "scaling.100%", + "scaling.105%", + "scaling.110%" + ], + "states": [ + "rootPlatformLight", + "rootPlatformDark", + "nestedInherit", + "nestedExplicitAuto", + "paintedBackground", + "translucentPanel", + "solidPanel" + ], + "tests": [ + "test/fortal/fortal_theme_resolution_test.dart", + "test/fortal/fortal_theming_widget_test.dart", + "test/fortal/fortal_tokens_test.dart" + ] + } + }, + "families": { + "avatar": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3", + "size4", + "size5", + "size6", + "size7", + "size8", + "size9" + ], + "default": "size3" + }, + "variant": { + "values": [ + "solid", + "soft" + ], + "default": "soft" + } + }, + "states": [ + "idle", + "loading", + "loaded", + "error", + "highContrast" + ], + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ] + }, + "badge": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size1" + }, + "variant": { + "values": [ + "solid", + "soft", + "surface", + "outline" + ], + "default": "soft" + } + }, + "states": [ + "idle", + "highContrast" + ], + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ] + }, + "button": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3", + "size4" + ], + "default": "size2" + }, + "variant": { + "values": [ + "classic", + "solid", + "soft", + "surface", + "outline", + "ghost" + ], + "default": "solid" + } + }, + "states": [ + "idle", + "hovered", + "pressed", + "focused", + "disabled", + "loading", + "highContrast" + ], + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ] + }, + "callout": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "soft", + "surface", + "outline" + ], + "default": "soft" + } + }, + "states": [ + "idle", + "highContrast" + ], + "supportedStyleProps": [ + "color", + "highContrast" + ] + }, + "card": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3", + "size4", + "size5" + ], + "default": "size1" + }, + "variant": { + "values": [ + "surface", + "classic", + "ghost" + ], + "default": "surface" + } + }, + "states": [ + "idle", + "hovered", + "pressed", + "focused", + "open", + "disabled", + "passive" + ], + "supportedStyleProps": [] + }, + "checkbox": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "classic", + "surface", + "soft" + ], + "default": "surface" + } + }, + "states": [ + "unchecked", + "checked", + "indeterminate", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast" + ], + "supportedStyleProps": [ + "color", + "highContrast" + ] + }, + "dialog": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3", + "size4" + ], + "default": "size3" + }, + "align": { + "values": [ + "start", + "center" + ], + "default": "center" + } + }, + "states": [ + "closed", + "open", + "entering", + "exiting", + "modal", + "nonModal" + ], + "supportedStyleProps": [] + }, + "divider": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3", + "size4" + ], + "default": "size1" + }, + "orientation": { + "values": [ + "horizontal", + "vertical" + ], + "default": "horizontal" + } + }, + "states": [ + "decorative", + "semantic" + ], + "supportedStyleProps": [ + "color" + ] + }, + "menu": { + "enums": { + "size": { + "values": [ + "size1", + "size2" + ], + "default": "size2" + }, + "variant": { + "values": [ + "solid", + "soft" + ], + "default": "solid" + } + }, + "states": [ + "closed", + "open", + "highlighted", + "disabled", + "checked", + "unchecked", + "submenuOpen", + "rtl", + "escape", + "focusRestored", + "highContrast" + ], + "supportedStyleProps": [ + "color", + "highContrast" + ] + }, + "icon_button": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3", + "size4" + ], + "default": "size2" + }, + "variant": { + "values": [ + "classic", + "solid", + "soft", + "surface", + "outline", + "ghost" + ], + "default": "solid" + } + }, + "states": [ + "idle", + "hovered", + "pressed", + "focused", + "disabled", + "loading", + "highContrast" + ], + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ] + }, + "popover": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3", + "size4" + ], + "default": "size2" + } + }, + "states": [ + "closed", + "open", + "hovered", + "escape", + "outsideTap", + "focusRestored", + "separateAnchor", + "collisionFlipped", + "collisionShifted" + ], + "supportedStyleProps": [] + }, + "progress": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "classic", + "surface", + "soft" + ], + "default": "surface" + } + }, + "states": [ + "determinate", + "indeterminate", + "animationDisabled", + "highContrast" + ], + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ] + }, + "radio": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "classic", + "surface", + "soft" + ], + "default": "surface" + } + }, + "states": [ + "unchecked", + "checked", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast", + "group" + ], + "supportedStyleProps": [ + "color", + "highContrast" + ] + }, + "select": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "triggerVariant": { + "values": [ + "classic", + "surface", + "soft", + "ghost" + ], + "default": "surface" + }, + "contentVariant": { + "values": [ + "solid", + "soft" + ], + "default": "solid" + } + }, + "states": [ + "closed", + "open", + "placeholder", + "selected", + "highlighted", + "disabled", + "grouped", + "separated", + "ownerRejected", + "collisionFlipped", + "collisionShifted", + "highContrast" + ], + "supportedStyleProps": [ + "triggerColor", + "triggerRadius", + "contentColor", + "contentHighContrast" + ] + }, + "slider": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "classic", + "surface", + "soft" + ], + "default": "surface" + }, + "orientation": { + "values": [ + "horizontal", + "vertical" + ], + "default": "horizontal" + } + }, + "states": [ + "idle", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast", + "multipleThumbs", + "rtl", + "inverted", + "ownerRejected" + ], + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ] + }, + "spinner": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + } + }, + "states": [ + "loading", + "notLoading", + "withChild", + "withoutChild", + "animationDisabled" + ], + "supportedStyleProps": [] + }, + "switch": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "classic", + "surface", + "soft" + ], + "default": "surface" + } + }, + "states": [ + "unchecked", + "checked", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast" + ], + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ] + }, + "tabs": { + "enums": { + "size": { + "values": [ + "size1", + "size2" + ], + "default": "size2" + }, + "wrap": { + "values": [ + "nowrap", + "wrap", + "wrapReverse" + ], + "default": "nowrap" + }, + "justify": { + "values": [ + "start", + "center", + "end" + ], + "default": "start" + }, + "orientation": { + "values": [ + "horizontal", + "vertical" + ], + "default": "horizontal" + }, + "activationMode": { + "values": [ + "automatic", + "manual" + ], + "default": "automatic" + } + }, + "states": [ + "inactive", + "active", + "hovered", + "pressed", + "focused", + "disabled", + "automatic", + "manual", + "rtl", + "highContrast" + ], + "supportedStyleProps": [ + "color", + "highContrast" + ] + }, + "text_field": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "classic", + "surface", + "soft" + ], + "default": "surface" + } + }, + "states": [ + "idle", + "hovered", + "focused", + "disabled", + "readOnly", + "invalid", + "leading", + "trailing" + ], + "supportedStyleProps": [ + "color", + "radius" + ] + }, + "tooltip": { + "enums": {}, + "states": [ + "closed", + "delayedOpen", + "instantOpen", + "triggerHovered", + "contentHovered", + "focused", + "touch", + "escape", + "outsideTap", + "ownerRejected", + "collisionFlipped", + "collisionShifted" + ], + "supportedStyleProps": [] + }, + "accordion": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "surface", + "soft" + ], + "default": "surface" + } + }, + "states": [ + "collapsed", + "expanded", + "hovered", + "pressed", + "focused", + "disabled" + ], + "supportedStyleProps": [ + "color", + "radius" + ] + }, + "toggle": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "ghost", + "outline" + ], + "default": "ghost" + } + }, + "states": [ + "off", + "on", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast" + ], + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ] + }, + "toggle_group": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "soft", + "surface" + ], + "default": "soft" + }, + "orientation": { + "values": [ + "horizontal", + "vertical" + ], + "default": "horizontal" + } + }, + "states": [ + "unselected", + "selected", + "hovered", + "pressed", + "focused", + "disabled", + "highContrast", + "rtl" + ], + "supportedStyleProps": [ + "color", + "radius", + "highContrast" + ] + } + } + }, + "visualMapping": { + "color": "FortalScope.accent", + "radius": "FortalScope.radius", + "highContrast": "direct fortalXStyler recipe", + "variant": "generated Fortal constructor", + "size": "generated Fortal constructor" + }, + "deferredCapabilities": { + "button": [ + "arbitrary child content" + ], + "card": [ + "interactive card behavior" + ], + "menu": [ + "compositional items", + "checkbox items", + "radio items", + "submenus" + ], + "select": [ + "structured content", + "independent trigger/content variants" + ], + "slider": [ + "multiple thumbs", + "range values", + "vertical orientation", + "inversion" + ], + "progress": [ + "indeterminate mode", + "custom maximum", + "animation controls" + ], + "spinner": [ + "loading gate", + "child overlay" + ], + "tabs": [ + "wrapping tab bars", + "tab-bar justification" + ] + } +} diff --git a/packages/remix/reference/radix_themes_3_3_0/manifest.schema.json b/packages/remix/reference/radix_themes_3_3_0/manifest.schema.json new file mode 100644 index 00000000..599b6b7b --- /dev/null +++ b/packages/remix/reference/radix_themes_3_3_0/manifest.schema.json @@ -0,0 +1,179 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/conceptadev/remix/fortal-radix-themes-3.3.0-parity.schema.json", + "title": "Fortal / Radix Themes parity manifest", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "contractVersion", + "coverageEvidence", + "source", + "theme", + "families", + "referenceFixtures", + "commonFlutterExceptions", + "approximations" + ], + "properties": { + "$schema": { "type": "string", "minLength": 1 }, + "contractVersion": { "type": "integer", "minimum": 1 }, + "coverageEvidence": { "type": "string", "minLength": 1 }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["package", "version", "integrity", "tarball"], + "properties": { + "package": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 }, + "integrity": { "type": "string", "minLength": 1 }, + "tarball": { "type": "string", "format": "uri" } + } + }, + "theme": { + "type": "object", + "additionalProperties": false, + "required": [ + "enums", + "resolvedRootDefaults", + "autoGray", + "states", + "sourceFiles", + "coverage" + ], + "properties": { + "enums": { "$ref": "#/$defs/enumMap" }, + "resolvedRootDefaults": { "type": "object" }, + "autoGray": { "type": "object", "minProperties": 26 }, + "states": { "$ref": "#/$defs/nonEmptyStrings" }, + "sourceFiles": { "$ref": "#/$defs/nonEmptyStrings" }, + "coverage": { "$ref": "#/$defs/coverage" } + } + }, + "families": { + "type": "array", + "minItems": 23, + "maxItems": 23, + "items": { "$ref": "#/$defs/family" } + }, + "referenceFixtures": { + "type": "object", + "additionalProperties": false, + "required": ["computedStyles", "screenshot", "generator", "fixture"], + "properties": { + "computedStyles": { "type": "string", "minLength": 1 }, + "screenshot": { "type": "string", "minLength": 1 }, + "generator": { "type": "string", "minLength": 1 }, + "fixture": { "type": "string", "minLength": 1 } + } + }, + "commonFlutterExceptions": { + "type": "array", + "items": { "$ref": "#/$defs/mappingBoundary" } + }, + "approximations": { + "type": "array", + "items": { + "allOf": [ + { "$ref": "#/$defs/mappingBoundary" }, + { + "type": "object", + "required": ["family", "tolerance"], + "properties": { + "family": { "type": "string", "minLength": 1 }, + "tolerance": { "type": "string", "minLength": 1 } + } + } + ] + } + } + }, + "$defs": { + "nonEmptyStrings": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "strings": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "enumMap": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["values", "default"], + "properties": { + "values": { "$ref": "#/$defs/nonEmptyStrings" }, + "default": { + "oneOf": [ + { "type": "string" }, + { "type": "number" }, + { "type": "boolean" }, + { "type": "null" } + ] + } + } + } + }, + "coverage": { + "type": "object", + "additionalProperties": false, + "required": ["enums", "states", "tests"], + "properties": { + "enums": { "$ref": "#/$defs/strings" }, + "states": { "$ref": "#/$defs/strings" }, + "tests": { "$ref": "#/$defs/nonEmptyStrings" } + } + }, + "family": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "fortalType", + "radixComponent", + "parity", + "enums", + "defaults", + "supportedStyleProps", + "states", + "sourceFiles", + "sourceSelectors", + "flutterExceptions", + "coverage" + ], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "fortalType": { "type": "string", "minLength": 1 }, + "radixComponent": { + "oneOf": [ + { "type": "string", "minLength": 1 }, + { "type": "null" } + ] + }, + "parity": { "enum": ["mapped", "extension"] }, + "enums": { "$ref": "#/$defs/enumMap" }, + "defaults": { "type": "object" }, + "supportedStyleProps": { "$ref": "#/$defs/strings" }, + "states": { "$ref": "#/$defs/nonEmptyStrings" }, + "sourceFiles": { "$ref": "#/$defs/strings" }, + "sourceSelectors": { "$ref": "#/$defs/strings" }, + "flutterExceptions": { "$ref": "#/$defs/nonEmptyStrings" }, + "coverage": { "$ref": "#/$defs/coverage" } + } + }, + "mappingBoundary": { + "type": "object", + "required": ["upstream", "flutter", "reason"], + "properties": { + "upstream": { "type": "string", "minLength": 1 }, + "flutter": { "type": "string", "minLength": 1 }, + "reason": { "type": "string", "minLength": 1 } + } + } + } +} diff --git a/packages/remix/scripts/extract_radix_tokens.dart b/packages/remix/scripts/extract_radix_tokens.dart new file mode 100644 index 00000000..9b41f66a --- /dev/null +++ b/packages/remix/scripts/extract_radix_tokens.dart @@ -0,0 +1,181 @@ +import 'dart:convert'; +import 'dart:io'; + +const _radixThemesVersion = '3.3.0'; +const _radixThemesIntegrity = + 'sha512-I0/h2CRNTpYNB7Mi3xFIvSsQq5a108d7kK8dTO5zp5b9HR5QJXKag6B8tjpz2ITkVYkFdkGk45doNkSr7OxwNw=='; + +const _colorNames = [ + 'gray', + 'mauve', + 'slate', + 'sage', + 'olive', + 'sand', + 'tomato', + 'red', + 'ruby', + 'crimson', + 'pink', + 'plum', + 'purple', + 'violet', + 'iris', + 'indigo', + 'blue', + 'cyan', + 'teal', + 'jade', + 'green', + 'grass', + 'bronze', + 'gold', + 'brown', + 'orange', + 'amber', + 'yellow', + 'lime', + 'mint', + 'sky', +]; + +// Chromium 152 sRGB raster values for the dark-mode `color-mix(in oklab, …)` +// track overrides in the pinned 3.3.0 artifact. +const _darkTrackOverrides = { + 'amber': '#e2ac37', + 'lime': '#98c254', + 'mint': '#65c3b0', + 'sky': '#5bbde2', + 'yellow': '#d2b929', +}; + +void main(List arguments) { + if (arguments.length != 1) { + stderr.writeln( + 'Usage: dart run scripts/extract_radix_tokens.dart ' + '', + ); + exitCode = 64; + return; + } + + final packageDirectory = Directory(arguments.single); + final packageJson = File('${packageDirectory.path}/package.json'); + final package = jsonDecode(packageJson.readAsStringSync()); + if (package case {'name': '@radix-ui/themes', 'version': final version}) { + if (version != _radixThemesVersion) { + throw StateError( + 'Expected @radix-ui/themes@$_radixThemesVersion, found $version.', + ); + } + } else { + throw StateError('${packageDirectory.path} is not a Radix Themes package.'); + } + + final scales = {}; + for (final colorName in _colorNames) { + final source = File( + '${packageDirectory.path}/tokens/colors/$colorName.css', + ).readAsStringSync(); + scales[colorName] = _extractScale(colorName, source); + } + + final baseSource = File( + '${packageDirectory.path}/tokens/base.css', + ).readAsStringSync(); + final output = { + 'meta': { + 'description': + 'sRGB fallback tokens extracted from the pinned @radix-ui/themes npm artifact', + 'radix_themes_version': _radixThemesVersion, + 'radix_colors_version': 'bundled with Radix Themes 3.3.0', + 'source_integrity': _radixThemesIntegrity, + }, + 'colors': { + 'scales': scales, + 'neutrals': { + 'blackA': {'alpha': _extractNeutral('black', baseSource)}, + 'whiteA': {'alpha': _extractNeutral('white', baseSource)}, + }, + }, + }; + + const encoder = JsonEncoder.withIndent(' '); + File( + 'radix_colors.generated.json', + ).writeAsStringSync('${encoder.convert(output)}\n'); +} + +Map _extractScale(String name, String source) { + final light = { + 'solid': {}, + 'alpha': {}, + }; + final dark = { + 'solid': {}, + 'alpha': {}, + }; + + for (var step = 1; step <= 12; step++) { + final solid = _hexValues(source, '$name-$step'); + final alpha = _hexValues(source, '$name-a$step'); + _expectModes('$name-$step', solid); + _expectModes('$name-a$step', alpha); + (light['solid']! as Map)['$step'] = solid[0]; + (dark['solid']! as Map)['$step'] = solid[1]; + (light['alpha']! as Map)['a$step'] = alpha[0]; + (dark['alpha']! as Map)['a$step'] = alpha[1]; + } + + final surfaces = _hexValues(source, '$name-surface'); + _expectModes('$name-surface', surfaces); + final contrastMatch = RegExp( + '--$name-contrast:\\s*([^;]+);', + ).firstMatch(source); + final contrast = switch (contrastMatch?.group(1)?.trim()) { + 'white' => '#ffffff', + 'black' => '#000000', + final String value when value.startsWith('#') => value, + final value => throw StateError('Unsupported $name contrast: $value'), + }; + + light + ..['surface'] = surfaces[0] + ..['indicator'] = (light['solid']! as Map)['9'] + ..['track'] = (light['solid']! as Map)['9'] + ..['contrast'] = contrast; + dark + ..['surface'] = surfaces[1] + ..['indicator'] = (dark['solid']! as Map)['9'] + ..['track'] = + _darkTrackOverrides[name] ?? + (dark['solid']! as Map)['9'] + ..['contrast'] = contrast; + + return {'light': light, 'dark': dark}; +} + +Map _extractNeutral(String name, String source) { + final result = {}; + for (var step = 1; step <= 12; step++) { + final match = RegExp( + '--$name-a$step:\\s*(rgba\\([^;]+\\));', + ).firstMatch(source); + result['a$step'] = + match?.group(1) ?? + (throw StateError('Missing --$name-a$step in tokens/base.css.')); + } + return result; +} + +List _hexValues(String source, String property) => RegExp( + '--$property:\\s*(#[0-9a-fA-F]{6,8});', +).allMatches(source).map((match) => match.group(1)!).toList(); + +void _expectModes(String property, List values) { + if (values.length != 2) { + throw StateError( + 'Expected light and dark sRGB values for --$property, found $values.', + ); + } +} diff --git a/packages/remix/scripts/generate_radix_colors.dart b/packages/remix/scripts/generate_radix_colors.dart index 376938ea..53652d5d 100644 --- a/packages/remix/scripts/generate_radix_colors.dart +++ b/packages/remix/scripts/generate_radix_colors.dart @@ -201,7 +201,7 @@ class RadixColorGenerator { buffer.writeln('// Generated from: ${meta['description']}'); buffer.writeln('// Radix Themes version: ${meta['radix_themes_version']}'); buffer.writeln('// Radix Colors version: ${meta['radix_colors_version']}'); - buffer.writeln('// Generated on: ${DateTime.now().toIso8601String()}'); + buffer.writeln('// Source integrity: ${meta['source_integrity']}'); buffer.writeln(); buffer.writeln('part of \'colors.dart\';'); buffer.writeln(); diff --git a/packages/remix/test/components/avatar/avatar_fortal_parity_test.dart b/packages/remix/test/components/avatar/avatar_fortal_parity_test.dart new file mode 100644 index 00000000..03177ddc --- /dev/null +++ b/packages/remix/test/components/avatar/avatar_fortal_parity_test.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; + +void main() { + test('defaults to Radix size3 and soft', () { + const avatar = FortalAvatar(label: 'LF'); + + expect(avatar.size, FortalAvatarSize.size3); + expect(avatar.variant, FortalAvatarVariant.soft); + }); + + group('Fortal Avatar geometry', () { + for (final (size, dimension, radius) in const [ + (FortalAvatarSize.size1, 24.0, 4.0), + (FortalAvatarSize.size2, 32.0, 4.0), + (FortalAvatarSize.size3, 40.0, 6.0), + (FortalAvatarSize.size4, 48.0, 6.0), + (FortalAvatarSize.size5, 64.0, 8.0), + (FortalAvatarSize.size6, 80.0, 12.0), + (FortalAvatarSize.size7, 96.0, 12.0), + (FortalAvatarSize.size8, 128.0, 16.0), + (FortalAvatarSize.size9, 160.0, 16.0), + ]) { + testWidgets('${size.name} matches the pinned dimension and radius', ( + tester, + ) async { + final spec = await _resolve( + tester, + fortalAvatarStyler(size: size, fallbackLength: 1), + ); + final box = spec.container.spec; + final decoration = box.decoration as BoxDecoration; + + expect(box.constraints!.minWidth, dimension); + expect(box.constraints!.maxWidth, dimension); + expect(box.constraints!.minHeight, dimension); + expect(box.constraints!.maxHeight, dimension); + expect( + decoration.borderRadius!.resolve(TextDirection.ltr).topLeft.x, + radius, + ); + expect(spec.label.spec.style!.height, 1); + expect(spec.label.spec.style!.fontWeight, FontWeight.w500); + }); + } + + testWidgets('explicit pixel sizes and type scale with the theme', ( + tester, + ) async { + final spec = await _resolve( + tester, + fortalAvatarStyler(size: .size6, fallbackLength: 1), + scaling: .percent110, + ); + + expect(spec.container.spec.constraints!.minWidth, closeTo(88, 1e-9)); + expect(spec.label.spec.style!.fontSize, closeTo(30.8, 1e-9)); + }); + + testWidgets('one- and two-letter fallbacks use the pinned type sizes', ( + tester, + ) async { + final one = await _resolve( + tester, + fortalAvatarStyler(size: .size3, fallbackLength: 1), + ); + final two = await _resolve( + tester, + fortalAvatarStyler(size: .size3, fallbackLength: 2), + ); + + expect(one.label.spec.style!.fontSize, 18); + expect(two.label.spec.style!.fontSize, 16); + expect(one.label.spec.style!.letterSpacing, 0); + expect(two.label.spec.style!.letterSpacing, 0); + }); + + testWidgets('full radius promotes every size to a circle', (tester) async { + final spec = await _resolve( + tester, + fortalAvatarStyler(size: .size9, fallbackLength: 1), + radius: .full, + ); + final decoration = spec.container.spec.decoration as BoxDecoration; + + expect( + decoration.borderRadius!.resolve(TextDirection.ltr).topLeft.x, + 9999, + ); + }); + }); + + group('Fortal Avatar variants', () { + testWidgets('soft and solid resolve normal and high-contrast roles', ( + tester, + ) async { + final soft = await _resolve(tester, fortalAvatarStyler()); + final softHigh = await _resolve( + tester, + fortalAvatarStyler(highContrast: true), + ); + final solid = await _resolve(tester, fortalAvatarStyler(variant: .solid)); + final solidHigh = await _resolve( + tester, + fortalAvatarStyler(variant: .solid, highContrast: true), + ); + final tokens = await _tokens(tester); + + expect(_color(soft), tokens.accentA3); + expect(soft.label.spec.style!.color, tokens.accentA11); + expect(_color(softHigh), tokens.accentA3); + expect(softHigh.label.spec.style!.color, tokens.accent12); + expect(_color(solid), tokens.accent9); + expect(solid.label.spec.style!.color, tokens.accentContrast); + expect(_color(solidHigh), tokens.accent12); + expect(solidHigh.label.spec.style!.color, tokens.accent1); + }); + }); +} + +Future _resolve( + WidgetTester tester, + RemixAvatarStyler style, { + FortalScaling scaling = .percent100, + FortalRadius radius = .medium, +}) async { + late RemixAvatarSpec resolved; + await tester.pumpWidget( + FortalScope( + brightness: .light, + scaling: scaling, + radius: radius, + child: WidgetsApp( + color: Colors.black, + builder: (context, child) { + resolved = style.build(context).spec; + return const SizedBox.shrink(); + }, + ), + ), + ); + return resolved; +} + +Future< + ({ + Color accent1, + Color accent9, + Color accent12, + Color accentA3, + Color accentA11, + Color accentContrast, + }) +> +_tokens(WidgetTester tester) async { + late ({ + Color accent1, + Color accent9, + Color accent12, + Color accentA3, + Color accentA11, + Color accentContrast, + }) + result; + await tester.pumpWidget( + FortalScope( + brightness: .light, + child: WidgetsApp( + color: Colors.black, + builder: (context, child) { + Color token(ColorToken value) => MixScope.tokenOf(value, context); + result = ( + accent1: token(FortalTokens.accent1), + accent9: token(FortalTokens.accent9), + accent12: token(FortalTokens.accent12), + accentA3: token(FortalTokens.accentA3), + accentA11: token(FortalTokens.accentA11), + accentContrast: token(FortalTokens.accentContrast), + ); + return const SizedBox.shrink(); + }, + ), + ), + ); + return result; +} + +Color? _color(RemixAvatarSpec spec) => + (spec.container.spec.decoration as BoxDecoration?)?.color; diff --git a/packages/remix/test/components/badge/badge_fortal_parity_test.dart b/packages/remix/test/components/badge/badge_fortal_parity_test.dart new file mode 100644 index 00000000..19a74146 --- /dev/null +++ b/packages/remix/test/components/badge/badge_fortal_parity_test.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; + +void main() { + test('defaults to Radix size1 and soft', () { + const badge = FortalBadge(child: Text('New')); + + expect(badge.size, FortalBadgeSize.size1); + expect(badge.variant, FortalBadgeVariant.soft); + }); + + group('Fortal Badge geometry', () { + for (final (size, padding, radius, fontSize) in const [ + ( + FortalBadgeSize.size1, + EdgeInsets.symmetric(horizontal: 6, vertical: 2), + 3.0, + 12.0, + ), + ( + FortalBadgeSize.size2, + EdgeInsets.symmetric(horizontal: 8, vertical: 4), + 4.0, + 12.0, + ), + ( + FortalBadgeSize.size3, + EdgeInsets.symmetric(horizontal: 10, vertical: 4), + 4.0, + 14.0, + ), + ]) { + testWidgets('${size.name} matches pinned metrics', (tester) async { + final spec = await _resolve(tester, fortalBadgeStyler(size: size)); + final box = spec.container.spec; + + expect(box.padding, padding); + expect(_radius(box), radius); + expect(spec.label.spec.style!.fontSize, fontSize); + expect(spec.label.spec.style!.fontWeight, FontWeight.w500); + }); + } + + testWidgets('scaling applies to metrics and full radius makes a pill', ( + tester, + ) async { + final scaled = await _resolve( + tester, + fortalBadgeStyler(size: .size3), + scaling: .percent110, + ); + final full = await _resolve(tester, fortalBadgeStyler(), radius: .full); + + final padding = scaled.container.spec.padding!.resolve(TextDirection.ltr); + expect(padding.left, closeTo(11, 1e-9)); + expect(padding.top, closeTo(4.4, 1e-9)); + expect(scaled.label.spec.style!.fontSize, closeTo(15.4, 1e-9)); + expect(_radius(full.container.spec), 9999); + }); + + testWidgets('shrink-wraps its surface inside a bounded parent', ( + tester, + ) async { + const slotWidth = 110.0; + const slotHeight = 48.0; + await tester.pumpWidget( + const FortalScope( + child: Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: slotWidth, + height: slotHeight, + child: Align( + alignment: Alignment.centerLeft, + child: FortalBadge(child: Text('Active')), + ), + ), + ), + ), + ), + ); + + final badgeSize = tester.getSize(find.byType(DecoratedBox)); + expect(badgeSize.width, lessThan(slotWidth)); + expect(badgeSize.height, lessThan(slotHeight)); + }); + }); + + group('Fortal Badge variants', () { + testWidgets( + 'variants resolve exact fills, inset strokes, and contrast roles', + (tester) async { + final tokens = await _tokens(tester); + final soft = await _resolve(tester, fortalBadgeStyler()); + final solidHigh = await _resolve( + tester, + fortalBadgeStyler(variant: .solid, highContrast: true), + ); + final surface = await _resolve( + tester, + fortalBadgeStyler(variant: .surface), + ); + final outlineHigh = await _resolve( + tester, + fortalBadgeStyler(variant: .outline, highContrast: true), + ); + + expect(_color(soft.container.spec), tokens.accentA3); + expect(soft.label.spec.style!.color, tokens.accentA11); + expect(_color(solidHigh.container.spec), tokens.accent12); + expect(solidHigh.label.spec.style!.color, tokens.accent1); + expect(_color(surface.container.spec), tokens.accentSurface); + expect( + surface.containerEffects!.behindContent!.shadows.single.color, + tokens.accentA6, + ); + expect( + outlineHigh.containerEffects!.behindContent!.shadows.map( + (shadow) => shadow.color, + ), + [tokens.accentA7, tokens.grayA11], + ); + expect(outlineHigh.label.spec.style!.color, tokens.accent12); + }, + ); + }); +} + +Future _resolve( + WidgetTester tester, + RemixBadgeStyler style, { + FortalScaling scaling = .percent100, + FortalRadius radius = .medium, +}) async { + late RemixBadgeSpec result; + await tester.pumpWidget( + FortalScope( + brightness: .light, + scaling: scaling, + radius: radius, + child: WidgetsApp( + color: Colors.black, + builder: (context, child) { + result = style.build(context).spec; + return const SizedBox.shrink(); + }, + ), + ), + ); + return result; +} + +Future< + ({ + Color accent1, + Color accent12, + Color accentA3, + Color accentA6, + Color accentA7, + Color accentA11, + Color accentSurface, + Color grayA11, + }) +> +_tokens(WidgetTester tester) async { + late ({ + Color accent1, + Color accent12, + Color accentA3, + Color accentA6, + Color accentA7, + Color accentA11, + Color accentSurface, + Color grayA11, + }) + result; + await tester.pumpWidget( + FortalScope( + brightness: .light, + child: WidgetsApp( + color: Colors.black, + builder: (context, child) { + Color token(ColorToken value) => MixScope.tokenOf(value, context); + result = ( + accent1: token(FortalTokens.accent1), + accent12: token(FortalTokens.accent12), + accentA3: token(FortalTokens.accentA3), + accentA6: token(FortalTokens.accentA6), + accentA7: token(FortalTokens.accentA7), + accentA11: token(FortalTokens.accentA11), + accentSurface: token(FortalTokens.accentSurface), + grayA11: token(FortalTokens.grayA11), + ); + return const SizedBox.shrink(); + }, + ), + ), + ); + return result; +} + +double _radius(BoxSpec box) => (box.decoration as BoxDecoration).borderRadius! + .resolve(TextDirection.ltr) + .topLeft + .x; + +Color? _color(BoxSpec box) => (box.decoration as BoxDecoration?)?.color; diff --git a/packages/remix/test/components/badge/badge_spec_test.dart b/packages/remix/test/components/badge/badge_spec_test.dart index 160fdfc4..bc182223 100644 --- a/packages/remix/test/components/badge/badge_spec_test.dart +++ b/packages/remix/test/components/badge/badge_spec_test.dart @@ -189,7 +189,7 @@ void main() { test('props list contains all properties', () { const spec = RemixBadgeSpec(); - expect(spec.props, hasLength(2)); + expect(spec.props, hasLength(3)); expect(spec.props, contains(spec.container)); expect(spec.props, contains(spec.label)); }); @@ -200,7 +200,7 @@ void main() { final spec = RemixBadgeSpec(container: containerSpec, label: textSpec); - expect(spec.props, hasLength(2)); + expect(spec.props, hasLength(3)); expect(spec.props, contains(containerSpec)); expect(spec.props, contains(textSpec)); }); @@ -289,7 +289,7 @@ void main() { expect(spec.container, equals(complexContainerSpec)); expect(spec.label, equals(complexTextSpec)); - expect(spec.props, hasLength(2)); + expect(spec.props, hasLength(3)); }); }); }); diff --git a/packages/remix/test/components/badge/badge_style_test.dart b/packages/remix/test/components/badge/badge_style_test.dart index 95fd4e30..50258ce5 100644 --- a/packages/remix/test/components/badge/badge_style_test.dart +++ b/packages/remix/test/components/badge/badge_style_test.dart @@ -279,7 +279,7 @@ void main() { test('props list contains all properties', () { const style = RemixBadgeStyler.create(); - expect(style.props, hasLength(5)); + expect(style.props, hasLength(6)); expect(style.props, contains(style.$container)); expect(style.props, contains(style.$label)); expect(style.props, contains(style.$variants)); diff --git a/packages/remix/test/components/box_effect_layer_component_lerp_test.dart b/packages/remix/test/components/box_effect_layer_component_lerp_test.dart new file mode 100644 index 00000000..b4cd3511 --- /dev/null +++ b/packages/remix/test/components/box_effect_layer_component_lerp_test.dart @@ -0,0 +1,307 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; + +void main() { + const beginLayer = RemixBoxEffectLayerSpec( + shadows: [RemixBoxShadow(color: Colors.red)], + ); + const endLayer = RemixBoxEffectLayerSpec( + shadows: [RemixBoxShadow(color: Colors.blue)], + ); + + final badgeBegin = RemixBadgeSpec( + containerEffects: RemixBoxEffectsSpec( + behindContent: beginLayer, + overContent: beginLayer, + ), + ); + final badgeEnd = RemixBadgeSpec( + containerEffects: RemixBoxEffectsSpec( + behindContent: endLayer, + overContent: endLayer, + ), + ); + final buttonBegin = RemixButtonSpec( + containerEffects: RemixBoxEffectsSpec( + behindContent: beginLayer, + overContent: beginLayer, + ), + ); + final buttonEnd = RemixButtonSpec( + containerEffects: RemixBoxEffectsSpec( + behindContent: endLayer, + overContent: endLayer, + ), + ); + final calloutBegin = RemixCalloutSpec( + containerEffects: RemixBoxEffectsSpec( + behindContent: beginLayer, + overContent: beginLayer, + ), + ); + final calloutEnd = RemixCalloutSpec( + containerEffects: RemixBoxEffectsSpec( + behindContent: endLayer, + overContent: endLayer, + ), + ); + final cardBegin = RemixCardSpec( + containerEffects: RemixBoxEffectsSpec( + behindContent: beginLayer, + overContent: beginLayer, + ), + ); + final cardEnd = RemixCardSpec( + containerEffects: RemixBoxEffectsSpec( + behindContent: endLayer, + overContent: endLayer, + ), + ); + final checkboxBegin = RemixCheckboxSpec( + containerEffects: RemixBoxEffectsSpec( + behindContent: beginLayer, + overContent: beginLayer, + ), + ); + final checkboxEnd = RemixCheckboxSpec( + containerEffects: RemixBoxEffectsSpec( + behindContent: endLayer, + overContent: endLayer, + ), + ); + final dialogBegin = RemixDialogSpec( + containerEffects: RemixBoxEffectsSpec(behindContent: beginLayer), + ); + final dialogEnd = RemixDialogSpec( + containerEffects: RemixBoxEffectsSpec(behindContent: endLayer), + ); + final iconButtonBegin = RemixIconButtonSpec( + containerEffects: RemixBoxEffectsSpec( + behindContent: beginLayer, + overContent: beginLayer, + ), + ); + final iconButtonEnd = RemixIconButtonSpec( + containerEffects: RemixBoxEffectsSpec( + behindContent: endLayer, + overContent: endLayer, + ), + ); + final menuBegin = RemixMenuSpec( + containerEffects: RemixBoxEffectsSpec(behindContent: beginLayer), + ); + final menuEnd = RemixMenuSpec( + containerEffects: RemixBoxEffectsSpec(behindContent: endLayer), + ); + final popoverBegin = RemixPopoverSpec( + containerEffects: RemixBoxEffectsSpec(behindContent: beginLayer), + ); + final popoverEnd = RemixPopoverSpec( + containerEffects: RemixBoxEffectsSpec(behindContent: endLayer), + ); + final progressBegin = RemixProgressSpec( + trackEffects: RemixBoxEffectsSpec( + behindContent: beginLayer, + overContent: beginLayer, + ), + indicatorEffects: RemixBoxEffectsSpec( + behindContent: beginLayer, + overContent: beginLayer, + ), + ); + final progressEnd = RemixProgressSpec( + trackEffects: RemixBoxEffectsSpec( + behindContent: endLayer, + overContent: endLayer, + ), + indicatorEffects: RemixBoxEffectsSpec( + behindContent: endLayer, + overContent: endLayer, + ), + ); + final radioBegin = RemixRadioSpec( + containerEffects: RemixBoxEffectsSpec( + behindContent: beginLayer, + overContent: beginLayer, + ), + ); + final radioEnd = RemixRadioSpec( + containerEffects: RemixBoxEffectsSpec( + behindContent: endLayer, + overContent: endLayer, + ), + ); + final selectTriggerBegin = RemixSelectTriggerSpec( + containerEffects: RemixBoxEffectsSpec( + behindContent: beginLayer, + overContent: beginLayer, + ), + ); + final selectTriggerEnd = RemixSelectTriggerSpec( + containerEffects: RemixBoxEffectsSpec( + behindContent: endLayer, + overContent: endLayer, + ), + ); + final selectContentBegin = RemixSelectContentSpec( + containerEffects: RemixBoxEffectsSpec(behindContent: beginLayer), + ); + final selectContentEnd = RemixSelectContentSpec( + containerEffects: RemixBoxEffectsSpec(behindContent: endLayer), + ); + final sliderBegin = RemixSliderSpec( + trackEffects: RemixBoxEffectsSpec( + behindContent: beginLayer, + overContent: beginLayer, + ), + rangeEffects: RemixBoxEffectsSpec( + behindContent: beginLayer, + overContent: beginLayer, + ), + thumbEffects: RemixBoxEffectsSpec( + behindContent: beginLayer, + overContent: beginLayer, + ), + thumbFocusEffects: RemixBoxEffectsSpec(overContent: beginLayer), + ); + final sliderEnd = RemixSliderSpec( + trackEffects: RemixBoxEffectsSpec( + behindContent: endLayer, + overContent: endLayer, + ), + rangeEffects: RemixBoxEffectsSpec( + behindContent: endLayer, + overContent: endLayer, + ), + thumbEffects: RemixBoxEffectsSpec( + behindContent: endLayer, + overContent: endLayer, + ), + thumbFocusEffects: RemixBoxEffectsSpec(overContent: endLayer), + ); + final switchBegin = RemixSwitchSpec( + trackEffects: RemixBoxEffectsSpec( + behindContent: beginLayer, + overContent: beginLayer, + ), + thumbEffects: RemixBoxEffectsSpec( + behindContent: beginLayer, + overContent: beginLayer, + ), + ); + final switchEnd = RemixSwitchSpec( + trackEffects: RemixBoxEffectsSpec( + behindContent: endLayer, + overContent: endLayer, + ), + thumbEffects: RemixBoxEffectsSpec( + behindContent: endLayer, + overContent: endLayer, + ), + ); + final textFieldBegin = RemixTextFieldSpec( + containerEffects: RemixBoxEffectsSpec( + behindContent: beginLayer, + overContent: beginLayer, + ), + ); + final textFieldEnd = RemixTextFieldSpec( + containerEffects: RemixBoxEffectsSpec( + behindContent: endLayer, + overContent: endLayer, + ), + ); + + final cases = { + 'badge surface': (t) => + badgeBegin.lerp(badgeEnd, t).containerEffects?.behindContent, + 'badge overlay': (t) => + badgeBegin.lerp(badgeEnd, t).containerEffects?.overContent, + 'button surface': (t) => + buttonBegin.lerp(buttonEnd, t).containerEffects?.behindContent, + 'button overlay': (t) => + buttonBegin.lerp(buttonEnd, t).containerEffects?.overContent, + 'callout surface': (t) => + calloutBegin.lerp(calloutEnd, t).containerEffects?.behindContent, + 'callout overlay': (t) => + calloutBegin.lerp(calloutEnd, t).containerEffects?.overContent, + 'card surface': (t) => + cardBegin.lerp(cardEnd, t).containerEffects?.behindContent, + 'card overlay': (t) => + cardBegin.lerp(cardEnd, t).containerEffects?.overContent, + 'checkbox surface': (t) => + checkboxBegin.lerp(checkboxEnd, t).containerEffects?.behindContent, + 'checkbox overlay': (t) => + checkboxBegin.lerp(checkboxEnd, t).containerEffects?.overContent, + 'dialog surface': (t) => + dialogBegin.lerp(dialogEnd, t).containerEffects?.behindContent, + 'icon button surface': (t) => + iconButtonBegin.lerp(iconButtonEnd, t).containerEffects?.behindContent, + 'icon button overlay': (t) => + iconButtonBegin.lerp(iconButtonEnd, t).containerEffects?.overContent, + 'menu surface': (t) => + menuBegin.lerp(menuEnd, t).containerEffects?.behindContent, + 'popover surface': (t) => + popoverBegin.lerp(popoverEnd, t).containerEffects?.behindContent, + 'progress surface': (t) => + progressBegin.lerp(progressEnd, t).trackEffects?.behindContent, + 'progress overlay': (t) => + progressBegin.lerp(progressEnd, t).trackEffects?.overContent, + 'progress indicator surface': (t) => + progressBegin.lerp(progressEnd, t).indicatorEffects?.behindContent, + 'progress indicator overlay': (t) => + progressBegin.lerp(progressEnd, t).indicatorEffects?.overContent, + 'radio surface': (t) => + radioBegin.lerp(radioEnd, t).containerEffects?.behindContent, + 'radio overlay': (t) => + radioBegin.lerp(radioEnd, t).containerEffects?.overContent, + 'select trigger surface': (t) => selectTriggerBegin + .lerp(selectTriggerEnd, t) + .containerEffects + ?.behindContent, + 'select trigger overlay': (t) => selectTriggerBegin + .lerp(selectTriggerEnd, t) + .containerEffects + ?.overContent, + 'select content surface': (t) => selectContentBegin + .lerp(selectContentEnd, t) + .containerEffects + ?.behindContent, + 'slider track surface': (t) => + sliderBegin.lerp(sliderEnd, t).trackEffects?.behindContent, + 'slider track overlay': (t) => + sliderBegin.lerp(sliderEnd, t).trackEffects?.overContent, + 'slider range surface': (t) => + sliderBegin.lerp(sliderEnd, t).rangeEffects?.behindContent, + 'slider range overlay': (t) => + sliderBegin.lerp(sliderEnd, t).rangeEffects?.overContent, + 'slider thumb surface': (t) => + sliderBegin.lerp(sliderEnd, t).thumbEffects?.behindContent, + 'slider thumb overlay': (t) => + sliderBegin.lerp(sliderEnd, t).thumbEffects?.overContent, + 'slider thumb focus overlay': (t) => + sliderBegin.lerp(sliderEnd, t).thumbFocusEffects?.overContent, + 'switch surface': (t) => + switchBegin.lerp(switchEnd, t).trackEffects?.behindContent, + 'switch overlay': (t) => + switchBegin.lerp(switchEnd, t).trackEffects?.overContent, + 'switch thumb surface': (t) => + switchBegin.lerp(switchEnd, t).thumbEffects?.behindContent, + 'switch thumb overlay': (t) => + switchBegin.lerp(switchEnd, t).thumbEffects?.overContent, + 'text field surface': (t) => + textFieldBegin.lerp(textFieldEnd, t).containerEffects?.behindContent, + 'text field overlay': (t) => + textFieldBegin.lerp(textFieldEnd, t).containerEffects?.overContent, + }; + + for (final entry in cases.entries) { + test('${entry.key} interpolates instead of snapping', () { + expect( + entry.value(0.25)?.shadows.first.color, + Color.lerp(Colors.red, Colors.blue, 0.25), + ); + }); + } +} diff --git a/packages/remix/test/components/button/button_spec_test.dart b/packages/remix/test/components/button/button_spec_test.dart index 9a14f5f3..44c2d1bf 100644 --- a/packages/remix/test/components/button/button_spec_test.dart +++ b/packages/remix/test/components/button/button_spec_test.dart @@ -170,7 +170,7 @@ void main() { test('props list contains all properties', () { const spec = RemixButtonSpec(); - expect(spec.props, hasLength(5)); + expect(spec.props, hasLength(6)); expect(spec.props, contains(spec.container)); expect(spec.props, contains(spec.label)); expect(spec.props, contains(spec.icon)); @@ -204,7 +204,7 @@ void main() { spec.debugFillProperties(builder); final properties = builder.properties; - expect(properties, hasLength(5)); + expect(properties, hasLength(6)); // Check that all expected properties are present final propertyNames = properties.map((p) => p.name).toList(); diff --git a/packages/remix/test/components/button/button_style_test.dart b/packages/remix/test/components/button/button_style_test.dart index 6cd3d675..0a8eda2a 100644 --- a/packages/remix/test/components/button/button_style_test.dart +++ b/packages/remix/test/components/button/button_style_test.dart @@ -513,9 +513,16 @@ void main() { final spacings = resolvedBySize.values .map((spec) => spec.spec.container.spec.flex?.spec.spacing) .toSet(); + final heights = resolvedBySize.values + .map( + (spec) => + spec.spec.container.spec.box?.spec.constraints?.minHeight, + ) + .toSet(); expect(paddings, hasLength(FortalButtonSize.values.length)); - expect(spacings, hasLength(FortalButtonSize.values.length)); + expect(heights, hasLength(FortalButtonSize.values.length)); + expect(spacings, hasLength(3)); }); }); } diff --git a/packages/remix/test/components/callout/callout_fortal_parity_test.dart b/packages/remix/test/components/callout/callout_fortal_parity_test.dart new file mode 100644 index 00000000..f03d1f55 --- /dev/null +++ b/packages/remix/test/components/callout/callout_fortal_parity_test.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; + +void main() { + test('defaults to Radix size2 and soft', () { + const callout = FortalCallout(child: Text('Heads up')); + + expect(callout.size, FortalCalloutSize.size2); + expect(callout.variant, FortalCalloutVariant.soft); + }); + + group('Fortal Callout geometry', () { + for (final (size, padding, gap, radius, fontSize) in const [ + (FortalCalloutSize.size1, 12.0, 8.0, 6.0, 14.0), + (FortalCalloutSize.size2, 16.0, 12.0, 8.0, 14.0), + (FortalCalloutSize.size3, 24.0, 16.0, 12.0, 16.0), + ]) { + testWidgets('${size.name} matches pinned metrics', (tester) async { + final spec = await _resolve(tester, fortalCalloutStyler(size: size)); + final flex = spec.container.spec; + final box = flex.box!.spec; + + expect(box.padding, EdgeInsets.all(padding)); + expect(flex.flex!.spec.spacing, gap); + expect(_radius(box), radius); + expect(spec.text.spec.style!.fontSize, fontSize); + expect(flex.flex!.spec.crossAxisAlignment, CrossAxisAlignment.start); + }); + } + + testWidgets('geometry and type scale with Fortal scaling', (tester) async { + final spec = await _resolve( + tester, + fortalCalloutStyler(size: .size3), + scaling: .percent110, + ); + expect( + spec.container.spec.box!.spec.padding!.resolve(TextDirection.ltr).left, + closeTo(26.4, 1e-9), + ); + expect(spec.container.spec.flex!.spec.spacing, closeTo(17.6, 1e-9)); + expect(spec.text.spec.style!.fontSize, closeTo(17.6, 1e-9)); + }); + }); + + group('Fortal Callout variants', () { + testWidgets('variants resolve pinned alpha fills and inset strokes', ( + tester, + ) async { + final tokens = await _tokens(tester); + final soft = await _resolve(tester, fortalCalloutStyler()); + final surface = await _resolve( + tester, + fortalCalloutStyler(variant: .surface), + ); + final outlineHigh = await _resolve( + tester, + fortalCalloutStyler(variant: .outline, highContrast: true), + ); + + expect(_color(soft.container), tokens.accentA3); + expect(soft.text.spec.style!.color, tokens.accentA11); + expect(_color(surface.container), tokens.accentA2); + expect( + surface.containerEffects!.behindContent!.shadows.single.color, + tokens.accentA6, + ); + expect( + outlineHigh.containerEffects!.behindContent!.shadows.single.color, + tokens.accentA7, + ); + expect(outlineHigh.text.spec.style!.color, tokens.accent12); + }); + }); +} + +Future _resolve( + WidgetTester tester, + RemixCalloutStyler style, { + FortalScaling scaling = .percent100, +}) async { + late RemixCalloutSpec result; + await tester.pumpWidget( + FortalScope( + brightness: .light, + scaling: scaling, + child: WidgetsApp( + color: Colors.black, + builder: (context, child) { + result = style.build(context).spec; + return const SizedBox.shrink(); + }, + ), + ), + ); + return result; +} + +Future< + ({ + Color accent12, + Color accentA2, + Color accentA3, + Color accentA6, + Color accentA7, + Color accentA11, + }) +> +_tokens(WidgetTester tester) async { + late ({ + Color accent12, + Color accentA2, + Color accentA3, + Color accentA6, + Color accentA7, + Color accentA11, + }) + result; + await tester.pumpWidget( + FortalScope( + brightness: .light, + child: WidgetsApp( + color: Colors.black, + builder: (context, child) { + Color token(ColorToken value) => MixScope.tokenOf(value, context); + result = ( + accent12: token(FortalTokens.accent12), + accentA2: token(FortalTokens.accentA2), + accentA3: token(FortalTokens.accentA3), + accentA6: token(FortalTokens.accentA6), + accentA7: token(FortalTokens.accentA7), + accentA11: token(FortalTokens.accentA11), + ); + return const SizedBox.shrink(); + }, + ), + ), + ); + return result; +} + +double _radius(BoxSpec box) => (box.decoration as BoxDecoration).borderRadius! + .resolve(TextDirection.ltr) + .topLeft + .x; + +Color? _color(StyleSpec style) => + (style.spec.box?.spec.decoration as BoxDecoration?)?.color; diff --git a/packages/remix/test/components/callout/callout_spec_test.dart b/packages/remix/test/components/callout/callout_spec_test.dart index 7657a9dc..6cbdce4a 100644 --- a/packages/remix/test/components/callout/callout_spec_test.dart +++ b/packages/remix/test/components/callout/callout_spec_test.dart @@ -278,7 +278,7 @@ void main() { test('props list contains all properties', () { const spec = RemixCalloutSpec(); - expect(spec.props, hasLength(3)); + expect(spec.props, hasLength(4)); expect(spec.props, contains(spec.container)); expect(spec.props, contains(spec.text)); expect(spec.props, contains(spec.icon)); @@ -295,7 +295,7 @@ void main() { icon: iconSpec, ); - expect(spec.props, hasLength(3)); + expect(spec.props, hasLength(4)); expect(spec.props, contains(containerSpec)); expect(spec.props, contains(textSpec)); expect(spec.props, contains(iconSpec)); @@ -402,7 +402,7 @@ void main() { expect(spec.container, equals(complexContainerSpec)); expect(spec.text, equals(complexTextSpec)); expect(spec.icon, equals(complexIconSpec)); - expect(spec.props, hasLength(3)); + expect(spec.props, hasLength(4)); }); test('spec equality with complex properties', () { diff --git a/packages/remix/test/components/callout/callout_style_test.dart b/packages/remix/test/components/callout/callout_style_test.dart index 8b7391cd..01bdb6a1 100644 --- a/packages/remix/test/components/callout/callout_style_test.dart +++ b/packages/remix/test/components/callout/callout_style_test.dart @@ -366,7 +366,7 @@ void main() { test('props list contains all properties', () { const style = RemixCalloutStyler.create(); - expect(style.props, hasLength(6)); + expect(style.props, hasLength(7)); expect(style.props, contains(style.$container)); expect(style.props, contains(style.$text)); expect(style.props, contains(style.$icon)); diff --git a/packages/remix/test/components/card/card_fortal_parity_test.dart b/packages/remix/test/components/card/card_fortal_parity_test.dart new file mode 100644 index 00000000..82454323 --- /dev/null +++ b/packages/remix/test/components/card/card_fortal_parity_test.dart @@ -0,0 +1,371 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; + +void main() { + test('public contract has the pinned enum order and defaults', () { + expect(FortalCardSize.values, [ + FortalCardSize.size1, + FortalCardSize.size2, + FortalCardSize.size3, + FortalCardSize.size4, + FortalCardSize.size5, + ]); + expect(FortalCardVariant.values, [ + FortalCardVariant.surface, + FortalCardVariant.classic, + FortalCardVariant.ghost, + ]); + + const card = FortalCard(child: Text('Card')); + expect(card.size, FortalCardSize.size1); + expect(card.variant, FortalCardVariant.surface); + }); + + group('geometry', () { + for (final (size, padding, radius) in const [ + (FortalCardSize.size1, 12.0, 8.0), + (FortalCardSize.size2, 16.0, 8.0), + (FortalCardSize.size3, 24.0, 12.0), + (FortalCardSize.size4, 32.0, 12.0), + (FortalCardSize.size5, 48.0, 16.0), + ]) { + testWidgets('${size.name} matches the pinned padding and radius', ( + tester, + ) async { + final spec = await _resolve(tester, fortalCardStyler(size: size)); + final box = spec.container.spec; + + expect(box.padding, EdgeInsets.all(padding)); + expect(_radius(box), radius); + expect(box.clipBehavior, Clip.antiAlias); + }); + } + + testWidgets('padding and radius scale with the theme', (tester) async { + final spec = await _resolve( + tester, + fortalCardStyler(size: .size5), + scaling: .percent110, + ); + expect( + spec.container.spec.padding!.resolve(TextDirection.ltr).left, + closeTo(52.8, 1e-9), + ); + expect(_radius(spec.container.spec), closeTo(17.6, 1e-9)); + }); + }); + + group('variants and states', () { + testWidgets('surface uses the panel layer and exact mixed strokes', ( + tester, + ) async { + final tokens = await _tokens(tester); + final idle = await _resolve(tester, fortalCardStyler()); + final hovered = await _resolve( + tester, + fortalCardStyler(), + states: {WidgetState.hovered}, + ); + final pressed = await _resolve( + tester, + fortalCardStyler(), + states: {WidgetState.pressed}, + ); + final open = await _resolve( + tester, + fortalCardStyler(), + states: {WidgetState.selected}, + ); + final openAndPressed = await _resolve( + tester, + fortalCardStyler(), + states: {WidgetState.selected, WidgetState.pressed}, + ); + + expect(idle.containerEffects!.backdropBlur, 64); + expect(idle.containerEffects!.behindContent!.gradientInsets, [1]); + expect( + (idle.containerEffects!.behindContent!.gradients.single + as LinearGradient) + .colors, + [tokens.panel, tokens.panel], + ); + expect( + idle.containerEffects!.overContent!.shadows.single.color, + tokens.stroke5, + ); + expect(idle.containerEffects!.overContent!.shadows.single.shapeInset, 1); + expect( + hovered.containerEffects!.overContent!.shadows.single.color, + tokens.stroke7, + ); + expect( + pressed.containerEffects!.overContent!.shadows.single.color, + tokens.stroke6, + ); + expect( + open.containerEffects!.overContent!.shadows.single.color, + tokens.stroke7, + ); + expect( + openAndPressed.containerEffects!.overContent!.shadows.single.color, + tokens.stroke7, + ); + }); + + testWidgets('classic preserves separate outer and inset shadow stacks', ( + tester, + ) async { + final idle = await _resolve(tester, fortalCardStyler(variant: .classic)); + final hovered = await _resolve( + tester, + fortalCardStyler(variant: .classic), + states: {WidgetState.hovered}, + ); + final pressed = await _resolve( + tester, + fortalCardStyler(variant: .classic), + states: {WidgetState.pressed}, + ); + final open = await _resolve( + tester, + fortalCardStyler(variant: .classic), + states: {WidgetState.selected}, + ); + final openAndPressed = await _resolve( + tester, + fortalCardStyler(variant: .classic), + states: {WidgetState.selected, WidgetState.pressed}, + ); + + for (final spec in [idle, hovered, pressed]) { + expect(spec.containerEffects!.behindContent!.shadows, hasLength(6)); + expect(spec.containerEffects!.overContent!.shadows, hasLength(6)); + expect( + spec.containerEffects!.behindContent!.shadows.every( + (shadow) => shadow.shapeInset == 0, + ), + isTrue, + ); + expect( + spec.containerEffects!.overContent!.shadows.every( + (shadow) => shadow.shapeInset == 1, + ), + isTrue, + ); + } + expect( + hovered.containerEffects!.behindContent!.shadows, + isNot(idle.containerEffects!.behindContent!.shadows), + ); + expect( + pressed.containerEffects!.overContent!.shadows, + isNot(idle.containerEffects!.overContent!.shadows), + ); + expect( + open.containerEffects!.behindContent!.shadows, + hovered.containerEffects!.behindContent!.shadows, + ); + expect( + open.containerEffects!.overContent!.shadows, + hovered.containerEffects!.overContent!.shadows, + ); + expect( + openAndPressed.containerEffects!.behindContent!.shadows, + hovered.containerEffects!.behindContent!.shadows, + ); + expect( + openAndPressed.containerEffects!.overContent!.shadows, + hovered.containerEffects!.overContent!.shadows, + ); + }); + + testWidgets('ghost cancels its padding and uses exact state fills', ( + tester, + ) async { + final tokens = await _tokens(tester); + final idle = await _resolve( + tester, + fortalCardStyler(variant: .ghost, size: .size3), + ); + final hovered = await _resolve( + tester, + fortalCardStyler(variant: .ghost, size: .size3), + states: {WidgetState.hovered}, + ); + final pressed = await _resolve( + tester, + fortalCardStyler(variant: .ghost, size: .size3), + states: {WidgetState.pressed}, + ); + final open = await _resolve( + tester, + fortalCardStyler(variant: .ghost, size: .size3), + states: {WidgetState.selected}, + ); + final openAndPressed = await _resolve( + tester, + fortalCardStyler(variant: .ghost, size: .size3), + states: {WidgetState.selected, WidgetState.pressed}, + ); + + expect(idle.container.spec.padding, const EdgeInsets.all(24)); + expect(idle.container.spec.margin, const EdgeInsets.all(-24)); + expect(_boxColor(idle.container), Colors.transparent); + expect(_boxColor(hovered.container), tokens.grayA3); + expect(_boxColor(pressed.container), tokens.grayA4); + expect(_boxColor(open.container), tokens.grayA3); + expect(_boxColor(openAndPressed.container), tokens.grayA3); + }); + + testWidgets('focus uses the pinned non-layout outline', (tester) async { + final tokens = await _tokens(tester); + final focused = await _resolve( + tester, + fortalCardStyler(), + states: {WidgetState.focused}, + ); + + expect(focused.containerEffects!.outline.color, tokens.focus8); + expect(focused.containerEffects!.outline.width, 2); + expect(focused.containerEffects!.outlineOffset, -1); + }); + + testWidgets('focused active and ghost compound states match CSS', ( + tester, + ) async { + final tokens = await _tokens(tester); + for (final variant in [ + FortalCardVariant.surface, + FortalCardVariant.classic, + ]) { + final activeAndFocused = await _resolve( + tester, + fortalCardStyler(variant: variant), + states: {WidgetState.pressed, WidgetState.focused}, + ); + expect( + activeAndFocused.containerEffects!.behindContent!.gradients, + hasLength(2), + ); + expect( + (activeAndFocused.containerEffects!.behindContent!.gradients.first + as LinearGradient) + .colors, + [tokens.accentA2, tokens.accentA2], + ); + + final openAndFocused = await _resolve( + tester, + fortalCardStyler(variant: variant), + states: {WidgetState.selected, WidgetState.focused}, + ); + expect( + openAndFocused.containerEffects!.behindContent!.gradients, + hasLength(1), + ); + } + + for (final states in [ + {WidgetState.hovered, WidgetState.focused}, + {WidgetState.pressed, WidgetState.focused}, + {WidgetState.selected, WidgetState.focused}, + {WidgetState.selected, WidgetState.pressed, WidgetState.focused}, + ]) { + final ghost = await _resolve( + tester, + fortalCardStyler(variant: .ghost), + states: states, + ); + expect(_boxColor(ghost.container), tokens.accentA2); + } + }); + }); + +} + +Future _resolve( + WidgetTester tester, + RemixCardStyler style, { + FortalScaling scaling = .percent100, + Set states = const {}, +}) async { + late RemixCardSpec result; + await tester.pumpWidget( + FortalScope( + brightness: .light, + scaling: scaling, + child: WidgetsApp( + color: Colors.black, + builder: (context, child) => WidgetStateProvider( + states: states, + child: Builder( + builder: (context) { + result = style.build(context).spec; + return const SizedBox.shrink(); + }, + ), + ), + ), + ), + ); + return result; +} + +Future< + ({ + Color panel, + Color stroke5, + Color stroke6, + Color stroke7, + Color grayA3, + Color grayA4, + Color accentA2, + Color focus8, + }) +> +_tokens(WidgetTester tester) async { + late ({ + Color panel, + Color stroke5, + Color stroke6, + Color stroke7, + Color grayA3, + Color grayA4, + Color accentA2, + Color focus8, + }) + result; + await tester.pumpWidget( + FortalScope( + brightness: .light, + child: WidgetsApp( + color: Colors.black, + builder: (context, child) { + Color token(ColorToken value) => MixScope.tokenOf(value, context); + result = ( + panel: token(FortalTokens.colorPanel), + stroke5: token(FortalTokens.grayStroke5), + stroke6: token(FortalTokens.grayStroke6), + stroke7: token(FortalTokens.grayStroke7), + grayA3: token(FortalTokens.grayA3), + grayA4: token(FortalTokens.grayA4), + accentA2: token(FortalTokens.accentA2), + focus8: token(FortalTokens.focus8), + ); + return const SizedBox.shrink(); + }, + ), + ), + ); + return result; +} + +double _radius(BoxSpec box) => (box.decoration as BoxDecoration).borderRadius! + .resolve(TextDirection.ltr) + .topLeft + .x; + +Color? _boxColor(StyleSpec style) => + (style.spec.decoration as BoxDecoration?)?.color; diff --git a/packages/remix/test/components/card/card_spec_test.dart b/packages/remix/test/components/card/card_spec_test.dart index a5147bd5..c566b629 100644 --- a/packages/remix/test/components/card/card_spec_test.dart +++ b/packages/remix/test/components/card/card_spec_test.dart @@ -138,7 +138,7 @@ void main() { test('props list contains all properties', () { const spec = RemixCardSpec(); - expect(spec.props, hasLength(1)); + expect(spec.props, hasLength(2)); expect(spec.props, contains(spec.container)); }); @@ -147,7 +147,7 @@ void main() { final spec = RemixCardSpec(container: containerSpec); - expect(spec.props, hasLength(1)); + expect(spec.props, hasLength(2)); expect(spec.props, contains(containerSpec)); }); }); @@ -220,7 +220,7 @@ void main() { final spec = RemixCardSpec(container: complexContainerSpec); expect(spec.container, equals(complexContainerSpec)); - expect(spec.props, hasLength(1)); + expect(spec.props, hasLength(2)); }); }); }); diff --git a/packages/remix/test/components/card/card_style_test.dart b/packages/remix/test/components/card/card_style_test.dart index 30cabb1d..323471df 100644 --- a/packages/remix/test/components/card/card_style_test.dart +++ b/packages/remix/test/components/card/card_style_test.dart @@ -285,7 +285,7 @@ void main() { test('props list contains all properties', () { const style = RemixCardStyler.create(); - expect(style.props, hasLength(4)); + expect(style.props, hasLength(5)); expect(style.props, contains(style.$container)); expect(style.props, contains(style.$variants)); expect(style.props, contains(style.$animation)); diff --git a/packages/remix/test/components/checkbox/checkbox_spec_test.dart b/packages/remix/test/components/checkbox/checkbox_spec_test.dart index 388c8c58..854dec6d 100644 --- a/packages/remix/test/components/checkbox/checkbox_spec_test.dart +++ b/packages/remix/test/components/checkbox/checkbox_spec_test.dart @@ -211,7 +211,7 @@ void main() { test('props list contains all properties', () { const spec = RemixCheckboxSpec(); - expect(spec.props, hasLength(2)); + expect(spec.props, hasLength(3)); expect(spec.props, contains(spec.container)); expect(spec.props, contains(spec.indicator)); }); @@ -225,7 +225,7 @@ void main() { indicator: indicatorSpec, ); - expect(spec.props, hasLength(2)); + expect(spec.props, hasLength(3)); expect(spec.props, contains(containerSpec)); expect(spec.props, contains(indicatorSpec)); }); @@ -317,7 +317,7 @@ void main() { expect(spec.container, equals(complexContainerSpec)); expect(spec.indicator, equals(complexIndicatorSpec)); - expect(spec.props, hasLength(2)); + expect(spec.props, hasLength(3)); }); }); }); diff --git a/packages/remix/test/components/checkbox/checkbox_style_test.dart b/packages/remix/test/components/checkbox/checkbox_style_test.dart index 16452a4f..abdc6523 100644 --- a/packages/remix/test/components/checkbox/checkbox_style_test.dart +++ b/packages/remix/test/components/checkbox/checkbox_style_test.dart @@ -318,7 +318,7 @@ void main() { test('props list contains all properties', () { const style = RemixCheckboxStyler.create(); - expect(style.props, hasLength(5)); + expect(style.props, hasLength(6)); expect(style.props, contains(style.$container)); expect(style.props, contains(style.$indicator)); expect(style.props, contains(style.$variants)); diff --git a/packages/remix/test/components/checkbox/fortal_checkbox_disabled_test.dart b/packages/remix/test/components/checkbox/fortal_checkbox_disabled_test.dart new file mode 100644 index 00000000..ab9b5ffd --- /dev/null +++ b/packages/remix/test/components/checkbox/fortal_checkbox_disabled_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; + +void main() { + testWidgets('disabled variants resolve the Radix gray-a3 surface', ( + tester, + ) async { + late Color expected; + late Map resolvedColors; + + await tester.pumpWidget( + FortalScope( + child: MaterialApp( + home: WidgetStateProvider( + states: const {WidgetState.disabled}, + child: Builder( + builder: (context) { + expected = FortalTokens.grayA3.resolve(context); + resolvedColors = { + for (final variant in FortalCheckboxVariant.values) + variant: + (fortalCheckboxStyler( + variant: variant, + ).build(context).spec.container.spec.decoration + as BoxDecoration?) + ?.color, + }; + return const SizedBox.shrink(); + }, + ), + ), + ), + ), + ); + + for (final entry in resolvedColors.entries) { + expect(entry.value, expected, reason: entry.key.name); + } + }); +} diff --git a/packages/remix/test/components/dialog/dialog_spec_test.dart b/packages/remix/test/components/dialog/dialog_spec_test.dart index 9afb3ad7..b338f788 100644 --- a/packages/remix/test/components/dialog/dialog_spec_test.dart +++ b/packages/remix/test/components/dialog/dialog_spec_test.dart @@ -52,7 +52,7 @@ void main() { final result = first.lerp(second, 0.5); expect(result, isA()); - expect(result.props, hasLength(4)); + expect(result.props, hasLength(5)); expect( result.props, containsAll([ diff --git a/packages/remix/test/components/dialog/dialog_style_test.dart b/packages/remix/test/components/dialog/dialog_style_test.dart index ea576404..ef21e10f 100644 --- a/packages/remix/test/components/dialog/dialog_style_test.dart +++ b/packages/remix/test/components/dialog/dialog_style_test.dart @@ -326,7 +326,7 @@ void main() { test('props list contains all properties', () { const style = RemixDialogStyler.create(); - expect(style.props, hasLength(7)); + expect(style.props, hasLength(8)); expect(style.props, contains(style.$container)); expect(style.props, contains(style.$title)); expect(style.props, contains(style.$description)); diff --git a/packages/remix/test/components/fortal_widget_test.dart b/packages/remix/test/components/fortal_widget_test.dart index e010f1a0..229f1242 100644 --- a/packages/remix/test/components/fortal_widget_test.dart +++ b/packages/remix/test/components/fortal_widget_test.dart @@ -78,7 +78,7 @@ void main() { final label = tester.widget(find.text(variant.name)); expect( label.style?.color, - colors.accent.scale.step(12), + colors.accent.scale.alphaStep(11), reason: '${variant.name} badges use the accent text step', ); } diff --git a/packages/remix/test/components/icon_button/icon_button_spec_test.dart b/packages/remix/test/components/icon_button/icon_button_spec_test.dart index d3ada8df..65431c08 100644 --- a/packages/remix/test/components/icon_button/icon_button_spec_test.dart +++ b/packages/remix/test/components/icon_button/icon_button_spec_test.dart @@ -260,7 +260,7 @@ void main() { test('props list contains all properties', () { const spec = RemixIconButtonSpec(); - expect(spec.props, hasLength(3)); + expect(spec.props, hasLength(4)); expect(spec.props, contains(spec.container)); expect(spec.props, contains(spec.icon)); expect(spec.props, contains(spec.spinner)); @@ -277,7 +277,7 @@ void main() { spinner: spinnerSpec, ); - expect(spec.props, hasLength(3)); + expect(spec.props, hasLength(4)); expect(spec.props, contains(containerSpec)); expect(spec.props, contains(iconSpec)); expect(spec.props, contains(spinnerSpec)); @@ -384,7 +384,7 @@ void main() { expect(spec.container, equals(complexContainerSpec)); expect(spec.icon, equals(complexIconSpec)); expect(spec.spinner, equals(complexSpinnerSpec)); - expect(spec.props, hasLength(3)); + expect(spec.props, hasLength(4)); }); }); }); diff --git a/packages/remix/test/components/icon_button/icon_button_style_test.dart b/packages/remix/test/components/icon_button/icon_button_style_test.dart index 131cd59b..649ec9ae 100644 --- a/packages/remix/test/components/icon_button/icon_button_style_test.dart +++ b/packages/remix/test/components/icon_button/icon_button_style_test.dart @@ -413,7 +413,7 @@ void main() { test('props list contains all properties', () { const style = RemixIconButtonStyler.create(); - expect(style.props, hasLength(6)); + expect(style.props, hasLength(7)); expect(style.props, contains(style.$container)); expect(style.props, contains(style.$icon)); expect(style.props, contains(style.$spinner)); diff --git a/packages/remix/test/components/menu/menu_spec_test.dart b/packages/remix/test/components/menu/menu_spec_test.dart index 4c1fec15..07b3177f 100644 --- a/packages/remix/test/components/menu/menu_spec_test.dart +++ b/packages/remix/test/components/menu/menu_spec_test.dart @@ -302,7 +302,7 @@ void main() { test('props list contains all properties', () { const spec = RemixMenuSpec(); - expect(spec.props, hasLength(4)); + expect(spec.props, hasLength(5)); expect(spec.props, contains(spec.trigger)); expect(spec.props, contains(spec.overlay)); expect(spec.props, contains(spec.item)); diff --git a/packages/remix/test/components/menu/menu_style_test.dart b/packages/remix/test/components/menu/menu_style_test.dart index 75736cc9..cef4ed64 100644 --- a/packages/remix/test/components/menu/menu_style_test.dart +++ b/packages/remix/test/components/menu/menu_style_test.dart @@ -464,8 +464,8 @@ void main() { void onOpenRequested(Offset? position, VoidCallback showOverlay) {} void onCloseRequested(VoidCallback hideOverlay) {} const positioning = OverlayPositionConfig( - targetAnchor: Alignment.topLeft, - followerAnchor: Alignment.bottomRight, + side: OverlaySide.top, + alignment: OverlayAlignment.end, ); final menu = style.call( diff --git a/packages/remix/test/components/menu/menu_widget_test.dart b/packages/remix/test/components/menu/menu_widget_test.dart index 6d2e0435..f1f05660 100644 --- a/packages/remix/test/components/menu/menu_widget_test.dart +++ b/packages/remix/test/components/menu/menu_widget_test.dart @@ -636,8 +636,8 @@ void main() { group('RemixMenu Positioning Tests', () { testWidgets('applies custom positioning config', (tester) async { const positioning = OverlayPositionConfig( - targetAnchor: Alignment.bottomLeft, - followerAnchor: Alignment.topLeft, + side: OverlaySide.bottom, + alignment: OverlayAlignment.start, ); await tester.pumpRemixApp( diff --git a/packages/remix/test/components/popover/popover_spec_test.dart b/packages/remix/test/components/popover/popover_spec_test.dart index 97ea699c..3dcc93b8 100644 --- a/packages/remix/test/components/popover/popover_spec_test.dart +++ b/packages/remix/test/components/popover/popover_spec_test.dart @@ -9,7 +9,7 @@ void main() { const spec = RemixPopoverSpec(); expect(spec.container, isA>()); - expect(spec.props, [spec.container]); + expect(spec.props, [spec.container, spec.containerEffects]); }); test('accepts and copies a custom container spec', () { diff --git a/packages/remix/test/components/popover/popover_widget_test.dart b/packages/remix/test/components/popover/popover_widget_test.dart index 64572587..cc38c08d 100644 --- a/packages/remix/test/components/popover/popover_widget_test.dart +++ b/packages/remix/test/components/popover/popover_widget_test.dart @@ -247,7 +247,7 @@ void main() { matching: find.byType(Box), ), ); - expect(box.styleSpec, same(rawContainer)); + expect(box.styleSpec, equals(rawContainer)); }); testWidgets('FortalPopover supplies the themed overlay style', ( @@ -264,13 +264,6 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Fortal content'), findsOneWidget); - expect( - find.ancestor( - of: find.text('Fortal content'), - matching: find.byType(Box), - ), - findsOneWidget, - ); }); }); } diff --git a/packages/remix/test/components/progress/progress_spec_test.dart b/packages/remix/test/components/progress/progress_spec_test.dart index 7660be4b..8ac28780 100644 --- a/packages/remix/test/components/progress/progress_spec_test.dart +++ b/packages/remix/test/components/progress/progress_spec_test.dart @@ -135,7 +135,7 @@ void main() { test('props list contains all properties', () { const spec = RemixProgressSpec(); - expect(spec.props, hasLength(4)); + expect(spec.props, hasLength(6)); expect(spec.props, contains(spec.container)); expect(spec.props, contains(spec.track)); expect(spec.props, contains(spec.indicator)); diff --git a/packages/remix/test/components/radio/radio_spec_test.dart b/packages/remix/test/components/radio/radio_spec_test.dart index 0ce49d44..758fa398 100644 --- a/packages/remix/test/components/radio/radio_spec_test.dart +++ b/packages/remix/test/components/radio/radio_spec_test.dart @@ -118,7 +118,7 @@ void main() { test('props list contains all properties', () { const spec = RemixRadioSpec(); - expect(spec.props, hasLength(2)); + expect(spec.props, hasLength(3)); expect(spec.props, contains(spec.container)); expect(spec.props, contains(spec.indicator)); }); diff --git a/packages/remix/test/components/select/select_fortal_parity_test.dart b/packages/remix/test/components/select/select_fortal_parity_test.dart new file mode 100644 index 00000000..fdeae99f --- /dev/null +++ b/packages/remix/test/components/select/select_fortal_parity_test.dart @@ -0,0 +1,425 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; + +void main() { + group('Fortal Select geometry', () { + for (final (size, expected) + in < + ( + FortalSelectSize, + ({ + double triggerHeight, + double triggerPadding, + double gap, + double triggerRadius, + double chevron, + double contentPadding, + double contentRadius, + double itemHeight, + double indicatorWidth, + double indicatorSize, + double itemRadius, + double itemFontSize, + }), + ) + >[ + ( + FortalSelectSize.size1, + ( + triggerHeight: 24, + triggerPadding: 8, + gap: 4, + triggerRadius: 3, + chevron: 9, + contentPadding: 4, + contentRadius: 6, + itemHeight: 24, + indicatorWidth: 20, + indicatorSize: 8, + itemRadius: 3, + itemFontSize: 12, + ), + ), + ( + FortalSelectSize.size2, + ( + triggerHeight: 32, + triggerPadding: 12, + gap: 6, + triggerRadius: 4, + chevron: 9, + contentPadding: 8, + contentRadius: 8, + itemHeight: 32, + indicatorWidth: 24, + indicatorSize: 10, + itemRadius: 4, + itemFontSize: 14, + ), + ), + ( + FortalSelectSize.size3, + ( + triggerHeight: 40, + triggerPadding: 16, + gap: 8, + triggerRadius: 6, + chevron: 11, + contentPadding: 8, + contentRadius: 8, + itemHeight: 32, + indicatorWidth: 24, + indicatorSize: 10, + itemRadius: 4, + itemFontSize: 16, + ), + ), + ]) { + testWidgets('${size.name} matches the pinned CSS metrics', ( + tester, + ) async { + final spec = await _resolve(tester, fortalSelectStyler(size: size)); + final trigger = spec.trigger.spec; + final triggerBox = trigger.container.spec.box!.spec; + final content = spec.content.spec; + final item = spec.item.spec; + final itemBox = item.container.spec.box!.spec; + + expect(triggerBox.constraints!.minHeight, expected.triggerHeight); + expect(triggerBox.constraints!.maxHeight, expected.triggerHeight); + expect( + triggerBox.padding, + EdgeInsets.symmetric(horizontal: expected.triggerPadding), + ); + expect(trigger.container.spec.flex!.spec.spacing, expected.gap); + expect(_radius(triggerBox), expected.triggerRadius); + expect(trigger.chevron.spec.size, expected.chevron); + + expect( + content.container.spec.padding, + EdgeInsets.all(expected.contentPadding), + ); + expect(_radius(content.container.spec), expected.contentRadius); + expect(content.container.spec.clipBehavior, Clip.antiAlias); + + expect(itemBox.constraints!.minHeight, expected.itemHeight); + expect(itemBox.constraints!.maxHeight, expected.itemHeight); + expect( + itemBox.padding, + EdgeInsets.symmetric(horizontal: expected.indicatorWidth), + ); + expect(_radius(itemBox), expected.itemRadius); + expect( + item.indicator.spec.constraints!.minWidth, + expected.indicatorWidth, + ); + expect( + item.indicator.spec.constraints!.maxWidth, + expected.indicatorWidth, + ); + expect(item.icon.spec.size, expected.indicatorSize); + expect(item.text.spec.style!.fontSize, expected.itemFontSize); + + }); + } + + testWidgets('all scalable metrics follow Fortal scaling', (tester) async { + final spec = await _resolve( + tester, + fortalSelectStyler(size: .size1), + scaling: .percent110, + ); + final triggerBox = spec.trigger.spec.container.spec.box!.spec; + final item = spec.item.spec; + + expect(triggerBox.constraints!.minHeight, closeTo(26.4, 1e-9)); + expect(triggerBox.padding, const EdgeInsets.symmetric(horizontal: 8.8)); + expect(item.indicator.spec.constraints!.minWidth, closeTo(22, 1e-9)); + expect(item.icon.spec.size, closeTo(8.8, 1e-9)); + expect(item.text.spec.style!.fontSize, closeTo(13.2, 1e-9)); + expect(spec.trigger.spec.chevron.spec.size, 9); + }); + + testWidgets('full radius promotes only the trigger to a pill', ( + tester, + ) async { + final spec = await _resolve( + tester, + fortalSelectStyler(size: .size2), + radius: .full, + ); + + expect(_radius(spec.trigger.spec.container.spec.box!.spec), 9999); + expect(_radius(spec.content.spec.container.spec), 12); + expect(_radius(spec.item.spec.container.spec.box!.spec), 6); + }); + }); + + group('Fortal Select state recipes', () { + testWidgets('surface trigger uses exact idle, open, and disabled roles', ( + tester, + ) async { + final idle = await _resolve(tester, fortalSelectStyler()); + final open = await _resolve( + tester, + fortalSelectStyler(), + states: {WidgetState.selected}, + ); + final disabledHover = await _resolve( + tester, + fortalSelectStyler(), + states: {WidgetState.disabled, WidgetState.hovered}, + ); + final tokens = await _tokens(tester); + + expect(_flexColor(idle.trigger.spec.container), tokens.colorSurface); + expect( + idle.trigger.spec.containerEffects!.behindContent!.shadows.single.kind, + RemixBoxShadowKind.inset, + ); + expect( + idle.trigger.spec.containerEffects!.behindContent!.shadows.single.color, + tokens.grayA7, + ); + expect( + open.trigger.spec.containerEffects!.behindContent!.shadows.single.color, + tokens.grayA8, + ); + expect(_flexColor(disabledHover.trigger.spec.container), tokens.grayA2); + expect( + disabledHover + .trigger + .spec + .containerEffects! + .behindContent! + .shadows + .single + .color, + tokens.grayA6, + ); + expect( + disabledHover.trigger.spec.label.spec.style!.color, + tokens.grayA11, + ); + expect(disabledHover.trigger.spec.chevron.spec.color, tokens.grayA9); + }); + + testWidgets('focus ring straddles the trigger edge with solid focus8', ( + tester, + ) async { + final focused = await _resolve( + tester, + fortalSelectStyler(), + states: {WidgetState.focused}, + ); + final tokens = await _tokens(tester); + final ring = focused.trigger.spec.containerEffects!.overContent!; + + expect(ring.shadows, hasLength(2)); + expect(ring.shadows.first.kind, RemixBoxShadowKind.outer); + expect(ring.shadows.last.kind, RemixBoxShadowKind.inset); + expect( + ring.shadows.map((shadow) => shadow.color), + everyElement(tokens.focus8), + ); + expect( + ring.shadows.map((shadow) => shadow.spreadRadius), + everyElement(1), + ); + }); + + testWidgets( + 'soft and ghost own the pinned alpha fills and placeholder opacity', + (tester) async { + final soft = await _resolve(tester, fortalSelectStyler(variant: .soft)); + final softOpen = await _resolve( + tester, + fortalSelectStyler(variant: .soft), + states: {WidgetState.selected}, + ); + final ghost = await _resolve( + tester, + fortalSelectStyler(variant: .ghost), + ); + final tokens = await _tokens(tester); + + expect(_flexColor(soft.trigger.spec.container), tokens.accentA3); + expect(_flexColor(softOpen.trigger.spec.container), tokens.accentA4); + expect(soft.trigger.spec.label.spec.style!.color, tokens.accent12); + expect(soft.trigger.spec.placeholderOpacity, 0.6); + expect(_flexColor(ghost.trigger.spec.container), Colors.transparent); + expect(ghost.trigger.spec.placeholderOpacity, 0.6); + final ghostBox = ghost.trigger.spec.container.spec.box!.spec; + expect( + ghostBox.padding, + const EdgeInsets.all( + 0, + ).copyWith(left: 8, right: 8, top: 4, bottom: 4), + ); + expect( + ghostBox.margin, + const EdgeInsets.symmetric(horizontal: -8, vertical: -4), + ); + expect(ghostBox.constraints, isNull); + }, + ); + }); + + testWidgets('uses the pinned Radix chevron and thick-check vector paths', ( + tester, + ) async { + await tester.pumpWidget( + FortalScope( + child: MaterialApp( + home: Scaffold( + body: FortalSelect( + trigger: const RemixSelectTrigger(placeholder: 'Choose'), + items: const [RemixSelectItem(value: 'a', label: 'Apple')], + selectedValue: 'a', + onChanged: (_) {}, + ), + ), + ), + ), + ); + + expect(find.byKey(const ValueKey('fortal-select-chevron')), findsOneWidget); + expect(find.byIcon(Icons.keyboard_arrow_down), findsNothing); + + await tester.tap(find.byType(RemixSelect)); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('fortal-select-indicator')), + findsOneWidget, + ); + expect(find.byIcon(Icons.check), findsNothing); + }); +} + +Future _resolve( + WidgetTester tester, + RemixSelectStyler style, { + Set states = const {}, + FortalScaling scaling = .percent100, + FortalRadius radius = .medium, + Brightness brightness = .light, +}) async { + late RemixSelectSpec resolved; + await tester.pumpWidget( + FortalScope( + brightness: brightness, + scaling: scaling, + radius: radius, + child: WidgetsApp( + color: Colors.black, + builder: (context, child) => WidgetStateProvider( + states: states, + child: Builder( + builder: (context) { + resolved = style.build(context).spec; + return const SizedBox.shrink(); + }, + ), + ), + ), + ), + ); + return resolved; +} + +Future< + ({ + Color colorSurface, + Color grayA2, + Color grayA5, + Color grayA6, + Color grayA7, + Color grayA8, + Color grayA9, + Color grayA11, + Color grayA12, + Color gray12, + Color accent1, + Color accent9, + Color accent12, + Color accentA3, + Color accentA4, + Color accentContrast, + Color focus8, + Color whiteA4, + Color whiteA11, + Color blackA9, + }) +> +_tokens(WidgetTester tester, {Brightness brightness = .light}) async { + late ({ + Color colorSurface, + Color grayA2, + Color grayA5, + Color grayA6, + Color grayA7, + Color grayA8, + Color grayA9, + Color grayA11, + Color grayA12, + Color gray12, + Color accent1, + Color accent9, + Color accent12, + Color accentA3, + Color accentA4, + Color accentContrast, + Color focus8, + Color whiteA4, + Color whiteA11, + Color blackA9, + }) + result; + await tester.pumpWidget( + FortalScope( + brightness: brightness, + child: WidgetsApp( + color: Colors.black, + builder: (context, child) { + Color token(ColorToken value) => MixScope.tokenOf(value, context); + result = ( + colorSurface: token(FortalTokens.colorSurface), + grayA2: token(FortalTokens.grayA2), + grayA5: token(FortalTokens.grayA5), + grayA6: token(FortalTokens.grayA6), + grayA7: token(FortalTokens.grayA7), + grayA8: token(FortalTokens.grayA8), + grayA9: token(FortalTokens.grayA9), + grayA11: token(FortalTokens.grayA11), + grayA12: token(FortalTokens.grayA12), + gray12: token(FortalTokens.gray12), + accent1: token(FortalTokens.accent1), + accent9: token(FortalTokens.accent9), + accent12: token(FortalTokens.accent12), + accentA3: token(FortalTokens.accentA3), + accentA4: token(FortalTokens.accentA4), + accentContrast: token(FortalTokens.accentContrast), + focus8: token(FortalTokens.focus8), + whiteA4: token(FortalTokens.whiteA4), + whiteA11: token(FortalTokens.whiteA11), + blackA9: token(FortalTokens.blackA9), + ); + return const SizedBox.shrink(); + }, + ), + ), + ); + return result; +} + +double? _radius(BoxSpec spec) { + return (spec.decoration as BoxDecoration?)?.borderRadius + ?.resolve(TextDirection.ltr) + .topLeft + .x; +} + +Color? _flexColor(StyleSpec style) => + (style.spec.box?.spec.decoration as BoxDecoration?)?.color; diff --git a/packages/remix/test/components/select/select_spec_test.dart b/packages/remix/test/components/select/select_spec_test.dart index 32cc7f0e..3a69837e 100644 --- a/packages/remix/test/components/select/select_spec_test.dart +++ b/packages/remix/test/components/select/select_spec_test.dart @@ -136,7 +136,7 @@ void main() { test('props list contains all properties', () { const spec = RemixSelectSpec(); - expect(spec.props, hasLength(3)); + expect(spec.props, hasLength(4)); expect(spec.props, contains(spec.trigger)); expect(spec.props, contains(spec.menuContainer)); expect(spec.props, contains(spec.item)); @@ -289,7 +289,7 @@ void main() { test('props list contains all properties', () { const spec = RemixSelectTriggerSpec(); - expect(spec.props, hasLength(3)); + expect(spec.props, hasLength(8)); expect(spec.props, contains(spec.container)); expect(spec.props, contains(spec.label)); expect(spec.props, contains(spec.icon)); @@ -431,7 +431,7 @@ void main() { test('props list contains all properties', () { const spec = RemixSelectMenuItemSpec(); - expect(spec.props, hasLength(3)); + expect(spec.props, hasLength(4)); expect(spec.props, contains(spec.container)); expect(spec.props, contains(spec.text)); expect(spec.props, contains(spec.icon)); diff --git a/packages/remix/test/components/select/select_widget_test.dart b/packages/remix/test/components/select/select_widget_test.dart index c9816119..37403b62 100644 --- a/packages/remix/test/components/select/select_widget_test.dart +++ b/packages/remix/test/components/select/select_widget_test.dart @@ -56,7 +56,7 @@ void main() { expect(find.byIcon(Icons.star), findsOneWidget); }); - testWidgets('shows dropdown arrow icon', (tester) async { + testWidgets('shows dropdown chevron', (tester) async { await tester.pumpRemixApp( RemixSelect( trigger: const RemixSelectTrigger(placeholder: 'Select'), @@ -65,7 +65,10 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.byIcon(Icons.keyboard_arrow_down), findsOneWidget); + expect( + find.byKey(const ValueKey('fortal-select-chevron')), + findsOneWidget, + ); }); }); @@ -89,7 +92,7 @@ void main() { expect(find.text('Option B'), findsOneWidget); }); - testWidgets('shows up arrow when opened', (tester) async { + testWidgets('rotates the chevron when opened', (tester) async { await tester.pumpRemixApp( RemixSelect( trigger: const RemixSelectTrigger(placeholder: 'Select'), @@ -98,10 +101,24 @@ void main() { ); await tester.pumpAndSettle(); + final chevron = find.byKey( + const ValueKey('fortal-select-chevron'), + ); + final transformFinder = find.ancestor( + of: chevron, + matching: find.byType(Transform), + ); + final closedTransform = tester.widget( + transformFinder.first, + ).transform; + await tester.tap(find.byType(RemixSelect)); await tester.pumpAndSettle(); - expect(find.byIcon(Icons.keyboard_arrow_up), findsOneWidget); + expect( + tester.widget(transformFinder.first).transform, + isNot(equals(closedTransform)), + ); }); testWidgets('calls onChanged when item is selected', (tester) async { @@ -525,7 +542,10 @@ void main() { await tester.tap(find.byType(RemixSelect)); await tester.pumpAndSettle(); - expect(find.byIcon(Icons.check), findsOneWidget); + expect( + find.byKey(const ValueKey('fortal-select-indicator')), + findsOneWidget, + ); }); testWidgets('applies semanticLabel to item', (tester) async { @@ -586,8 +606,8 @@ void main() { trigger: const RemixSelectTrigger(placeholder: 'Select'), items: const [RemixSelectItem(value: 'a', label: 'Option A')], positioning: const OverlayPositionConfig( - targetAnchor: Alignment.bottomLeft, - followerAnchor: Alignment.topLeft, + side: OverlaySide.bottom, + alignment: OverlayAlignment.start, ), ), ); diff --git a/packages/remix/test/components/slider/slider_spec_test.dart b/packages/remix/test/components/slider/slider_spec_test.dart index 16cc12ff..fcf76574 100644 --- a/packages/remix/test/components/slider/slider_spec_test.dart +++ b/packages/remix/test/components/slider/slider_spec_test.dart @@ -194,7 +194,7 @@ void main() { test('props list contains all properties', () { final spec = RemixSliderSpec(); - expect(spec.props, hasLength(5)); + expect(spec.props, hasLength(12)); expect(spec.props, contains(spec.thumb)); expect(spec.props, contains(spec.trackColor)); expect(spec.props, contains(spec.trackWidth)); diff --git a/packages/remix/test/components/spinner/spinner_spec_test.dart b/packages/remix/test/components/spinner/spinner_spec_test.dart index 6544b357..9b1f84b4 100644 --- a/packages/remix/test/components/spinner/spinner_spec_test.dart +++ b/packages/remix/test/components/spinner/spinner_spec_test.dart @@ -195,7 +195,7 @@ void main() { test('props list contains all properties', () { const spec = RemixSpinnerSpec(); - expect(spec.props, hasLength(6)); + expect(spec.props, hasLength(9)); expect(spec.props, contains(spec.size)); expect(spec.props, contains(spec.strokeWidth)); expect(spec.props, contains(spec.indicatorColor)); diff --git a/packages/remix/test/components/spinner/spinner_style_test.dart b/packages/remix/test/components/spinner/spinner_style_test.dart index c7eaddf0..c7779230 100644 --- a/packages/remix/test/components/spinner/spinner_style_test.dart +++ b/packages/remix/test/components/spinner/spinner_style_test.dart @@ -240,7 +240,7 @@ void main() { test('props list contains all properties', () { final style = RemixSpinnerStyler(); - expect(style.props, hasLength(9)); + expect(style.props, hasLength(12)); expect(style.props, contains(style.$size)); expect(style.props, contains(style.$strokeWidth)); expect(style.props, contains(style.$indicatorColor)); diff --git a/packages/remix/test/components/switch/switch_spec_test.dart b/packages/remix/test/components/switch/switch_spec_test.dart index 56f74a07..285c9929 100644 --- a/packages/remix/test/components/switch/switch_spec_test.dart +++ b/packages/remix/test/components/switch/switch_spec_test.dart @@ -156,7 +156,7 @@ void main() { test('props list contains all properties', () { const spec = RemixSwitchSpec(); - expect(spec.props, hasLength(2)); + expect(spec.props, hasLength(4)); expect(spec.props, contains(spec.container)); expect(spec.props, contains(spec.thumb)); }); diff --git a/packages/remix/test/components/switch/switch_style_test.dart b/packages/remix/test/components/switch/switch_style_test.dart index 0a3fb36a..9592afb5 100644 --- a/packages/remix/test/components/switch/switch_style_test.dart +++ b/packages/remix/test/components/switch/switch_style_test.dart @@ -350,7 +350,7 @@ void main() { test('props list contains all properties', () { final style = RemixSwitchStyler(); - expect(style.props, hasLength(5)); + expect(style.props, hasLength(7)); expect(style.props, contains(style.$container)); expect(style.props, contains(style.$thumb)); expect(style.props, contains(style.$variants)); diff --git a/packages/remix/test/components/textfield/textfield_spec_test.dart b/packages/remix/test/components/textfield/textfield_spec_test.dart index 293818b3..62fe33b4 100644 --- a/packages/remix/test/components/textfield/textfield_spec_test.dart +++ b/packages/remix/test/components/textfield/textfield_spec_test.dart @@ -233,7 +233,7 @@ void main() { test('props includes all relevant properties', () { const spec = RemixTextFieldSpec(); - expect(spec.props.length, equals(16)); + expect(spec.props.length, equals(17)); expect(spec.props, contains(spec.text)); expect(spec.props, contains(spec.hintText)); expect(spec.props, contains(spec.textAlign)); diff --git a/packages/remix/test/components/textfield/textfield_widget_test.dart b/packages/remix/test/components/textfield/textfield_widget_test.dart index b0833cce..2b212189 100644 --- a/packages/remix/test/components/textfield/textfield_widget_test.dart +++ b/packages/remix/test/components/textfield/textfield_widget_test.dart @@ -23,7 +23,7 @@ void main() { spec.container.spec.box!.spec.decoration! as BoxDecoration; expect(spec.text.spec.style?.color, colors.gray.scale.step(12)); - expect(decoration.color, isNull); + expect(decoration.color, colors.colorSurface); await tester.pumpRemixApp(const FortalTextField.soft()); context = tester.element(find.byType(FortalTextField)); @@ -34,7 +34,7 @@ void main() { decoration = spec.container.spec.box!.spec.decoration! as BoxDecoration; expect(spec.text.spec.style?.color, colors.accent.scale.step(12)); - expect(decoration.color, colors.accent.scale.step(3)); + expect(decoration.color, colors.accent.scale.alphaStep(3)); }); group('Basic Rendering', () { @@ -576,32 +576,31 @@ void main() { expect(find.byType(RemixTextField), findsOneWidget); }); - testWidgets( - 'layout override for one property keeps layout defaults', - (tester) async { - await tester.pumpRemixApp( - RemixTextField( - label: 'Label', - helperText: 'Helper', - style: RemixTextFieldStyler().layout(FlexBoxStyler().spacing(12)), - ), - ); - await tester.pumpAndSettle(); - - final flex = tester - .widget(find.byType(ColumnBox)) - .styleSpec - ?.spec - .flex - ?.spec; - - // Customizing spacing keeps the min-size / start-alignment defaults - // instead of falling back to ColumnBox's max / center. - expect(flex?.spacing, 12); - expect(flex?.mainAxisSize, MainAxisSize.min); - expect(flex?.crossAxisAlignment, CrossAxisAlignment.start); - }, - ); + testWidgets('layout override for one property keeps layout defaults', ( + tester, + ) async { + await tester.pumpRemixApp( + RemixTextField( + label: 'Label', + helperText: 'Helper', + style: RemixTextFieldStyler().layout(FlexBoxStyler().spacing(12)), + ), + ); + await tester.pumpAndSettle(); + + final flex = tester + .widget(find.byType(ColumnBox)) + .styleSpec + ?.spec + .flex + ?.spec; + + // Customizing spacing keeps the min-size / start-alignment defaults + // instead of falling back to ColumnBox's max / center. + expect(flex?.spacing, 12); + expect(flex?.mainAxisSize, MainAxisSize.min); + expect(flex?.crossAxisAlignment, CrossAxisAlignment.start); + }); testWidgets('applies width and height constraints', (tester) async { await tester.pumpRemixApp( diff --git a/packages/remix/test/components/tooltip/tooltip_spec_test.dart b/packages/remix/test/components/tooltip/tooltip_spec_test.dart index e4884013..09e0e20b 100644 --- a/packages/remix/test/components/tooltip/tooltip_spec_test.dart +++ b/packages/remix/test/components/tooltip/tooltip_spec_test.dart @@ -151,7 +151,7 @@ void main() { test('props includes all relevant properties', () { const spec = RemixTooltipSpec(); - expect(spec.props.length, equals(5)); + expect(spec.props.length, equals(6)); expect(spec.props, contains(spec.container)); expect(spec.props, contains(spec.label)); expect(spec.props, contains(spec.waitDuration)); diff --git a/packages/remix/test/components/tooltip/tooltip_widget_test.dart b/packages/remix/test/components/tooltip/tooltip_widget_test.dart index 3d59d022..49990b78 100644 --- a/packages/remix/test/components/tooltip/tooltip_widget_test.dart +++ b/packages/remix/test/components/tooltip/tooltip_widget_test.dart @@ -459,9 +459,9 @@ void main() { testWidgets('accepts custom positioning', (tester) async { const positioning = OverlayPositionConfig( - targetAnchor: Alignment.topCenter, - followerAnchor: Alignment.bottomCenter, - offset: Offset(0, -8), + side: OverlaySide.top, + alignment: OverlayAlignment.center, + sideOffset: 8, ); await tester.pumpRemixApp( diff --git a/packages/remix/test/fortal/fortal_control_matrix_test.dart b/packages/remix/test/fortal/fortal_control_matrix_test.dart new file mode 100644 index 00000000..cf0fde90 --- /dev/null +++ b/packages/remix/test/fortal/fortal_control_matrix_test.dart @@ -0,0 +1,314 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; + +import '../helpers/test_helpers.dart'; + +void main() { + group('Fortal control matrices', () { + test('enum domains and defaults match Radix Themes 3.3.0', () { + expect(FortalIconButtonSize.values, [ + FortalIconButtonSize.size1, + FortalIconButtonSize.size2, + FortalIconButtonSize.size3, + FortalIconButtonSize.size4, + ]); + expect(FortalIconButtonVariant.values, [ + FortalIconButtonVariant.classic, + FortalIconButtonVariant.solid, + FortalIconButtonVariant.soft, + FortalIconButtonVariant.surface, + FortalIconButtonVariant.outline, + FortalIconButtonVariant.ghost, + ]); + expect(FortalCheckboxSize.values, [ + FortalCheckboxSize.size1, + FortalCheckboxSize.size2, + FortalCheckboxSize.size3, + ]); + expect(FortalCheckboxVariant.values, [ + FortalCheckboxVariant.classic, + FortalCheckboxVariant.surface, + FortalCheckboxVariant.soft, + ]); + expect(FortalRadioSize.values, [ + FortalRadioSize.size1, + FortalRadioSize.size2, + FortalRadioSize.size3, + ]); + expect(FortalRadioVariant.values, [ + FortalRadioVariant.classic, + FortalRadioVariant.surface, + FortalRadioVariant.soft, + ]); + expect(FortalSwitchSize.values, [ + FortalSwitchSize.size1, + FortalSwitchSize.size2, + FortalSwitchSize.size3, + ]); + expect(FortalSwitchVariant.values, [ + FortalSwitchVariant.classic, + FortalSwitchVariant.surface, + FortalSwitchVariant.soft, + ]); + expect(FortalProgressSize.values, [ + FortalProgressSize.size1, + FortalProgressSize.size2, + FortalProgressSize.size3, + ]); + expect(FortalProgressVariant.values, [ + FortalProgressVariant.classic, + FortalProgressVariant.surface, + FortalProgressVariant.soft, + ]); + expect(FortalSpinnerSize.values, [ + FortalSpinnerSize.size1, + FortalSpinnerSize.size2, + FortalSpinnerSize.size3, + ]); + expect(FortalTextFieldSize.values, [ + FortalTextFieldSize.size1, + FortalTextFieldSize.size2, + FortalTextFieldSize.size3, + ]); + expect(FortalTextFieldVariant.values, [ + FortalTextFieldVariant.classic, + FortalTextFieldVariant.surface, + FortalTextFieldVariant.soft, + ]); + + const iconButton = FortalIconButton( + semanticLabel: 'Add', + icon: Icons.add, + ); + const checkbox = FortalCheckbox(selected: false); + const radio = FortalRadio(value: 1); + const switchControl = FortalSwitch(selected: false); + const progress = FortalProgress(value: 0.5); + const spinner = FortalSpinner(); + const textField = FortalTextField(); + + expect(iconButton.size, FortalIconButtonSize.size2); + expect(iconButton.variant, FortalIconButtonVariant.solid); + expect(checkbox.size, FortalCheckboxSize.size2); + expect(checkbox.variant, FortalCheckboxVariant.surface); + expect(radio.size, FortalRadioSize.size2); + expect(radio.variant, FortalRadioVariant.surface); + expect(switchControl.size, FortalSwitchSize.size2); + expect(switchControl.variant, FortalSwitchVariant.surface); + expect(progress.size, FortalProgressSize.size2); + expect(progress.variant, FortalProgressVariant.surface); + expect(spinner.size, FortalSpinnerSize.size2); + expect(spinner, isA()); + expect(textField.size, FortalTextFieldSize.size2); + expect(textField.variant, FortalTextFieldVariant.surface); + }); + + testWidgets('icon button sizes resolve exact square and icon metrics', ( + tester, + ) async { + const expected = { + FortalIconButtonSize.size1: (24, 12), + FortalIconButtonSize.size2: (32, 16), + FortalIconButtonSize.size3: (40, 20), + FortalIconButtonSize.size4: (48, 24), + }; + + for (final entry in expected.entries) { + final resolved = await _resolveFortal( + tester, + (context) => fortalIconButtonStyler(size: entry.key).build(context), + ); + final constraints = resolved.spec.container.spec.constraints!; + expect( + constraints, + BoxConstraints.tightFor( + width: entry.value.$1, + height: entry.value.$1, + ), + ); + expect(resolved.spec.icon.spec.size, entry.value.$2); + } + }); + + testWidgets('checkbox and radio sizes resolve exact control metrics', ( + tester, + ) async { + const expectedCheckbox = { + FortalCheckboxSize.size1: (14, 9), + FortalCheckboxSize.size2: (16, 10), + FortalCheckboxSize.size3: (20, 12), + }; + const expectedRadio = { + FortalRadioSize.size1: (14, 5.6), + FortalRadioSize.size2: (16, 6.4), + FortalRadioSize.size3: (20, 8), + }; + + for (final entry in expectedCheckbox.entries) { + final resolved = await _resolveFortal( + tester, + (context) => fortalCheckboxStyler(size: entry.key).build(context), + ); + expect( + resolved.spec.container.spec.constraints, + BoxConstraints.tightFor( + width: entry.value.$1, + height: entry.value.$1, + ), + ); + expect(resolved.spec.indicator.spec.size, entry.value.$2); + } + + for (final entry in expectedRadio.entries) { + final resolved = await _resolveFortal( + tester, + (context) => fortalRadioStyler(size: entry.key).build(context), + ); + expect( + resolved.spec.container.spec.constraints, + BoxConstraints.tightFor( + width: entry.value.$1, + height: entry.value.$1, + ), + ); + expect( + resolved.spec.indicator.spec.constraints, + BoxConstraints.tightFor( + width: entry.value.$2, + height: entry.value.$2, + ), + ); + } + }); + + testWidgets('switch sizes resolve exact track and thumb metrics', ( + tester, + ) async { + const expected = { + FortalSwitchSize.size1: (28, 16, 14), + FortalSwitchSize.size2: (35, 20, 18), + FortalSwitchSize.size3: (42, 24, 22), + }; + + for (final entry in expected.entries) { + final resolved = await _resolveFortal( + tester, + (context) => fortalSwitchStyler(size: entry.key).build(context), + ); + expect( + resolved.spec.container.spec.constraints, + BoxConstraints.tightFor( + width: entry.value.$1, + height: entry.value.$2, + ), + ); + expect( + resolved.spec.thumb.spec.constraints, + BoxConstraints.tightFor( + width: entry.value.$3, + height: entry.value.$3, + ), + ); + } + }); + + testWidgets('progress and spinner sizes resolve exact metrics', ( + tester, + ) async { + const progressHeights = { + FortalProgressSize.size1: 4, + FortalProgressSize.size2: 6, + FortalProgressSize.size3: 8, + }; + const spinnerSizes = { + FortalSpinnerSize.size1: 12, + FortalSpinnerSize.size2: 16, + FortalSpinnerSize.size3: 20, + }; + + for (final entry in progressHeights.entries) { + final resolved = await _resolveFortal( + tester, + (context) => fortalProgressStyler(size: entry.key).build(context), + ); + expect( + resolved.spec.container.spec.constraints?.maxHeight, + entry.value, + ); + expect(resolved.spec.track.spec.constraints?.maxHeight, entry.value); + expect( + resolved.spec.indicator.spec.constraints?.maxHeight, + entry.value, + ); + } + + for (final entry in spinnerSizes.entries) { + final resolved = await _resolveFortal( + tester, + (context) => fortalSpinnerStyler(size: entry.key).build(context), + ); + expect(resolved.spec.size, entry.value); + expect(resolved.spec.opacity, 0.65); + expect(resolved.spec.duration, const Duration(milliseconds: 800)); + } + }); + + testWidgets('text field size and variant matrix resolves exact metrics', ( + tester, + ) async { + const expectedHeights = { + FortalTextFieldSize.size1: 24, + FortalTextFieldSize.size2: 32, + FortalTextFieldSize.size3: 40, + }; + final recipes = + <({Color? color, RemixBoxEffectsSpec? containerEffects})>{}; + + for (final variant in FortalTextFieldVariant.values) { + for (final entry in expectedHeights.entries) { + final resolved = await _resolveFortal( + tester, + (context) => fortalTextFieldStyler( + variant: variant, + size: entry.key, + ).build(context), + ); + expect( + resolved.spec.container.spec.box?.spec.constraints?.maxHeight, + entry.value, + reason: '$variant ${entry.key}', + ); + expect(resolved.spec.containerEffects?.behindContent, isNotNull); + if (entry.key == FortalTextFieldSize.size2) { + final decoration = + resolved.spec.container.spec.box!.spec.decoration + as BoxDecoration; + recipes.add(( + color: decoration.color, + containerEffects: resolved.spec.containerEffects, + )); + } + } + } + + expect(recipes, hasLength(FortalTextFieldVariant.values.length)); + }); + }); +} + +Future _resolveFortal( + WidgetTester tester, + T Function(BuildContext context) resolve, +) async { + late T result; + await tester.pumpRemixApp( + Builder( + builder: (context) { + result = resolve(context); + return const SizedBox.shrink(); + }, + ), + ); + return result; +} diff --git a/packages/remix/test/fortal/fortal_high_contrast_test.dart b/packages/remix/test/fortal/fortal_high_contrast_test.dart new file mode 100644 index 00000000..6ec9937f --- /dev/null +++ b/packages/remix/test/fortal/fortal_high_contrast_test.dart @@ -0,0 +1,560 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; + +void main() { + group('Fortal high-contrast recipes', () { + testWidgets('solid controls retain dark high-contrast interaction roles', ( + tester, + ) async { + for (final state in [WidgetState.hovered, WidgetState.pressed]) { + final button = await _resolveFortalStyle( + tester, + (context) => fortalButtonStyler(highContrast: true).build(context), + brightness: .dark, + states: {state}, + ); + final iconButton = await _resolveFortalStyle( + tester, + (context) => + fortalIconButtonStyler(highContrast: true).build(context), + brightness: .dark, + states: {state}, + ); + + expect( + _flexBoxColor(button.spec.container), + indigo.dark.scale.step(12), + reason: 'Button ${state.name} background must remain high contrast.', + ); + expect(_textColor(button.spec.label), slate.dark.scale.step(1)); + expect( + _boxColor(iconButton.spec.container), + indigo.dark.scale.step(12), + reason: + 'Icon button ${state.name} background must remain high contrast.', + ); + expect(iconButton.spec.icon.spec.color, slate.dark.scale.step(1)); + } + }); + + testWidgets('soft and surface controls retain distinct base fills', ( + tester, + ) async { + final softButton = await _resolveFortalStyle( + tester, + (context) => fortalButtonStyler(variant: .soft).build(context), + ); + final surfaceButton = await _resolveFortalStyle( + tester, + (context) => fortalButtonStyler(variant: .surface).build(context), + ); + final softIconButton = await _resolveFortalStyle( + tester, + (context) => fortalIconButtonStyler(variant: .soft).build(context), + ); + final surfaceIconButton = await _resolveFortalStyle( + tester, + (context) => fortalIconButtonStyler(variant: .surface).build(context), + ); + + expect( + _flexBoxColor(softButton.spec.container), + indigo.light.scale.alphaStep(3), + ); + expect(_flexBoxColor(surfaceButton.spec.container), indigo.light.surface); + expect( + _boxColor(softIconButton.spec.container), + indigo.light.scale.alphaStep(3), + ); + expect(_boxColor(surfaceIconButton.spec.container), indigo.light.surface); + }); + + testWidgets('solid interactions meet contrast for every accent and mode', ( + tester, + ) async { + for (final brightness in Brightness.values) { + for (final accent in FortalAccentColor.values) { + for (final state in [WidgetState.hovered, WidgetState.pressed]) { + final button = await _resolveFortalStyle( + tester, + (context) => + fortalButtonStyler(highContrast: true).build(context), + accent: accent, + brightness: brightness, + states: {state}, + ); + final iconButton = await _resolveFortalStyle( + tester, + (context) => + fortalIconButtonStyler(highContrast: true).build(context), + accent: accent, + brightness: brightness, + states: {state}, + ); + + final buttonBackground = _flexBoxColor(button.spec.container)!; + final buttonForeground = _textColor(button.spec.label)!; + final iconBackground = _boxColor(iconButton.spec.container)!; + final iconForeground = iconButton.spec.icon.spec.color!; + + expect( + _contrastRatio(buttonBackground, buttonForeground), + greaterThanOrEqualTo(4.5), + reason: '$accent $brightness button ${state.name}', + ); + expect( + _contrastRatio(iconBackground, iconForeground), + greaterThanOrEqualTo(3.0), + reason: '$accent $brightness icon button ${state.name}', + ); + } + } + } + }); + + testWidgets('icon button variants resolve stronger accent roles', ( + tester, + ) async { + for (final variant in FortalIconButtonVariant.values) { + final normal = await _resolveFortalStyle( + tester, + (context) => fortalIconButtonStyler(variant: variant).build(context), + ); + final highContrast = await _resolveFortalStyle( + tester, + (context) => fortalIconButtonStyler( + variant: variant, + highContrast: true, + ).build(context), + ); + + if (variant == .solid || variant == .classic) { + expect(_boxColor(normal.spec.container), indigo.light.scale.step(9)); + expect( + _boxColor(highContrast.spec.container), + indigo.light.scale.step(12), + ); + expect(normal.spec.icon.spec.color, indigo.light.contrast); + expect(highContrast.spec.icon.spec.color, slate.light.scale.step(1)); + } else { + expect(normal.spec.icon.spec.color, indigo.light.scale.alphaStep(11)); + expect( + highContrast.spec.icon.spec.color, + indigo.light.scale.step(12), + ); + } + } + }); + + testWidgets('badge variants resolve stronger accent roles', (tester) async { + for (final variant in FortalBadgeVariant.values) { + final normal = await _resolveFortalStyle( + tester, + (context) => fortalBadgeStyler(variant: variant).build(context), + ); + final highContrast = await _resolveFortalStyle( + tester, + (context) => fortalBadgeStyler( + variant: variant, + highContrast: true, + ).build(context), + ); + + if (variant == .solid) { + expect(_boxColor(normal.spec.container), indigo.light.scale.step(9)); + expect( + _boxColor(highContrast.spec.container), + indigo.light.scale.step(12), + ); + expect(_textColor(normal.spec.label), indigo.light.contrast); + expect( + _textColor(highContrast.spec.label), + indigo.light.scale.step(1), + ); + } else { + expect( + _textColor(normal.spec.label), + indigo.light.scale.alphaStep(11), + ); + expect( + _textColor(highContrast.spec.label), + indigo.light.scale.step(12), + ); + } + } + }); + + testWidgets('callout variants resolve accent12 foregrounds', ( + tester, + ) async { + for (final variant in FortalCalloutVariant.values) { + final normal = await _resolveFortalStyle( + tester, + (context) => fortalCalloutStyler(variant: variant).build(context), + ); + final highContrast = await _resolveFortalStyle( + tester, + (context) => fortalCalloutStyler( + variant: variant, + highContrast: true, + ).build(context), + ); + + expect(_textColor(normal.spec.text), indigo.light.scale.alphaStep(11)); + expect(_textColor(highContrast.spec.text), indigo.light.scale.step(12)); + expect(normal.spec.icon.spec.color, indigo.light.scale.alphaStep(11)); + expect(highContrast.spec.icon.spec.color, indigo.light.scale.step(12)); + } + }); + + testWidgets('selected checkbox variants resolve stronger roles', ( + tester, + ) async { + for (final variant in FortalCheckboxVariant.values) { + final normal = await _resolveFortalStyle( + tester, + (context) => fortalCheckboxStyler(variant: variant).build(context), + states: {WidgetState.selected}, + ); + final highContrast = await _resolveFortalStyle( + tester, + (context) => fortalCheckboxStyler( + variant: variant, + highContrast: true, + ).build(context), + states: {WidgetState.selected}, + ); + + if (variant == .classic || variant == .surface) { + expect(_boxColor(normal.spec.container), indigo.light.indicator); + expect( + _boxColor(highContrast.spec.container), + indigo.light.scale.step(12), + ); + expect(normal.spec.indicator.spec.color, indigo.light.contrast); + expect( + highContrast.spec.indicator.spec.color, + indigo.light.scale.step(1), + ); + } else { + expect( + normal.spec.indicator.spec.color, + indigo.light.scale.alphaStep(11), + ); + expect( + highContrast.spec.indicator.spec.color, + indigo.light.scale.step(12), + ); + } + } + }); + + testWidgets('selected radio variants resolve stronger roles', ( + tester, + ) async { + for (final variant in FortalRadioVariant.values) { + final normal = await _resolveFortalStyle( + tester, + (context) => fortalRadioStyler(variant: variant).build(context), + states: {WidgetState.selected}, + ); + final highContrast = await _resolveFortalStyle( + tester, + (context) => fortalRadioStyler( + variant: variant, + highContrast: true, + ).build(context), + states: {WidgetState.selected}, + ); + + if (variant == .classic || variant == .surface) { + expect(_boxColor(normal.spec.container), indigo.light.indicator); + expect( + _boxColor(highContrast.spec.container), + indigo.light.scale.step(12), + ); + expect(_boxColor(normal.spec.indicator), indigo.light.contrast); + expect( + _boxColor(highContrast.spec.indicator), + indigo.light.scale.step(1), + ); + } else { + expect(_boxColor(normal.spec.indicator), indigo.light.scale.step(11)); + expect( + _boxColor(highContrast.spec.indicator), + indigo.light.scale.step(12), + ); + } + } + }); + + testWidgets('selected switch variants resolve accent12 tracks', ( + tester, + ) async { + for (final variant in FortalSwitchVariant.values) { + final normal = await _resolveFortalStyle( + tester, + (context) => fortalSwitchStyler(variant: variant).build(context), + states: {WidgetState.selected}, + ); + final highContrast = await _resolveFortalStyle( + tester, + (context) => fortalSwitchStyler( + variant: variant, + highContrast: true, + ).build(context), + states: {WidgetState.selected}, + ); + + final expectedNormal = switch (variant) { + .classic || .surface => indigo.light.track, + .soft => indigo.light.scale.alphaStep(4), + }; + final expectedHighContrast = switch (variant) { + .classic || .surface => indigo.light.scale.step(12), + .soft => indigo.light.scale.alphaStep(6), + }; + expect(_boxColor(normal.spec.container), expectedNormal); + expect(_boxColor(highContrast.spec.container), expectedHighContrast); + } + }); + + testWidgets('selected switches retain the exact white Radix thumb', ( + tester, + ) async { + for (final variant in FortalSwitchVariant.values) { + final highContrast = await _resolveFortalStyle( + tester, + (context) => fortalSwitchStyler( + variant: variant, + highContrast: true, + ).build(context), + brightness: .dark, + states: {WidgetState.selected}, + ); + + expect( + _boxColor(highContrast.spec.thumb), + Colors.white, + reason: '$variant must contrast with its accent12 track.', + ); + } + }); + + testWidgets('selected toggle variants resolve accent12 foregrounds', ( + tester, + ) async { + for (final variant in FortalToggleVariant.values) { + final normal = await _resolveFortalStyle( + tester, + (context) => fortalToggleStyler(variant: variant).build(context), + states: {WidgetState.selected}, + ); + final highContrast = await _resolveFortalStyle( + tester, + (context) => fortalToggleStyler( + variant: variant, + highContrast: true, + ).build(context), + states: {WidgetState.selected}, + ); + + final expectedNormal = switch (variant) { + .ghost => indigo.light.scale.step(11), + .outline => indigo.light.scale.step(11), + }; + expect(_textColor(normal.spec.label), expectedNormal); + expect( + _textColor(highContrast.spec.label), + indigo.light.scale.step(12), + ); + } + }); + + testWidgets('select exposes high contrast through its direct styler', ( + tester, + ) async { + final hoveredNormal = await _resolveFortalStyle( + tester, + (context) => fortalSelectStyler().build(context), + states: {WidgetState.hovered}, + ); + final hoveredHighContrast = await _resolveFortalStyle( + tester, + (context) => fortalSelectStyler(highContrast: true).build(context), + states: {WidgetState.hovered}, + ); + expect( + _textColor(hoveredNormal.spec.item.spec.text), + indigo.light.contrast, + ); + expect( + _textColor(hoveredHighContrast.spec.item.spec.text), + indigo.light.scale.step(1), + ); + expect( + hoveredNormal.spec.item.spec.icon.spec.color, + indigo.light.contrast, + ); + expect( + hoveredHighContrast.spec.item.spec.icon.spec.color, + indigo.light.scale.step(1), + ); + }); + + testWidgets('slider variants resolve accent12 ranges', (tester) async { + for (final variant in FortalSliderVariant.values) { + final normal = await _resolveFortalStyle( + tester, + (context) => fortalSliderStyler(variant: variant).build(context), + ); + final highContrast = await _resolveFortalStyle( + tester, + (context) => fortalSliderStyler( + variant: variant, + highContrast: true, + ).build(context), + ); + + final expectedNormal = switch (variant) { + .classic || .surface => indigo.light.indicator, + .soft => indigo.light.scale.step(6), + }; + expect(_boxColor(normal.spec.range), expectedNormal); + final contrastGradient = + highContrast.spec.rangeEffects!.behindContent!.gradients.last + as LinearGradient; + expect(contrastGradient.colors, everyElement(blackAlpha[8])); + } + }); + + testWidgets('progress variants resolve accent12 indicators', ( + tester, + ) async { + for (final variant in FortalProgressVariant.values) { + final normal = await _resolveFortalStyle( + tester, + (context) => fortalProgressStyler(variant: variant).build(context), + ); + final highContrast = await _resolveFortalStyle( + tester, + (context) => fortalProgressStyler( + variant: variant, + highContrast: true, + ).build(context), + ); + + final expectedNormal = switch (variant) { + .classic || .surface => indigo.light.indicator, + .soft => indigo.light.scale.step(8), + }; + expect(_boxColor(normal.spec.indicator), expectedNormal); + expect( + _boxColor(highContrast.spec.indicator), + indigo.light.scale.step(12), + ); + } + }); + + testWidgets('progress variants respect the configured radius scale', ( + tester, + ) async { + for (final variant in FortalProgressVariant.values) { + final style = await _resolveFortalStyle( + tester, + (context) => fortalProgressStyler(variant: variant).build(context), + radius: .none, + ); + + expect(_boxBorderRadius(style.spec.indicator), BorderRadius.zero); + expect(_boxBorderRadius(style.spec.container), BorderRadius.zero); + } + }); + + testWidgets('selected tabs resolve an accent12 underline', (tester) async { + final normal = await _resolveFortalStyle( + tester, + (context) => fortalTabStyler().build(context), + states: {WidgetState.selected}, + ); + final highContrast = await _resolveFortalStyle( + tester, + (context) => fortalTabStyler(highContrast: true).build(context), + states: {WidgetState.selected}, + ); + + expect(_tabUnderlineColor(normal), const Color(0xFF3E63DD)); + expect(_tabUnderlineColor(highContrast), const Color(0xFF1F2D5C)); + }); + }); +} + +Future _resolveFortalStyle( + WidgetTester tester, + T Function(BuildContext context) resolve, { + FortalAccentColor accent = .indigo, + Brightness brightness = .light, + FortalRadius radius = .medium, + Set states = const {}, +}) async { + late T style; + + await tester.pumpWidget( + FortalScope( + accent: accent, + brightness: brightness == .dark ? .dark : .light, + radius: radius, + child: MaterialApp( + home: WidgetStateProvider( + states: states, + child: Builder( + builder: (context) { + style = resolve(context); + return const SizedBox.shrink(); + }, + ), + ), + ), + ), + ); + + return style; +} + +Color? _boxColor(StyleSpec style) => + (style.spec.decoration as BoxDecoration?)?.color; + +Color? _flexBoxColor(StyleSpec style) => + (style.spec.box?.spec.decoration as BoxDecoration?)?.color; + +BorderRadiusGeometry? _boxBorderRadius(StyleSpec style) => + switch (style.spec.decoration) { + BoxDecoration(:final borderRadius) => borderRadius, + ShapeDecoration(shape: RoundedRectangleBorder(:final borderRadius)) => + borderRadius, + _ => null, + }; + +double _contrastRatio(Color first, Color second) { + final firstLuminance = first.computeLuminance(); + final secondLuminance = second.computeLuminance(); + final lighter = firstLuminance > secondLuminance + ? firstLuminance + : secondLuminance; + final darker = firstLuminance > secondLuminance + ? secondLuminance + : firstLuminance; + + return (lighter + 0.05) / (darker + 0.05); +} + +Color? _textColor(StyleSpec style) => style.spec.style?.color; + +Color? _tabUnderlineColor(StyleSpec style) { + final modifiers = style.widgetModifiers?.whereType().toList(); + final decoration = modifiers?.single.spec.decoration as BoxDecoration?; + final border = decoration?.border; + + return border is Border ? border.bottom.color : null; +} diff --git a/packages/remix/test/fortal/fortal_theme_resolution_test.dart b/packages/remix/test/fortal/fortal_theme_resolution_test.dart new file mode 100644 index 00000000..cd501f94 --- /dev/null +++ b/packages/remix/test/fortal/fortal_theme_resolution_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; + +void main() { + test('theme configuration stores only canonical nullable overrides', () { + const config = FortalThemeConfig(); + + expect(config.accent, isNull); + expect(config.gray, isNull); + expect(config.brightness, isNull); + expect(config.panelBackground, isNull); + expect(config.radius, isNull); + expect(config.scaling, isNull); + expect(config.hasBackground, isNull); + }); + + testWidgets('root scope resolves the documented defaults', (tester) async { + late FortalThemeData data; + + await tester.pumpWidget( + FortalScope( + child: Builder( + builder: (context) { + data = FortalTheme.of(context); + return const SizedBox(); + }, + ), + ), + ); + + expect(data.accent, FortalAccentColor.indigo); + expect(data.gray, FortalGrayColor.slate); + expect(data.brightness, Brightness.light); + expect(data.panelBackground, FortalPanelBackground.translucent); + expect(data.radius, FortalRadius.medium); + expect(data.scaling, FortalScaling.percent100); + expect(data.hasBackground, isTrue); + }); + + testWidgets('nested scopes inherit unspecified values without repainting', ( + tester, + ) async { + late FortalThemeData outer; + late FortalThemeData inner; + + await tester.pumpWidget( + FortalScope( + accent: .red, + gray: .mauve, + brightness: .dark, + radius: .large, + child: Builder( + builder: (context) { + outer = FortalTheme.of(context); + return FortalScope( + scaling: .percent110, + child: Builder( + builder: (context) { + inner = FortalTheme.of(context); + return const SizedBox(); + }, + ), + ); + }, + ), + ), + ); + + expect(inner.accent, outer.accent); + expect(inner.gray, outer.gray); + expect(inner.brightness, outer.brightness); + expect(inner.radius, outer.radius); + expect(inner.scaling, FortalScaling.percent110); + expect(inner.hasBackground, isFalse); + }); + + testWidgets('captured themes rebuild their Mix token scope', (tester) async { + late BuildContext sourceContext; + + await tester.pumpWidget( + FortalScope( + accent: .red, + child: Builder( + builder: (context) { + sourceContext = context; + return const SizedBox(); + }, + ), + ), + ); + + final captured = InheritedTheme.capture( + from: sourceContext, + to: tester.element(find.byType(FortalScope)), + ); + late Color accent; + await tester.pumpWidget( + captured.wrap( + Builder( + builder: (context) { + accent = MixScope.tokenOf(FortalTokens.accent9, context); + return const SizedBox(); + }, + ), + ), + ); + + expect(accent, isNotNull); + }); +} diff --git a/packages/remix/test/fortal/fortal_theme_test.dart b/packages/remix/test/fortal/fortal_theme_test.dart new file mode 100644 index 00000000..c8463da0 --- /dev/null +++ b/packages/remix/test/fortal/fortal_theme_test.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; + +void main() { + group('Fortal scaled tokens', () { + for (final scaling in FortalScaling.values) { + for (final radius in FortalRadius.values) { + testWidgets('${scaling.name} and ${radius.name}', (tester) async { + late double space4; + late double toggleGap1; + late double toggleGap3; + late double borderWidth; + late Radius radius3; + late Radius radiusFull; + late Radius radiusThumb; + late Radius radiusCircle; + late Radius radius3OrFull; + late TextStyle text1; + late TextStyle text3; + late TextStyle accordionText2; + + await tester.pumpWidget( + FortalScope( + brightness: .light, + scaling: scaling, + radius: radius, + child: WidgetsApp( + color: const Color(0xFF000000), + builder: (context, child) { + space4 = MixScope.tokenOf(FortalTokens.space4, context); + toggleGap1 = MixScope.tokenOf( + FortalTokens.toggleGap1, + context, + ); + toggleGap3 = MixScope.tokenOf( + FortalTokens.toggleGap3, + context, + ); + borderWidth = MixScope.tokenOf( + FortalTokens.borderWidth1, + context, + ); + radius3 = MixScope.tokenOf(FortalTokens.radius3, context); + radiusFull = MixScope.tokenOf( + FortalTokens.radiusFull, + context, + ); + radiusThumb = MixScope.tokenOf( + FortalTokens.radiusThumb, + context, + ); + radiusCircle = MixScope.tokenOf( + FortalTokens.radiusCircle, + context, + ); + radius3OrFull = MixScope.tokenOf( + FortalTokens.radius3OrFull, + context, + ); + text1 = MixScope.tokenOf(FortalTokens.text1, context); + text3 = MixScope.tokenOf(FortalTokens.text3, context); + accordionText2 = MixScope.tokenOf( + FortalTokens.accordionText2, + context, + ); + return const SizedBox.shrink(); + }, + ), + ), + ); + + final radiusFactor = switch (radius) { + .none => 0.0, + .small => 0.75, + .medium => 1.0, + .large || .full => 1.5, + }; + final expectedRadius3 = 6 * scaling.factor * radiusFactor; + final expectedFull = radius == .full ? 9999.0 : 0.0; + final expectedThumb = switch (radius) { + .none || .small => 0.5, + .medium || .large || .full => 9999.0, + }; + + expect(space4, closeTo(16 * scaling.factor, 1e-9)); + expect(toggleGap1, closeTo(2 * scaling.factor, 1e-9)); + expect(toggleGap3, closeTo(6 * scaling.factor, 1e-9)); + expect(borderWidth, 1); + expect(radius3.x, closeTo(expectedRadius3, 1e-9)); + expect(radius3.y, closeTo(expectedRadius3, 1e-9)); + expect(radiusFull, Radius.circular(expectedFull)); + expect(radiusThumb, Radius.circular(expectedThumb)); + expect(radiusCircle, const Radius.circular(9999)); + final expectedRadius3OrFull = expectedRadius3 > expectedFull + ? expectedRadius3 + : expectedFull; + expect(radius3OrFull.x, closeTo(expectedRadius3OrFull, 1e-9)); + expect(radius3OrFull.y, closeTo(expectedRadius3OrFull, 1e-9)); + expect(text1.fontSize, closeTo(12 * scaling.factor, 1e-9)); + expect( + text1.letterSpacing, + closeTo(0.0025 * 12 * scaling.factor, 1e-9), + ); + expect(text1.height, closeTo(16 / 12, 1e-9)); + expect(text3.fontSize, closeTo(16 * scaling.factor, 1e-9)); + expect(text3.height, closeTo(24 / 16, 1e-9)); + expect(accordionText2.fontSize, closeTo(15 * scaling.factor, 1e-9)); + expect(accordionText2.height, closeTo(20 / 15, 1e-9)); + }); + } + } + }); + + testWidgets('gray accent aliases the resolved gray family', (tester) async { + late Color accent9; + late Color gray9; + + await tester.pumpWidget( + FortalScope( + brightness: .light, + accent: .gray, + gray: .slate, + child: WidgetsApp( + color: const Color(0xFF000000), + builder: (context, child) { + accent9 = MixScope.tokenOf(FortalTokens.accent9, context); + gray9 = MixScope.tokenOf(FortalTokens.gray9, context); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(accent9, gray9); + expect(accent9, slate.light.scale.step(9)); + }); +} diff --git a/packages/remix/test/fortal/fortal_tokens_test.dart b/packages/remix/test/fortal/fortal_tokens_test.dart new file mode 100644 index 00000000..19222512 --- /dev/null +++ b/packages/remix/test/fortal/fortal_tokens_test.dart @@ -0,0 +1,412 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; + +void main() { + group('Fortal semantic tokens', () { + test('keeps the background token name stable', () { + expect(FortalTokens.colorBackground.name, 'fortal.color.background'); + }); + + testWidgets('radio indicator metrics resolve before size arithmetic', ( + tester, + ) async { + final expectedSizes = { + FortalRadioSize.size1: 5.6, + FortalRadioSize.size2: 6.4, + FortalRadioSize.size3: 8.0, + }; + + for (final entry in expectedSizes.entries) { + late RemixRadioSpec spec; + await tester.pumpWidget( + FortalScope( + child: Builder( + builder: (context) { + spec = fortalRadioStyler(size: entry.key).build(context).spec; + return const SizedBox.shrink(); + }, + ), + ), + ); + + final constraints = spec.indicator.spec.constraints!; + expect(constraints.minWidth, entry.value); + expect(constraints.minHeight, entry.value); + expect(constraints.isTight, isTrue); + expect(constraints.isNormalized, isTrue); + } + }); + + testWidgets('resolve to the Radix light and dark semantic colors', ( + tester, + ) async { + final light = await _captureFortalTokens(tester, Brightness.light); + final dark = await _captureFortalTokens(tester, Brightness.dark); + + expect(light.semanticColors, const { + 'background': Color(0xFFFFFFFF), + 'panelSolid': Color(0xFFFFFFFF), + 'panelTranslucent': Color(0xB3FFFFFF), + 'surface': Color(0xD9FFFFFF), + 'overlay': Color(0x66000000), + }); + expect(dark.semanticColors, const { + 'background': Color(0xFF111113), + 'panelSolid': Color(0xFF18191B), + 'panelTranslucent': Color(0x09D8F4F6), + 'surface': Color(0x40000000), + 'overlay': Color(0x99000000), + }); + + for (final entry in light.semanticColors.entries) { + expect( + entry.value, + isNot(dark.semanticColors[entry.key]), + reason: '${entry.key} must differ between light and dark modes.', + ); + } + }); + + testWidgets('bind mode-specific shadow strokes into shadow recipes', ( + tester, + ) async { + final light = await _captureFortalTokens(tester, Brightness.light); + final dark = await _captureFortalTokens(tester, Brightness.dark); + + // Independent Chromium 3.3.0 `color-mix(in oklab, ...)` reference + // vectors. Keeping the numeric oracle here prevents the production + // interpolation helper from validating itself. + _expectColorChannels( + light.shadowStroke, + alpha: 0.294118, + red: 0.7666243898705472, + green: 0.7864932535606407, + blue: 0.8354613643851005, + tolerance: 0.0002, + ); + _expectColorChannels( + dark.shadowStroke, + alpha: 0.391176, + red: 0.42099932796903133, + green: 0.45831310533138864, + blue: 0.49482048001248397, + tolerance: 0.0002, + ); + expect(light.shadowStroke.toARGB32(), 0x4BC3C9D5); + expect(dark.shadowStroke.toARGB32(), 0x646B757E); + expect(light.shadowStroke, isNot(dark.shadowStroke)); + + // buildFortalShadows bakes fully-resolved Color values into every paint + // layer, so a shadowStroke-backed layer is pinned by resolved-value + // equality against the mode's shadowStroke rather than token identity. + for (final entry in {'light': light, 'dark': dark}.entries) { + final mode = entry.key; + final tokens = entry.value; + expect( + tokens.shadow2FirstLayerColor, + tokens.shadowStroke, + reason: 'shadow2 first layer resolved color ($mode)', + ); + expect( + tokens.shadow4FirstLayerColor, + tokens.shadowStroke, + reason: 'shadow4 first layer resolved color ($mode)', + ); + expect( + tokens.shadow5FirstLayerColor, + tokens.shadowStroke, + reason: 'shadow5 first layer resolved color ($mode)', + ); + expect( + tokens.shadow6FirstLayerColor, + tokens.shadowStroke, + reason: 'shadow6 first layer resolved color ($mode)', + ); + } + + expect( + _shadowGeometry(light.shadow3), + isNot(_shadowGeometry(dark.shadow3)), + ); + expect(_shadowGeometry(light.shadow3), const [ + (offset: Offset(0, 0), blurRadius: 0.0, spreadRadius: 1.0), + (offset: Offset(0, 2), blurRadius: 3.0, spreadRadius: -2.0), + (offset: Offset(0, 3), blurRadius: 12.0, spreadRadius: -4.0), + (offset: Offset(0, 4), blurRadius: 16.0, spreadRadius: -8.0), + ]); + expect(_shadowGeometry(dark.shadow3), const [ + (offset: Offset(0, 0), blurRadius: 0.0, spreadRadius: 1.0), + (offset: Offset(0, 2), blurRadius: 3.0, spreadRadius: -2.0), + (offset: Offset(0, 3), blurRadius: 8.0, spreadRadius: -2.0), + (offset: Offset(0, 4), blurRadius: 12.0, spreadRadius: -4.0), + ]); + + // shadow1 is the exact ordered CSS inset recipe in both modes. + final expectedLightShadow1Stroke = slate.light.scale.alphaStep(5); + final expectedDarkShadow1Stroke = slate.dark.scale.alphaStep(3); + + expect(light.shadow1FirstLayerColor, expectedLightShadow1Stroke); + expect(dark.shadow1FirstLayerColor, expectedDarkShadow1Stroke); + + expect( + _shadowGeometry(light.shadow1), + isNot(_shadowGeometry(dark.shadow1)), + ); + expect( + light.shadow1.map((shadow) => shadow.kind), + everyElement(RemixBoxShadowKind.inset), + ); + expect( + dark.shadow1.map((shadow) => shadow.kind), + everyElement(RemixBoxShadowKind.inset), + ); + for (final shadows in [ + light.shadow2, + light.shadow3, + light.shadow4, + light.shadow5, + light.shadow6, + dark.shadow2, + dark.shadow3, + dark.shadow4, + dark.shadow5, + dark.shadow6, + ]) { + expect( + shadows.map((shadow) => shadow.blurStyle), + everyElement(BlurStyle.normal), + ); + } + expect(_shadowGeometry(light.shadow1), const [ + (offset: Offset(0, 0), blurRadius: 0.0, spreadRadius: 1.0), + (offset: Offset(0, 1.5), blurRadius: 2.0, spreadRadius: 0.0), + (offset: Offset(0, 1.5), blurRadius: 2.0, spreadRadius: 0.0), + ]); + expect(_shadowGeometry(dark.shadow1), const [ + (offset: Offset(0, -1), blurRadius: 1.0, spreadRadius: 0.0), + (offset: Offset(0, 0), blurRadius: 0.0, spreadRadius: 1.0), + (offset: Offset(0, 3), blurRadius: 4.0, spreadRadius: 0.0), + (offset: Offset(0, 0), blurRadius: 0.0, spreadRadius: 1.0), + ]); + + final expectedLightShadow1Colors = [ + slate.light.scale.alphaStep(5), + slate.light.scale.alphaStep(2), + blackAlpha[2]!, + ]; + final expectedDarkShadow1Colors = [ + slate.dark.scale.alphaStep(3), + slate.dark.scale.alphaStep(3), + blackAlpha[5]!, + slate.dark.scale.alphaStep(4), + ]; + expect(_shadowColors(light.shadow1), isNot(_shadowColors(dark.shadow1))); + expect(_shadowColors(light.shadow1), expectedLightShadow1Colors); + expect(_shadowColors(dark.shadow1), expectedDarkShadow1Colors); + + // shadow4 and shadow5 use identical geometry in both modes; only the + // mode-specific black/gray alpha steps backing each layer change. + expect(_shadowGeometry(light.shadow4), const [ + (offset: Offset(0, 0), blurRadius: 0.0, spreadRadius: 1.0), + (offset: Offset(0, 8), blurRadius: 40.0, spreadRadius: 0.0), + (offset: Offset(0, 12), blurRadius: 32.0, spreadRadius: -16.0), + ]); + expect(_shadowGeometry(dark.shadow4), _shadowGeometry(light.shadow4)); + + final expectedLightShadow4Colors = [ + light.shadowStroke, + blackAlpha[1]!, + slate.light.scale.alphaStep(3), + ]; + final expectedDarkShadow4Colors = [ + dark.shadowStroke, + blackAlpha[3]!, + blackAlpha[5]!, + ]; + expect(_shadowColors(light.shadow4), isNot(_shadowColors(dark.shadow4))); + expect(_shadowColors(light.shadow4), expectedLightShadow4Colors); + expect(_shadowColors(dark.shadow4), expectedDarkShadow4Colors); + + expect(_shadowGeometry(light.shadow5), const [ + (offset: Offset(0, 0), blurRadius: 0.0, spreadRadius: 1.0), + (offset: Offset(0, 12), blurRadius: 60.0, spreadRadius: 0.0), + (offset: Offset(0, 12), blurRadius: 32.0, spreadRadius: -16.0), + ]); + expect(_shadowGeometry(dark.shadow5), _shadowGeometry(light.shadow5)); + + final expectedLightShadow5Colors = [ + light.shadowStroke, + blackAlpha[3]!, + slate.light.scale.alphaStep(5), + ]; + final expectedDarkShadow5Colors = [ + dark.shadowStroke, + blackAlpha[5]!, + blackAlpha[7]!, + ]; + expect(_shadowColors(light.shadow5), isNot(_shadowColors(dark.shadow5))); + expect(_shadowColors(light.shadow5), expectedLightShadow5Colors); + expect(_shadowColors(dark.shadow5), expectedDarkShadow5Colors); + }); + }); +} + +Future<_FortalTokenSnapshot> _captureFortalTokens( + WidgetTester tester, + Brightness brightness, +) async { + late _FortalTokenSnapshot snapshot; + + await tester.pumpWidget( + FortalScope( + brightness: brightness == .dark ? .dark : .light, + child: MaterialApp( + home: Builder( + builder: (context) { + final shadow1 = List.unmodifiable( + MixScope.tokenOf(FortalTokens.shadow1Layers, context), + ); + final shadow2 = List.unmodifiable( + MixScope.tokenOf(FortalTokens.shadow2, context), + ); + final shadow3 = List.unmodifiable( + MixScope.tokenOf(FortalTokens.shadow3, context), + ); + final shadow4 = List.unmodifiable( + MixScope.tokenOf(FortalTokens.shadow4, context), + ); + final shadow5 = List.unmodifiable( + MixScope.tokenOf(FortalTokens.shadow5, context), + ); + final shadow6 = List.unmodifiable( + MixScope.tokenOf(FortalTokens.shadow6, context), + ); + + snapshot = _FortalTokenSnapshot( + background: MixScope.tokenOf( + FortalTokens.colorBackground, + context, + ), + panelSolid: MixScope.tokenOf( + FortalTokens.colorPanelSolid, + context, + ), + panelTranslucent: MixScope.tokenOf( + FortalTokens.colorPanelTranslucent, + context, + ), + surface: MixScope.tokenOf(FortalTokens.colorSurface, context), + overlay: MixScope.tokenOf(FortalTokens.colorOverlay, context), + shadowStroke: MixScope.tokenOf( + FortalTokens.shadowStroke, + context, + ), + shadow1: shadow1, + shadow2: shadow2, + shadow3: shadow3, + shadow4: shadow4, + shadow5: shadow5, + shadow6: shadow6, + shadow1FirstLayerColor: shadow1.first.color, + shadow2FirstLayerColor: shadow2.first.color, + shadow4FirstLayerColor: shadow4.first.color, + shadow5FirstLayerColor: shadow5.first.color, + shadow6FirstLayerColor: shadow6.first.color, + ); + + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + + return snapshot; +} + +List<({Offset offset, double blurRadius, double spreadRadius})> _shadowGeometry( + List shadows, +) { + return [ + for (final shadow in shadows) + ( + offset: shadow.offset, + blurRadius: shadow.blurRadius, + spreadRadius: shadow.spreadRadius, + ), + ]; +} + +/// Extracts each shadow layer's resolved color, in order. +/// +/// buildFortalShadows bakes fully-resolved [Color] values into every +/// shadow values (it takes the theme's resolved [FortalThemeColors] rather than +/// embedding token refs), so pinning the exact color per layer is what +/// catches a swapped gray/black alpha step for a given brightness — the +/// layer count and geometry alone wouldn't change. +List _shadowColors(List shadows) { + return [for (final shadow in shadows) shadow.color]; +} + +void _expectColorChannels( + Color actual, { + required double alpha, + required double red, + required double green, + required double blue, + double tolerance = 1e-9, +}) { + expect(actual.a, closeTo(alpha, tolerance), reason: 'alpha channel'); + expect(actual.r, closeTo(red, tolerance), reason: 'red channel'); + expect(actual.g, closeTo(green, tolerance), reason: 'green channel'); + expect(actual.b, closeTo(blue, tolerance), reason: 'blue channel'); +} + +class _FortalTokenSnapshot { + const _FortalTokenSnapshot({ + required this.background, + required this.panelSolid, + required this.panelTranslucent, + required this.surface, + required this.overlay, + required this.shadowStroke, + required this.shadow1, + required this.shadow2, + required this.shadow3, + required this.shadow4, + required this.shadow5, + required this.shadow6, + required this.shadow1FirstLayerColor, + required this.shadow2FirstLayerColor, + required this.shadow4FirstLayerColor, + required this.shadow5FirstLayerColor, + required this.shadow6FirstLayerColor, + }); + + final Color background; + final Color panelSolid; + final Color panelTranslucent; + final Color surface; + final Color overlay; + final Color shadowStroke; + final List shadow1; + final List shadow2; + final List shadow3; + final List shadow4; + final List shadow5; + final List shadow6; + final Color shadow1FirstLayerColor; + final Color shadow2FirstLayerColor; + final Color shadow4FirstLayerColor; + final Color shadow5FirstLayerColor; + final Color shadow6FirstLayerColor; + + Map get semanticColors => { + 'background': background, + 'panelSolid': panelSolid, + 'panelTranslucent': panelTranslucent, + 'surface': surface, + 'overlay': overlay, + }; +} diff --git a/packages/remix/test/fortal/radix_3_3_color_fixture_test.dart b/packages/remix/test/fortal/radix_3_3_color_fixture_test.dart new file mode 100644 index 00000000..e7564ac9 --- /dev/null +++ b/packages/remix/test/fortal/radix_3_3_color_fixture_test.dart @@ -0,0 +1,148 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart' as remix; + +const _themes = { + 'gray': remix.gray, + 'mauve': remix.mauve, + 'slate': remix.slate, + 'sage': remix.sage, + 'olive': remix.olive, + 'sand': remix.sand, + 'tomato': remix.tomato, + 'red': remix.red, + 'ruby': remix.ruby, + 'crimson': remix.crimson, + 'pink': remix.pink, + 'plum': remix.plum, + 'purple': remix.purple, + 'violet': remix.violet, + 'iris': remix.iris, + 'indigo': remix.indigo, + 'blue': remix.blue, + 'cyan': remix.cyan, + 'teal': remix.teal, + 'jade': remix.jade, + 'green': remix.green, + 'grass': remix.grass, + 'bronze': remix.bronze, + 'gold': remix.gold, + 'brown': remix.brown, + 'orange': remix.orange, + 'amber': remix.amber, + 'yellow': remix.yellow, + 'lime': remix.lime, + 'mint': remix.mint, + 'sky': remix.sky, +}; + +void main() { + final fixture = + jsonDecode(File('radix_colors.generated.json').readAsStringSync()) + as Map; + + test('fixture is pinned to the approved Radix Themes artifact', () { + final meta = fixture['meta']! as Map; + expect(meta['radix_themes_version'], '3.3.0'); + expect( + meta['source_integrity'], + 'sha512-I0/h2CRNTpYNB7Mi3xFIvSsQq5a108d7kK8dTO5zp5b9HR5QJXKag6B8tjpz2ITkVYkFdkGk45doNkSr7OxwNw==', + ); + }); + + test('all generated scales and semantic roles match the pinned fixture', () { + final colors = fixture['colors']! as Map; + final scales = colors['scales']! as Map; + expect(scales.keys, orderedEquals(_themes.keys)); + + for (final entry in _themes.entries) { + final expectedTheme = scales[entry.key]! as Map; + _expectMode( + '${entry.key}.light', + entry.value.light, + expectedTheme['light']! as Map, + ); + _expectMode( + '${entry.key}.dark', + entry.value.dark, + expectedTheme['dark']! as Map, + ); + } + }); + + test('neutral alpha scales match the pinned fixture', () { + final colors = fixture['colors']! as Map; + final neutrals = colors['neutrals']! as Map; + + for (final entry in >{ + 'blackA': remix.blackAlpha, + 'whiteA': remix.whiteAlpha, + }.entries) { + final neutral = neutrals[entry.key]! as Map; + final alpha = neutral['alpha']! as Map; + for (var step = 1; step <= 12; step++) { + expect( + entry.value[step], + _parseColor(alpha['a$step']! as String), + reason: '${entry.key}.a$step', + ); + } + } + }); +} + +void _expectMode( + String name, + remix.RadixColor actual, + Map expected, +) { + final solid = expected['solid']! as Map; + final alpha = expected['alpha']! as Map; + for (var step = 1; step <= 12; step++) { + expect( + actual.scale.step(step), + _parseColor(solid['$step']! as String), + reason: '$name.$step', + ); + expect( + actual.scale.alphaStep(step), + _parseColor(alpha['a$step']! as String), + reason: '$name.a$step', + ); + } + + expect(actual.surface, _parseColor(expected['surface']! as String)); + expect(actual.indicator, _parseColor(expected['indicator']! as String)); + expect(actual.track, _parseColor(expected['track']! as String)); + expect(actual.contrast, _parseColor(expected['contrast']! as String)); +} + +Color _parseColor(String source) { + if (source.startsWith('#')) { + final hex = source.substring(1); + if (hex.length == 6) { + return Color(0xFF000000 | int.parse(hex, radix: 16)); + } + if (hex.length == 8) { + final rgb = int.parse(hex.substring(0, 6), radix: 16); + final alpha = int.parse(hex.substring(6), radix: 16); + return Color(alpha << 24 | rgb); + } + } + + final rgba = RegExp( + r'^rgba\((\d+), (\d+), (\d+), ([\d.]+)\)$', + ).firstMatch(source); + if (rgba != null) { + final red = int.parse(rgba.group(1)!); + final green = int.parse(rgba.group(2)!); + final blue = int.parse(rgba.group(3)!); + final alpha = (double.parse(rgba.group(4)!) * 255).round(); + return Color(alpha << 24 | red << 16 | green << 8 | blue); + } + + throw FormatException('Unsupported fixture color: $source'); +} diff --git a/packages/remix/test/public_api_compatibility_test.dart b/packages/remix/test/public_api_compatibility_test.dart new file mode 100644 index 00000000..368a0ef1 --- /dev/null +++ b/packages/remix/test/public_api_compatibility_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; + +void main() { + test('core constructors retain the origin/main data-driven surface', () { + const button = RemixButton(label: 'Save'); + const card = RemixCard(child: Text('Passive')); + const menu = RemixMenu( + trigger: RemixMenuTrigger(label: 'Actions'), + items: [RemixMenuItem(value: 'save', label: 'Save')], + ); + const select = RemixSelect( + trigger: RemixSelectTrigger(placeholder: 'Choose'), + items: [RemixSelectItem(value: 'one', label: 'One')], + ); + const slider = RemixSlider(value: 0.5); + const progress = RemixProgress(value: 0.5); + const tabBar = RemixTabBar(child: Text('Tabs')); + const spinner = RemixSpinner(); + + expect(button.label, 'Save'); + expect(card.child, isA()); + expect(menu.trigger, isA()); + expect(menu.items.single, isA>()); + expect(select.items.single, isA>()); + expect(slider.value, 0.5); + expect(progress.value, 0.5); + expect(tabBar.child, isA()); + expect(spinner, isA()); + }); + + test('generated wrappers preserve generic and named constructors', () { + const menu = FortalMenu.soft( + trigger: RemixMenuTrigger(label: 'Actions'), + items: [RemixMenuItem(value: 'save', label: 'Save')], + ); + const select = FortalSelect.ghost( + trigger: RemixSelectTrigger(placeholder: 'Choose'), + items: [RemixSelectItem(value: 'one', label: 'One')], + ); + const radio = FortalRadio.soft(value: 'one'); + const button = FortalButton.soft(label: 'Save'); + + expect(menu.variant, FortalMenuVariant.soft); + expect(select.variant, FortalSelectVariant.ghost); + expect(radio.variant, FortalRadioVariant.soft); + expect(button.variant, FortalButtonVariant.soft); + }); + + test('theme configuration exposes only canonical names', () { + const config = FortalThemeConfig( + accent: .red, + gray: .mauve, + brightness: .dark, + panelBackground: .solid, + radius: .large, + scaling: .percent105, + hasBackground: false, + ); + + expect(config.accent, FortalAccentColor.red); + expect(config.gray, FortalGrayColor.mauve); + expect(config.brightness, Brightness.dark); + }); +} diff --git a/packages/remix/test/public_api_test.dart b/packages/remix/test/public_api_test.dart new file mode 100644 index 00000000..23c90b8c --- /dev/null +++ b/packages/remix/test/public_api_test.dart @@ -0,0 +1,13 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; + +void main() { + test('mode-aware Fortal filters are constructible from the public API', () { + final modifier = fortalModeAwareFilter( + light: const [RemixCssColorFilterOperation.brightness(1.1)], + dark: const [RemixCssColorFilterOperation.contrast(0.9)], + ); + + expect(modifier, isNotNull); + }); +} diff --git a/packages/remix/test/rendering/remix_blend_mode_test.dart b/packages/remix/test/rendering/remix_blend_mode_test.dart new file mode 100644 index 00000000..3cebe3b7 --- /dev/null +++ b/packages/remix/test/rendering/remix_blend_mode_test.dart @@ -0,0 +1,205 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; +import 'package:remix/src/rendering/remix_box_effects.dart' + show RemixBoxWithEffects; + +const _boundaryKey = ValueKey('blend-boundary'); + +void main() { + testWidgets( + 'rejects composited descendants instead of silently skipping them', + (tester) async { + await tester.pumpWidget( + _blendHarness( + blendMode: BlendMode.multiply, + child: const RepaintBoundary( + child: ColoredBox( + color: Colors.red, + child: SizedBox.square(dimension: 20), + ), + ), + ), + ); + + final error = tester.takeException(); + expect(error, isA()); + expect( + error.toString(), + contains('does not support composited descendants'), + ); + }, + ); + + testWidgets('limits the blend operation to the child paint bounds', ( + tester, + ) async { + await tester.pumpWidget( + _blendHarness( + blendMode: BlendMode.src, + child: const ColoredBox( + color: Colors.red, + child: SizedBox.square(dimension: 20), + ), + ), + ); + + final pixels = await _capture(tester); + expect(_pixel(pixels, 30, 30), _rgba(Colors.red)); + expect(_pixel(pixels, 5, 5), _rgba(Colors.blue)); + }); + + testWidgets('includes overflowing descendants in the blend layer bounds', ( + tester, + ) async { + await tester.pumpWidget( + _blendHarness( + blendMode: BlendMode.src, + child: const SizedBox.square( + dimension: 20, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: -8, + top: 6, + width: 8, + height: 8, + child: ColoredBox(color: Colors.red), + ), + ], + ), + ), + ), + ); + + final pixels = await _capture(tester); + expect(_pixel(pixels, 16, 30), _rgba(Colors.red)); + expect(_pixel(pixels, 5, 5), _rgba(Colors.blue)); + }); + + testWidgets( + 'includes overflowing surface paint effects in the blend bounds', + (tester) async { + await tester.pumpWidget( + _blendHarness( + blendMode: BlendMode.src, + child: SizedBox.square( + dimension: 20, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: -8, + top: 6, + width: 8, + height: 8, + child: RemixBoxWithEffects( + styleSpec: const StyleSpec( + spec: BoxSpec( + decoration: BoxDecoration(color: Colors.red), + ), + ), + containerEffects: const RemixBoxEffectsSpec( + behindContent: RemixBoxEffectLayerSpec( + shadows: [ + RemixBoxShadow(color: Colors.green, spreadRadius: 2), + ], + ), + outline: BorderSide(color: Colors.yellow, width: 1), + outlineOffset: 3, + ), + child: const SizedBox.expand(), + ), + ), + ], + ), + ), + ), + ); + + final pixels = await _capture(tester); + expect(_pixel(pixels, 8, 30), _rgba(Colors.yellow)); + expect(_pixel(pixels, 10, 30), _rgba(Colors.green)); + expect(_pixel(pixels, 13, 30), _rgba(Colors.red)); + expect(_pixel(pixels, 5, 5), _rgba(Colors.blue)); + }, + ); + + testWidgets('does not expand the blend bounds past descendant clips', ( + tester, + ) async { + await tester.pumpWidget( + _blendHarness( + blendMode: BlendMode.src, + child: const SizedBox.square( + dimension: 20, + child: ClipRect( + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: -8, + top: 6, + width: 8, + height: 8, + child: ColoredBox(color: Colors.red), + ), + ], + ), + ), + ), + ), + ); + + final pixels = await _capture(tester); + expect(_pixel(pixels, 16, 30), _rgba(Colors.blue)); + }); +} + +Widget _blendHarness({required BlendMode blendMode, required Widget child}) => + MaterialApp( + home: Center( + child: RepaintBoundary( + key: _boundaryKey, + child: SizedBox.square( + dimension: 60, + child: Stack( + fit: StackFit.expand, + children: [ + const ColoredBox(color: Colors.blue), + Center( + child: RemixBlendMode(blendMode: blendMode, child: child), + ), + ], + ), + ), + ), + ), + ); + +Future<({ByteData bytes, int width})> _capture(WidgetTester tester) async { + final boundary = tester.renderObject( + find.byKey(_boundaryKey), + ); + final result = await tester.runAsync(() async { + final image = boundary.toImageSync(pixelRatio: 1); + final bytes = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + final width = image.width; + image.dispose(); + return (bytes: bytes!, width: width); + }); + return result!; +} + +int _pixel(({ByteData bytes, int width}) image, int x, int y) => + image.bytes.getUint32((y * image.width + x) * 4); + +int _rgba(Color color) { + final argb = color.toARGB32(); + return ((argb & 0x00FFFFFF) << 8) | (argb >>> 24); +} diff --git a/packages/remix/test/rendering/remix_box_effects_pixel_test.dart b/packages/remix/test/rendering/remix_box_effects_pixel_test.dart new file mode 100644 index 00000000..b1ccddbe --- /dev/null +++ b/packages/remix/test/rendering/remix_box_effects_pixel_test.dart @@ -0,0 +1,242 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; +import 'package:remix/src/rendering/remix_box_effects.dart' + show RemixBoxWithEffects; + +void main() { + group('Remix box effects pixel contract', () { + testWidgets('clips inset gradients and preserves CSS layer order', ( + tester, + ) async { + final pixels = await _render( + tester, + size: const Size(12, 12), + child: RemixBoxWithEffects( + styleSpec: const StyleSpec( + spec: BoxSpec( + constraints: BoxConstraints.tightFor(width: 12, height: 12), + decoration: BoxDecoration(color: Colors.blue), + ), + ), + containerEffects: const RemixBoxEffectsSpec( + behindContent: RemixBoxEffectLayerSpec( + gradients: [ + LinearGradient(colors: [Colors.red, Colors.red]), + LinearGradient(colors: [Colors.green, Colors.green]), + ], + gradientInsets: [3, 1], + ), + ), + ), + ); + + expect(_pixel(pixels, 0, 6), _rgba(Colors.blue)); + expect(_pixel(pixels, 1, 6), _rgba(Colors.green)); + expect(_pixel(pixels, 3, 6), _rgba(Colors.red)); + }); + + testWidgets('preserves inset spread, offset, blur, and shapeInset', ( + tester, + ) async { + final pixels = await _render( + tester, + size: const Size(80, 24), + child: Row( + children: [ + _insetSample( + const RemixBoxShadow( + kind: RemixBoxShadowKind.inset, + color: Colors.black, + spreadRadius: 3, + ), + ), + _insetSample( + const RemixBoxShadow( + kind: RemixBoxShadowKind.inset, + color: Colors.black, + spreadRadius: -3, + ), + ), + _insetSample( + const RemixBoxShadow( + kind: RemixBoxShadowKind.inset, + color: Colors.black, + offset: Offset(3, 0), + ), + ), + _insetSample( + const RemixBoxShadow( + kind: RemixBoxShadowKind.inset, + color: Colors.black, + blurRadius: 8, + shapeInset: 3, + ), + ), + ], + ), + ); + + expect(_pixel(pixels, 1, 12), _rgba(Colors.black)); + expect(_pixel(pixels, 6, 12), _rgba(Colors.white)); + expect(_pixel(pixels, 21, 12), _rgba(Colors.white)); + expect(_pixel(pixels, 41, 12), _rgba(Colors.black)); + expect(_pixel(pixels, 58, 12), _rgba(Colors.white)); + expect(_red(_pixel(pixels, 61, 12)), greaterThan(240)); + expect( + _red(_pixel(pixels, 63, 12)), + lessThan(_red(_pixel(pixels, 66, 12))), + ); + expect( + _red(_pixel(pixels, 66, 12)), + lessThan(_red(_pixel(pixels, 70, 12))), + ); + }); + + testWidgets( + 'orders border, content, foreground decoration, effects, outline', + (tester) async { + final pixels = await _render( + tester, + size: const Size(32, 32), + child: Center( + child: RemixBoxWithEffects( + styleSpec: const StyleSpec( + spec: BoxSpec( + constraints: BoxConstraints.tightFor(width: 20, height: 20), + decoration: BoxDecoration( + color: Colors.blue, + border: Border.fromBorderSide( + BorderSide(color: Colors.yellow, width: 2), + ), + ), + foregroundDecoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide(color: Colors.purple, width: 2), + ), + ), + ), + ), + containerEffects: const RemixBoxEffectsSpec( + overContent: RemixBoxEffectLayerSpec( + gradients: [ + LinearGradient(colors: [Colors.green, Colors.green]), + ], + gradientInsets: [5], + ), + outline: BorderSide(color: Colors.red, width: 2), + outlineOffset: 2, + ), + child: const ColoredBox(color: Colors.orange), + ), + ), + ); + + expect(_pixel(pixels, 2, 16), _rgba(Colors.red)); + expect(_pixel(pixels, 4, 16), _rgba(Colors.transparent)); + expect(_pixel(pixels, 6, 16), _rgba(Colors.purple)); + expect(_pixel(pixels, 9, 16), _rgba(Colors.orange)); + expect(_pixel(pixels, 11, 16), _rgba(Colors.green)); + }, + ); + + testWidgets( + 'keeps ordinary and advanced shadows outside clipping and blur', + (tester) async { + final pixels = await _render( + tester, + size: const Size(48, 32), + background: Colors.white, + child: Center( + child: RemixBoxWithEffects( + styleSpec: const StyleSpec( + spec: BoxSpec( + constraints: BoxConstraints.tightFor(width: 20, height: 20), + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all(Radius.circular(4)), + boxShadow: [BoxShadow(color: Colors.red, spreadRadius: 2)], + ), + ), + ), + containerEffects: const RemixBoxEffectsSpec( + backdropBlur: 4, + behindContent: RemixBoxEffectLayerSpec( + shadows: [ + RemixBoxShadow(color: Colors.blue, spreadRadius: 4), + ], + ), + ), + ), + ), + ); + + expect(_pixel(pixels, 10, 16), _rgba(Colors.blue)); + expect(_pixel(pixels, 12, 16), _rgba(Colors.red)); + expect(_pixel(pixels, 24, 16), _rgba(Colors.white)); + }, + ); + }); +} + +Widget _insetSample(RemixBoxShadow shadow) => RemixBoxWithEffects( + styleSpec: const StyleSpec( + spec: BoxSpec( + constraints: BoxConstraints.tightFor(width: 20, height: 24), + decoration: BoxDecoration(color: Colors.white), + ), + ), + containerEffects: RemixBoxEffectsSpec( + behindContent: RemixBoxEffectLayerSpec(shadows: [shadow]), + ), +); + +Future<({ByteData bytes, int width})> _render( + WidgetTester tester, { + required Size size, + required Widget child, + Color background = Colors.transparent, +}) async { + const boundaryKey = ValueKey('box-effects-pixels'); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: RepaintBoundary( + key: boundaryKey, + child: ColoredBox( + color: background, + child: SizedBox.fromSize(size: size, child: child), + ), + ), + ), + ), + ); + final boundary = tester.renderObject( + find.byKey(boundaryKey), + ); + final result = await tester.runAsync(() async { + final image = boundary.toImageSync(pixelRatio: 1); + final bytes = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + final width = image.width; + image.dispose(); + return (bytes: bytes!, width: width); + }); + return result!; +} + +int _pixel(({ByteData bytes, int width}) image, int x, int y) => + image.bytes.getUint32((y * image.width + x) * 4); + +int _rgba(Color color) => + (color.r * 255).round() << 24 | + (color.g * 255).round() << 16 | + (color.b * 255).round() << 8 | + (color.a * 255).round(); + +int _red(int rgba) => rgba >> 24 & 0xFF; diff --git a/packages/remix/test/rendering/remix_box_effects_test.dart b/packages/remix/test/rendering/remix_box_effects_test.dart new file mode 100644 index 00000000..bc72f562 --- /dev/null +++ b/packages/remix/test/rendering/remix_box_effects_test.dart @@ -0,0 +1,703 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; +import 'package:remix/src/rendering/remix_box_effects.dart' + show RemixBoxWithEffects, RemixFlexBoxWithEffects; + +void main() { + group('RemixBoxEffectsSpec', () { + test('rejects explicit shadows and a shadow token together', () { + expect( + () => RemixBoxEffectLayerMix( + shadows: [RemixBoxShadowMix()], + shadowToken: const RemixBoxShadowListToken('test.shadow'), + ), + throwsArgumentError, + ); + }); + + testWidgets('rejects a non-finite backdrop blur during build', ( + tester, + ) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: RemixBoxWithEffects( + styleSpec: const StyleSpec(spec: BoxSpec()), + containerEffects: const RemixBoxEffectsSpec( + backdropBlur: double.infinity, + ), + child: const SizedBox.square(dimension: 20), + ), + ), + ); + + final exception = tester.takeException(); + expect(exception, isA()); + expect(exception.toString(), contains('backdropBlur must be finite')); + }); + + final invalidEffects = <(String, RemixBoxEffectsSpec)>[ + ( + 'outline.width', + const RemixBoxEffectsSpec(outline: BorderSide(width: double.infinity)), + ), + ( + 'gradientInsets must be empty or match', + const RemixBoxEffectsSpec( + behindContent: RemixBoxEffectLayerSpec( + gradients: [ + LinearGradient(colors: [Colors.red, Colors.blue]), + ], + gradientInsets: [0, 1], + ), + ), + ), + ( + 'gradientInsets must contain finite', + const RemixBoxEffectsSpec( + overContent: RemixBoxEffectLayerSpec( + gradients: [ + LinearGradient(colors: [Colors.red, Colors.blue]), + ], + gradientInsets: [double.infinity], + ), + ), + ), + ( + 'offset must contain finite', + const RemixBoxEffectsSpec( + behindContent: RemixBoxEffectLayerSpec( + shadows: [RemixBoxShadow(offset: Offset(double.infinity, 0))], + ), + ), + ), + ( + 'blurRadius must be finite and non-negative', + const RemixBoxEffectsSpec( + behindContent: RemixBoxEffectLayerSpec( + shadows: [RemixBoxShadow(blurRadius: double.infinity)], + ), + ), + ), + ( + 'spreadRadius must be finite', + const RemixBoxEffectsSpec( + overContent: RemixBoxEffectLayerSpec( + shadows: [RemixBoxShadow(spreadRadius: double.infinity)], + ), + ), + ), + ( + 'shapeInset must be finite and non-negative', + const RemixBoxEffectsSpec( + overContent: RemixBoxEffectLayerSpec( + shadows: [RemixBoxShadow(shapeInset: double.infinity)], + ), + ), + ), + ]; + + for (final (message, effects) in invalidEffects) { + testWidgets('rejects invalid effects during build: $message', ( + tester, + ) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: RemixBoxWithEffects( + styleSpec: const StyleSpec(spec: BoxSpec()), + containerEffects: effects, + ), + ), + ); + + final exception = tester.takeException(); + expect(exception, isA()); + expect(exception.toString(), contains(message)); + }); + } + + test('owns only advanced paint', () { + const background = RemixBoxEffectLayerSpec( + gradients: [ + LinearGradient(colors: [Colors.red, Colors.blue]), + ], + gradientInsets: [1], + shadows: [RemixBoxShadow(kind: RemixBoxShadowKind.inset)], + ); + final effects = RemixBoxEffectsSpec( + behindContent: background, + backdropBlur: 8, + outline: BorderSide(color: Colors.green, width: 2), + outlineOffset: 3, + ); + + expect(effects.behindContent, same(background)); + expect(effects.overContent, isNull); + expect(effects.backdropBlur, 8); + expect(effects.outline.color, Colors.green); + expect(effects.outlineOffset, 3); + expect(background.props, [ + background.gradients, + [1], + background.shadows, + ]); + }); + + testWidgets('rejects outline stroke alignment other than inside', ( + tester, + ) async { + Widget build(double strokeAlign) => RemixBoxWithEffects( + styleSpec: const StyleSpec(spec: BoxSpec(decoration: BoxDecoration())), + containerEffects: RemixBoxEffectsSpec( + outline: BorderSide(strokeAlign: strokeAlign), + ), + ); + + for (final strokeAlign in [ + BorderSide.strokeAlignCenter, + BorderSide.strokeAlignOutside, + ]) { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: build(strokeAlign), + ), + ); + expect(tester.takeException(), isA()); + } + expect(RemixBoxEffectsSpec().outline, BorderSide.none); + }); + + test('fades an appearing outline through its own transparent hue', () { + final target = RemixBoxEffectsSpec( + outline: BorderSide(color: Color(0xFF3A7BD5), width: 4), + outlineOffset: 6, + ); + + final middle = RemixBoxEffectsSpec.lerpNullable(null, target, 0.5)!; + + expect(middle.outline.color.a, closeTo(0.5, 1e-12)); + expect(middle.outline.color.r, closeTo(target.outline.color.r, 1e-12)); + expect(middle.outline.color.g, closeTo(target.outline.color.g, 1e-12)); + expect(middle.outline.color.b, closeTo(target.outline.color.b, 1e-12)); + expect(middle.outline.width, 2); + expect(middle.outlineOffset, 3); + }); + + test('fades a disappearing outline through its own transparent hue', () { + final source = RemixBoxEffectsSpec( + outline: BorderSide(color: Color(0xFF3A7BD5), width: 4), + outlineOffset: 6, + ); + + final middle = RemixBoxEffectsSpec.lerpNullable(source, null, 0.5)!; + + expect(middle.outline.color.a, closeTo(0.5, 1e-12)); + expect(middle.outline.color.r, closeTo(source.outline.color.r, 1e-12)); + expect(middle.outline.color.g, closeTo(source.outline.color.g, 1e-12)); + expect(middle.outline.color.b, closeTo(source.outline.color.b, 1e-12)); + expect(middle.outline.width, 2); + expect(middle.outlineOffset, 3); + }); + }); + + group('Box effects geometry', () { + testWidgets('uses the real Mix Box when effects are absent', ( + tester, + ) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: RemixBoxWithEffects( + styleSpec: const StyleSpec(spec: BoxSpec()), + child: const SizedBox.square(dimension: 12), + ), + ), + ); + + expect(find.byType(Box), findsOneWidget); + expect(find.byType(CustomPaint), findsNothing); + }); + + testWidgets('uses the real Mix widgets when effects are empty', ( + tester, + ) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + RemixBoxWithEffects( + styleSpec: const StyleSpec(spec: BoxSpec()), + containerEffects: const RemixBoxEffectsSpec( + behindContent: RemixBoxEffectLayerSpec(), + ), + ), + RemixFlexBoxWithEffects( + styleSpec: const StyleSpec(spec: FlexBoxSpec()), + direction: Axis.horizontal, + containerEffects: const RemixBoxEffectsSpec( + overContent: RemixBoxEffectLayerSpec(), + ), + ), + ], + ), + ), + ); + + expect(find.byType(Box), findsOneWidget); + expect(find.byType(RowBox), findsOneWidget); + expect(find.byType(CustomPaint), findsNothing); + }); + + testWidgets('accepts a radius-only rectangular Box decoration', ( + tester, + ) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: RemixBoxWithEffects( + styleSpec: const StyleSpec( + spec: BoxSpec( + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(9)), + ), + ), + ), + containerEffects: RemixBoxEffectsSpec( + outline: BorderSide(color: Colors.green, width: 2), + ), + child: const SizedBox.square(dimension: 20), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.byType(CustomPaint), findsWidgets); + }); + + testWidgets('treats a missing decoration as a zero-radius rectangle', ( + tester, + ) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: RemixBoxWithEffects( + styleSpec: const StyleSpec(spec: BoxSpec()), + containerEffects: const RemixBoxEffectsSpec( + outline: BorderSide(color: Colors.green, width: 2), + ), + child: const SizedBox.square(dimension: 20), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.byType(CustomPaint), findsWidgets); + }); + + for (final invalid in [ + const ShapeDecoration(shape: RoundedRectangleBorder()), + const BoxDecoration(shape: BoxShape.circle), + ]) { + testWidgets('rejects unsupported effect geometry: $invalid', ( + tester, + ) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: RemixBoxWithEffects( + styleSpec: StyleSpec(spec: BoxSpec(decoration: invalid)), + containerEffects: RemixBoxEffectsSpec(backdropBlur: 1), + child: const SizedBox.square(dimension: 20), + ), + ), + ); + + final error = tester.takeException(); + expect(error, isA()); + expect(error.toString(), contains('BoxDecoration')); + }); + } + + testWidgets('matches Mix Box layout through the effects adapter', ( + tester, + ) async { + final spec = StyleSpec( + spec: BoxSpec( + alignment: Alignment.bottomRight, + padding: EdgeInsets.all(3), + margin: EdgeInsets.all(5), + constraints: BoxConstraints.tightFor(width: 64, height: 44), + decoration: BoxDecoration( + border: Border.fromBorderSide(BorderSide(width: 2)), + borderRadius: BorderRadius.all(Radius.circular(7)), + ), + transform: Matrix4.translationValues(2, 4, 0), + transformAlignment: Alignment.topLeft, + ), + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + key: const ValueKey('mix-host'), + width: 100, + height: 80, + child: Box( + styleSpec: spec, + child: const SizedBox( + key: ValueKey('mix-child'), + width: 11, + height: 9, + ), + ), + ), + SizedBox( + key: const ValueKey('effects-host'), + width: 100, + height: 80, + child: RemixBoxWithEffects( + styleSpec: spec, + containerEffects: const RemixBoxEffectsSpec( + behindContent: RemixBoxEffectLayerSpec( + shadows: [ + RemixBoxShadow( + kind: RemixBoxShadowKind.inset, + color: Colors.red, + spreadRadius: 1, + ), + ], + ), + ), + child: const SizedBox( + key: ValueKey('effects-child'), + width: 11, + height: 9, + ), + ), + ), + ], + ), + ), + ); + + final mixHost = tester.getTopLeft(find.byKey(const ValueKey('mix-host'))); + final effectsHost = tester.getTopLeft( + find.byKey(const ValueKey('effects-host')), + ); + final mixChild = tester.getRect(find.byKey(const ValueKey('mix-child'))); + final effectsChild = tester.getRect( + find.byKey(const ValueKey('effects-child')), + ); + + expect(effectsChild.size, mixChild.size); + expect(effectsChild.topLeft - effectsHost, mixChild.topLeft - mixHost); + }); + + testWidgets('preserves negative margins and null-child sizing', ( + tester, + ) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + key: const ValueKey('negative-footprint'), + child: RemixBoxWithEffects( + styleSpec: const StyleSpec( + spec: BoxSpec( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + margin: EdgeInsets.symmetric( + horizontal: -8, + vertical: -4, + ), + decoration: BoxDecoration(), + ), + ), + containerEffects: const RemixBoxEffectsSpec( + behindContent: RemixBoxEffectLayerSpec( + gradients: [ + LinearGradient(colors: [Colors.red, Colors.blue]), + ], + ), + ), + child: const SizedBox(width: 20, height: 10), + ), + ), + RemixBoxWithEffects( + key: const ValueKey('null-child'), + styleSpec: const StyleSpec( + spec: BoxSpec( + constraints: BoxConstraints.tightFor(width: 18, height: 12), + decoration: BoxDecoration(), + ), + ), + containerEffects: const RemixBoxEffectsSpec(backdropBlur: 1), + ), + ], + ), + ), + ); + + expect( + tester.getSize(find.byKey(const ValueKey('negative-footprint'))), + const Size(20, 10), + ); + expect( + tester.getSize(find.byKey(const ValueKey('null-child'))), + const Size(18, 12), + ); + }); + + testWidgets('clips content while preserving hit testing and semantics', ( + tester, + ) async { + var taps = 0; + final semantics = tester.ensureSemantics(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: RemixBoxWithEffects( + styleSpec: const StyleSpec( + spec: BoxSpec( + constraints: BoxConstraints.tightFor(width: 30, height: 20), + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(4)), + ), + ), + ), + containerEffects: const RemixBoxEffectsSpec( + outline: BorderSide(color: Colors.green, width: 2), + outlineOffset: 2, + ), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => taps += 1, + child: Semantics( + key: ValueKey('effects-semantics'), + label: 'advanced box effects child', + child: OverflowBox( + maxWidth: 60, + maxHeight: 40, + child: SizedBox(width: 60, height: 40), + ), + ), + ), + ), + ), + ), + ); + + expect(find.byType(ClipPath), findsOneWidget); + await tester.tap( + find.byKey(const ValueKey('effects-semantics')), + warnIfMissed: false, + ); + expect(taps, 1); + expect( + tester + .getSemantics(find.byKey(const ValueKey('effects-semantics'))) + .label, + contains('advanced box effects child'), + ); + semantics.dispose(); + }); + + testWidgets('clips blur and inner effects but not outer paint', ( + tester, + ) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: RemixBoxWithEffects( + styleSpec: const StyleSpec( + spec: BoxSpec( + clipBehavior: Clip.hardEdge, + constraints: BoxConstraints.tightFor(width: 30, height: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(6)), + ), + ), + ), + containerEffects: const RemixBoxEffectsSpec( + backdropBlur: 4, + behindContent: RemixBoxEffectLayerSpec( + shadows: [ + RemixBoxShadow( + kind: RemixBoxShadowKind.inset, + color: Colors.blue, + spreadRadius: 1, + ), + RemixBoxShadow(color: Colors.red, spreadRadius: 3), + ], + ), + outline: BorderSide(color: Colors.green, width: 2), + outlineOffset: 2, + ), + ), + ), + ); + + expect(find.byType(BackdropFilter), findsOneWidget); + expect(find.byType(ClipRRect), findsOneWidget); + expect(find.byType(ClipPath), findsOneWidget); + expect(find.byType(CustomPaint), findsAtLeastNWidgets(3)); + }); + + for (final advanced in [false, true]) { + testWidgets('preserves Box modifiers and animation on the ' + '${advanced ? 'advanced' : 'fast'} path', (tester) async { + final animation = AnimationConfig.linear( + const Duration(milliseconds: 100), + ); + Widget build(double width) => Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: RemixBoxWithEffects( + key: const ValueKey('animated-box'), + styleSpec: StyleSpec( + spec: BoxSpec( + constraints: BoxConstraints.tightFor( + width: width, + height: 20, + ), + decoration: const BoxDecoration(), + ), + animation: animation, + widgetModifiers: const [OpacityModifier(0.75)], + ), + containerEffects: advanced + ? const RemixBoxEffectsSpec( + outline: BorderSide(color: Colors.red, width: 1), + ) + : null, + ), + ), + ); + + await tester.pumpWidget(build(20)); + + expect(find.byType(Opacity), findsOneWidget); + expect(tester.widget(find.byType(Opacity)).opacity, 0.75); + final providers = tester.widgetList>( + find.byType(StyleSpecProvider), + ); + expect( + providers.where((provider) => provider.spec.animation == animation), + hasLength(1), + ); + + await tester.pumpWidget(build(40)); + await tester.pump(const Duration(milliseconds: 50)); + final animatedBox = find.byWidgetPredicate( + (widget) => + widget is ConstrainedBox && + widget.constraints.hasTightHeight && + widget.constraints.maxHeight == 20, + ); + expect(animatedBox, findsOneWidget); + expect(tester.getSize(animatedBox).width, closeTo(30, 0.01)); + await tester.pump(const Duration(milliseconds: 50)); + expect(tester.getSize(animatedBox).width, 40); + }); + } + + testWidgets('applies outer Flex and nested Box metadata exactly once', ( + tester, + ) async { + final outerAnimation = AnimationConfig.linear( + const Duration(milliseconds: 100), + ); + final innerAnimation = AnimationConfig.linear( + const Duration(milliseconds: 200), + ); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: RemixFlexBoxWithEffects( + styleSpec: StyleSpec( + spec: FlexBoxSpec( + box: StyleSpec( + spec: const BoxSpec(decoration: BoxDecoration()), + animation: innerAnimation, + widgetModifiers: const [OpacityModifier(0.6)], + ), + ), + animation: outerAnimation, + widgetModifiers: const [OpacityModifier(0.8)], + ), + direction: Axis.horizontal, + containerEffects: const RemixBoxEffectsSpec( + outline: BorderSide(color: Colors.red, width: 1), + ), + children: const [SizedBox.square(dimension: 10)], + ), + ), + ); + + final opacities = tester + .widgetList(find.byType(Opacity)) + .map((widget) => widget.opacity) + .toList(); + expect(opacities, hasLength(2)); + expect(opacities, containsAll([0.6, 0.8])); + expect( + tester + .widgetList>( + find.byType(StyleSpecProvider), + ) + .where((provider) => provider.spec.animation == outerAnimation), + hasLength(1), + ); + expect( + tester + .widgetList>( + find.byType(StyleSpecProvider), + ) + .where((provider) => provider.spec.animation == innerAnimation), + hasLength(1), + ); + }); + + testWidgets('retains the forced and styled Flex direction assertion', ( + tester, + ) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: RemixFlexBoxWithEffects( + styleSpec: const StyleSpec( + spec: FlexBoxSpec( + box: StyleSpec(spec: BoxSpec(decoration: BoxDecoration())), + flex: StyleSpec(spec: FlexSpec(direction: Axis.vertical)), + ), + ), + direction: Axis.horizontal, + containerEffects: const RemixBoxEffectsSpec( + outline: BorderSide(color: Colors.red, width: 1), + ), + ), + ), + ); + + final exception = tester.takeException(); + expect(exception, isA()); + expect( + exception.toString(), + contains('forced and styled flex directions must agree'), + ); + }); + }); +} diff --git a/packages/remix/test/rendering/remix_ordered_color_filter_test.dart b/packages/remix/test/rendering/remix_ordered_color_filter_test.dart new file mode 100644 index 00000000..3df2085b --- /dev/null +++ b/packages/remix/test/rendering/remix_ordered_color_filter_test.dart @@ -0,0 +1,260 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/src/rendering/remix_ordered_color_filter.dart'; + +void main() { + group('RemixCssColorFilterOperation', () { + test('uses the W3C sRGB brightness, contrast, and saturation matrices', () { + expect( + const RemixCssColorFilterOperation.brightness(1.1).matrix, + _closeMatrix([ + 1.1, + 0, + 0, + 0, + 0, + 0, + 1.1, + 0, + 0, + 0, + 0, + 0, + 1.1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + ); + expect( + const RemixCssColorFilterOperation.contrast(0.88).matrix, + _closeMatrix([ + 0.88, + 0, + 0, + 0, + 15.3, + 0, + 0.88, + 0, + 0, + 15.3, + 0, + 0, + 0.88, + 0, + 15.3, + 0, + 0, + 0, + 1, + 0, + ]), + ); + expect( + const RemixCssColorFilterOperation.saturate(1.1).matrix, + _closeMatrix([ + 1.0787, + -0.0715, + -0.0072, + 0, + 0, + -0.0213, + 1.0285, + -0.0072, + 0, + 0, + -0.0213, + -0.0715, + 1.0928, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + ); + }); + }); + + group('RemixOrderedColorFilterModifier', () { + testWidgets('matches pinned Chromium pixels in CSS function order', ( + tester, + ) async { + const boundaryKey = ValueKey('filter-boundary'); + const input = [ + Color(0xFFC43B72), + Color(0xFF287FBD), + Color(0xFF65AD38), + Color(0xFFE6B84D), + ]; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: RepaintBoundary( + key: boundaryKey, + child: + const RemixOrderedColorFilterModifier([ + RemixCssColorFilterOperation.contrast(0.88), + RemixCssColorFilterOperation.saturate(1.1), + RemixCssColorFilterOperation.brightness(1.1), + ]).build( + Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final color in input) + ColoredBox( + color: color, + child: SizedBox.square(dimension: 20), + ), + ], + ), + ), + ), + ), + ), + ); + + final pixels = await _capture(tester, find.byKey(boundaryKey)); + + // Generated independently by Chromium from the pinned Radix sequence. + // A one-channel-value tolerance accounts for engine raster rounding. + _expectPixelClose(_pixel(pixels, 10, 10), 0xD94781FF); + _expectPixelClose(_pixel(pixels, 30, 10), 0x308DCFFF); + _expectPixelClose(_pixel(pixels, 50, 10), 0x6EBB3EFF); + _expectPixelClose(_pixel(pixels, 70, 10), 0xF4C351FF); + }); + + test('interpolates compatible lists with CSS identity lacuna values', () { + const none = RemixOrderedColorFilterModifier([]); + const filtered = RemixOrderedColorFilterModifier([ + RemixCssColorFilterOperation.contrast(0.8), + RemixCssColorFilterOperation.saturate(1.2), + ]); + + expect(none.lerp(filtered, 0.5).operations, const [ + RemixCssColorFilterOperation.contrast(0.9), + RemixCssColorFilterOperation.saturate(1.1), + ]); + + const shorter = RemixOrderedColorFilterModifier([ + RemixCssColorFilterOperation.contrast(0.8), + ]); + expect(shorter.lerp(filtered, 0.5).operations, const [ + RemixCssColorFilterOperation.contrast(0.8), + RemixCssColorFilterOperation.saturate(1.1), + ]); + }); + + test('uses discrete interpolation for incompatible function order', () { + const first = RemixOrderedColorFilterModifier([ + RemixCssColorFilterOperation.contrast(0.8), + RemixCssColorFilterOperation.brightness(1.1), + ]); + const second = RemixOrderedColorFilterModifier([ + RemixCssColorFilterOperation.brightness(1.1), + RemixCssColorFilterOperation.contrast(0.8), + ]); + + expect(first.lerp(second, 0.49), first); + expect(first.lerp(second, 0.5), second); + }); + + testWidgets('filters the entire subtree without changing behavior', ( + tester, + ) async { + var taps = 0; + const modifier = RemixOrderedColorFilterModifier([ + RemixCssColorFilterOperation.saturate(0), + ]); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: modifier.build( + GestureDetector( + onTap: () => taps += 1, + child: Semantics( + label: 'filtered subtree', + child: const ColoredBox( + color: Colors.red, + child: SizedBox(width: 40, height: 30), + ), + ), + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(ColoredBox)), const Size(40, 30)); + await tester.tap(find.byType(ColoredBox)); + expect(taps, 1); + expect( + tester.getSemantics(find.byType(Semantics).last).label, + contains('filtered subtree'), + ); + expect(find.byType(ColorFiltered), findsOneWidget); + }); + + testWidgets('returns the child unchanged for an empty sequence', ( + tester, + ) async { + const child = SizedBox(key: ValueKey('unchanged')); + const modifier = RemixOrderedColorFilterModifier([]); + + expect(modifier.build(child), same(child)); + }); + }); +} + +Matcher _closeMatrix(List expected) => pairwiseCompare( + expected, + (actual, value) => (actual - value).abs() < 1e-10, + 'matrix values within 1e-10', +); + +Future<({ByteData bytes, int width})> _capture( + WidgetTester tester, + Finder finder, +) async { + final boundary = tester.renderObject(finder); + final result = await tester.runAsync(() async { + final image = boundary.toImageSync(pixelRatio: 1); + final bytes = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + final width = image.width; + image.dispose(); + return (bytes: bytes!, width: width); + }); + return result!; +} + +int _pixel(({ByteData bytes, int width}) image, int x, int y) { + final offset = (y * image.width + x) * 4; + return image.bytes.getUint32(offset); +} + +void _expectPixelClose(int actual, int expected) { + for (var shift = 0; shift <= 24; shift += 8) { + expect( + ((actual >> shift) & 0xFF) - ((expected >> shift) & 0xFF), + inInclusiveRange(-1, 1), + reason: + 'actual 0x${actual.toRadixString(16)}, ' + 'expected 0x${expected.toRadixString(16)}', + ); + } +} diff --git a/packages/remix/test/utilities/remix_style_test.dart b/packages/remix/test/utilities/remix_style_test.dart new file mode 100644 index 00000000..d49d3246 --- /dev/null +++ b/packages/remix/test/utilities/remix_style_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; +import 'package:remix/src/utilities/remix_style.dart' + show RemixDefaultContentStyle; + +void main() { + testWidgets( + 'RemixDefaultContentStyle provides defaults while descendants can override', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: RemixDefaultContentStyle( + text: StyleSpec( + spec: TextSpec( + style: TextStyle(color: Colors.blue, fontSize: 15), + ), + ), + icon: StyleSpec(spec: IconSpec(color: Colors.green, size: 19)), + child: Row( + children: [ + Icon(Icons.star), + Text('Inherited'), + Icon(Icons.close, color: Colors.red, size: 13), + Text( + 'Explicit', + style: TextStyle(color: Colors.orange, fontSize: 17), + ), + ], + ), + ), + ), + ); + + final inheritedTextContext = tester.element(find.text('Inherited')); + final inheritedIconContext = tester.element(find.byIcon(Icons.star)); + expect( + DefaultTextStyle.of(inheritedTextContext).style.color, + Colors.blue, + ); + expect(DefaultTextStyle.of(inheritedTextContext).style.fontSize, 15); + expect(IconTheme.of(inheritedIconContext).color, Colors.green); + expect(IconTheme.of(inheritedIconContext).size, 19); + + final explicitText = tester.widget(find.text('Explicit')); + final explicitIcon = tester.widget(find.byIcon(Icons.close)); + expect(explicitText.style?.color, Colors.orange); + expect(explicitText.style?.fontSize, 17); + expect(explicitIcon.color, Colors.red); + expect(explicitIcon.size, 13); + }, + ); +} diff --git a/packages/remix/tool/fortal_parity/check.dart b/packages/remix/tool/fortal_parity/check.dart new file mode 100644 index 00000000..0eac1d6d --- /dev/null +++ b/packages/remix/tool/fortal_parity/check.dart @@ -0,0 +1,702 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +const _expectedIntegrity = + 'sha512-I0/h2CRNTpYNB7Mi3xFIvSsQq5a108d7kK8dTO5zp5b9HR5QJXKag6B8tjpz2ITkVYkFdkGk45doNkSr7OxwNw=='; +const _expectedNakedUiVersion = '1.0.0-beta.7'; +const _expectedMappedFamilies = { + 'avatar', + 'badge', + 'button', + 'callout', + 'card', + 'checkbox', + 'dialog', + 'divider', + 'menu', + 'icon_button', + 'popover', + 'progress', + 'radio', + 'select', + 'slider', + 'spinner', + 'switch', + 'tabs', + 'text_field', + 'tooltip', +}; +const _expectedExtensions = {'accordion', 'toggle', 'toggle_group'}; +const _expectedStyleProps = >{ + 'avatar': {'color', 'radius', 'highContrast'}, + 'badge': {'color', 'radius', 'highContrast'}, + 'button': {'color', 'radius', 'highContrast'}, + 'callout': {'color', 'highContrast'}, + 'card': {}, + 'checkbox': {'color', 'highContrast'}, + 'dialog': {}, + 'divider': {'color'}, + 'menu': {'color', 'highContrast'}, + 'icon_button': {'color', 'radius', 'highContrast'}, + 'popover': {}, + 'progress': {'color', 'radius', 'highContrast'}, + 'radio': {'color', 'highContrast'}, + 'select': {'color', 'radius', 'highContrast'}, + 'slider': {'color', 'radius', 'highContrast'}, + 'spinner': {}, + 'switch': {'color', 'radius', 'highContrast'}, + 'tabs': {'color', 'highContrast'}, + 'text_field': {'color', 'radius'}, + 'tooltip': {}, +}; + +void main() { + final packageRoot = Directory.current; + final pubspec = File('${packageRoot.path}/pubspec.yaml'); + if (!pubspec.existsSync() || + !pubspec.readAsStringSync().contains('name: remix')) { + stderr.writeln('Run this checker from packages/remix.'); + exitCode = 64; + return; + } + + final workspaceRoot = packageRoot.parent.parent; + final failures = []; + final manifestFile = File( + '${packageRoot.path}/reference/radix_themes_3_3_0/manifest.json', + ); + final manifest = _readObject(manifestFile, failures); + if (manifest == null) { + _finish(failures); + } + + final evidencePath = manifest['coverageEvidence']; + if (evidencePath is! String || evidencePath.isEmpty) { + failures.add('coverageEvidence must be a package-relative JSON path.'); + _finish(failures); + } + final evidence = _readObject( + File('${packageRoot.path}/$evidencePath'), + failures, + ); + if (evidence == null) { + _finish(failures); + } + _checkCoverageEvidenceOwners(evidence, failures); + + _checkSource(manifest, failures); + _checkTheme(manifest, evidence, packageRoot, failures); + _checkFamilies(manifest, evidence, packageRoot, workspaceRoot, failures); + _checkFixtures(manifest, packageRoot, failures); + _checkNakedPin( + pubspec, + File('${workspaceRoot.path}/pubspec.yaml'), + File('${workspaceRoot.path}/pubspec.lock'), + failures, + ); + _checkVariantConstructors(packageRoot, failures); + _checkApproximations(manifest, failures); + _finish(failures); +} + +Map? _readObject(File file, List failures) { + if (!file.existsSync()) { + failures.add('Missing ${file.path}.'); + return null; + } + try { + final value = jsonDecode(file.readAsStringSync()); + if (value is Map) return value; + failures.add('${file.path} must contain a JSON object.'); + } on FormatException catch (error) { + failures.add('${file.path} is invalid JSON: $error'); + } + return null; +} + +void _checkSource(Map manifest, List failures) { + final source = _object(manifest['source'], 'source', failures); + if (source == null) return; + _expect( + source['package'] == '@radix-ui/themes', + 'source.package must be @radix-ui/themes.', + failures, + ); + _expect( + source['version'] == '3.3.0', + 'source.version must be 3.3.0.', + failures, + ); + _expect( + source['integrity'] == _expectedIntegrity, + 'source.integrity drifted from the approved npm artifact.', + failures, + ); + _expect( + source['tarball'] == + 'https://registry.npmjs.org/@radix-ui/themes/-/themes-3.3.0.tgz', + 'source.tarball drifted from the approved npm artifact.', + failures, + ); +} + +void _checkTheme( + Map manifest, + Map evidence, + Directory packageRoot, + List failures, +) { + final theme = _object(manifest['theme'], 'theme', failures); + if (theme == null) return; + _expect( + theme['upstreamInventory'] is Map, + 'theme must retain a separate upstreamInventory.', + failures, + ); + _expect( + _strings( + theme['supportedVisualStates'], + 'theme.supportedVisualStates', + failures, + ).isNotEmpty, + 'theme must list supportedVisualStates separately.', + failures, + ); + _checkCoverage( + owner: 'theme', + enums: _enumKeys(theme['enums'], 'theme.enums', failures), + states: _strings(theme['states'], 'theme.states', failures), + coverageValue: theme['coverage'], + evidence: evidence, + packageRoot: packageRoot, + failures: failures, + ); +} + +void _checkFamilies( + Map manifest, + Map evidence, + Directory packageRoot, + Directory workspaceRoot, + List failures, +) { + final familyValues = manifest['families']; + if (familyValues is! List) { + failures.add('families must be a JSON array.'); + return; + } + final families = >{}; + final mapped = {}; + final extensions = {}; + for (final (index, value) in familyValues.indexed) { + final family = _object(value, 'families[$index]', failures); + if (family == null) continue; + final id = family['id']; + if (id is! String || id.isEmpty) { + failures.add('families[$index].id must be a non-empty string.'); + continue; + } + if (families.containsKey(id)) failures.add('Duplicate family id: $id.'); + families[id] = family; + switch (family['parity']) { + case 'mapped': + mapped.add(id); + case 'extension': + extensions.add(id); + default: + failures.add('$id.parity must be mapped or extension.'); + } + + final selectors = _strings( + family['sourceSelectors'], + '$id.sourceSelectors', + failures, + ); + final sourceFiles = _strings( + family['sourceFiles'], + '$id.sourceFiles', + failures, + ); + if (family['parity'] == 'mapped') { + _expect( + selectors.isNotEmpty, + '$id must name its upstream selectors.', + failures, + ); + _expect( + sourceFiles.isNotEmpty, + '$id must name its upstream source files.', + failures, + ); + final actualProps = _strings( + family['supportedStyleProps'], + '$id.supportedStyleProps', + failures, + ).toSet(); + _expect( + _sameSet(actualProps, _expectedStyleProps[id] ?? const {}), + '$id supportedStyleProps drifted: $actualProps.', + failures, + ); + } + _expect( + _strings( + family['flutterExceptions'], + '$id.flutterExceptions', + failures, + ).isNotEmpty, + '$id must document its Flutter mapping or extension status.', + failures, + ); + _expect( + family['upstreamInventory'] is Map, + '$id must retain a separate upstreamInventory.', + failures, + ); + _expect( + family['visualMapping'] is Map, + '$id must document its Flutter visualMapping.', + failures, + ); + _strings( + family['deferredCapabilities'], + '$id.deferredCapabilities', + failures, + ); + _checkCoverage( + owner: id, + enums: _enumKeys(family['enums'], '$id.enums', failures), + states: _strings(family['states'], '$id.states', failures), + coverageValue: family['coverage'], + evidence: evidence, + packageRoot: packageRoot, + failures: failures, + ); + } + + _expect( + _sameSet(mapped, _expectedMappedFamilies), + 'Mapped family set drifted: $mapped.', + failures, + ); + _expect( + _sameSet(extensions, _expectedExtensions), + 'Extension family set drifted: $extensions.', + failures, + ); + _expect( + families.length == 23, + 'Exactly 23 Fortal families must be tracked.', + failures, + ); +} + +void _checkCoverage({ + required String owner, + required Set enums, + required List states, + required Object? coverageValue, + required Map evidence, + required Directory packageRoot, + required List failures, +}) { + final coverage = _object(coverageValue, '$owner.coverage', failures); + if (coverage == null) return; + final coveredEnums = _strings( + coverage['enums'], + '$owner.coverage.enums', + failures, + ).toSet(); + final coveredStates = _strings( + coverage['states'], + '$owner.coverage.states', + failures, + ).toSet(); + _expect( + _sameSet(coveredEnums, enums), + '$owner enum coverage drifted. Expected $enums, found $coveredEnums.', + failures, + ); + _expect( + _sameSet(coveredStates, states.toSet()), + '$owner state coverage drifted. Expected ${states.toSet()}, found $coveredStates.', + failures, + ); + final tests = _strings(coverage['tests'], '$owner.coverage.tests', failures); + _expect( + tests.isNotEmpty, + '$owner must cite at least one test file.', + failures, + ); + for (final relativePath in tests) { + final test = File('${packageRoot.path}/$relativePath'); + if (!test.existsSync()) { + failures.add('$owner cites missing test $relativePath.'); + } else if (!test.readAsStringSync().contains('test')) { + failures.add( + '$owner evidence $relativePath contains no test declaration.', + ); + } + } + + final evidenceValue = evidence[owner]; + if (evidenceValue is! List || evidenceValue.isEmpty) { + failures.add('$owner must retain upstream coverage evidence.'); + } +} + +void _checkCoverageEvidenceOwners( + Map evidence, + List failures, +) { + final expectedOwners = { + 'theme', + ..._expectedMappedFamilies, + ..._expectedExtensions, + }; + _expect( + _sameSet(evidence.keys.toSet(), expectedOwners), + 'Coverage evidence owners drifted. Expected $expectedOwners, ' + 'found ${evidence.keys.toSet()}.', + failures, + ); +} + +Set _enumKeys(Object? value, String path, List failures) { + final enums = _object(value, path, failures); + if (enums == null) return {}; + final result = {}; + for (final entry in enums.entries) { + final definition = _object(entry.value, '$path.${entry.key}', failures); + if (definition == null) continue; + final values = _strings( + definition['values'], + '$path.${entry.key}.values', + failures, + ); + for (final value in values) { + result.add('${entry.key}.$value'); + } + if (definition.containsKey('default')) { + final defaultValue = definition['default']; + if (defaultValue != null && !values.contains(defaultValue)) { + failures.add('$path.${entry.key}.default is not one of its values.'); + } + } + } + return result; +} + +void _checkFixtures( + Map manifest, + Directory packageRoot, + List failures, +) { + final fixtures = _object( + manifest['referenceFixtures'], + 'referenceFixtures', + failures, + ); + if (fixtures == null) return; + File resolve(String key) => File('${packageRoot.path}/${fixtures[key]}'); + for (final key in ['computedStyles', 'screenshot', 'generator', 'fixture']) { + final value = fixtures[key]; + if (value is! String || value.isEmpty) { + failures.add('referenceFixtures.$key must be a path.'); + continue; + } + if (!resolve(key).existsSync()) + failures.add('Missing reference fixture $value.'); + } + + final computed = _readObject(resolve('computedStyles'), failures); + final source = computed == null + ? null + : _object(computed['source'], 'computedStyles.source', failures); + _expect( + source?['version'] == '3.3.0', + 'Computed styles must record Radix 3.3.0.', + failures, + ); + _expect( + source?['integrity'] == _expectedIntegrity, + 'Computed-style npm integrity drifted.', + failures, + ); + final probes = computed == null + ? null + : _object(computed['probes'], 'computedStyles.probes', failures); + final normalizedProbeIds = + probes?.keys.map((id) => id.replaceAll('-', '_')).toSet() ?? {}; + _expect( + _sameSet(normalizedProbeIds, _expectedMappedFamilies), + 'Computed styles must contain one probe for every mapped family.', + failures, + ); + + final screenshot = resolve('screenshot'); + if (screenshot.existsSync()) { + final bytes = screenshot.readAsBytesSync(); + final validSignature = + bytes.length >= 24 && + const [ + 137, + 80, + 78, + 71, + 13, + 10, + 26, + 10, + ].indexed.every((entry) => bytes[entry.$1] == entry.$2); + _expect(validSignature, 'Reference screenshot is not a PNG.', failures); + if (validSignature) { + final data = ByteData.sublistView(bytes); + _expect( + data.getUint32(16) == 1440 && data.getUint32(20) == 1280, + 'Reference screenshot must be 1440×1280.', + failures, + ); + } + } + + final lock = _readObject( + File('${packageRoot.path}/tool/fortal_parity/chromium/package-lock.json'), + failures, + ); + final packages = lock == null + ? null + : _object(lock['packages'], 'chromium lockfile packages', failures); + final lockedThemes = packages == null + ? null + : _object( + packages['node_modules/@radix-ui/themes'], + 'chromium lockfile Radix package', + failures, + ); + _expect( + lockedThemes?['version'] == '3.3.0', + 'Chromium harness must lock Radix 3.3.0.', + failures, + ); + _expect( + lockedThemes?['integrity'] == _expectedIntegrity, + 'Chromium harness integrity drifted.', + failures, + ); +} + +void _checkNakedPin( + File packagePubspec, + File workspacePubspec, + File workspaceLock, + List failures, +) { + final packageSource = packagePubspec.readAsStringSync(); + _expect( + RegExp( + '^ naked_ui: ${RegExp.escape(_expectedNakedUiVersion)}\\s*\$', + multiLine: true, + ).hasMatch(packageSource), + 'Remix must declare the exact naked_ui $_expectedNakedUiVersion ' + 'dependency.', + failures, + ); + final workspaceSource = workspacePubspec.readAsStringSync(); + _expect( + !_hasDependencyOverride(workspaceSource, 'naked_ui'), + 'The workspace must resolve naked_ui $_expectedNakedUiVersion from ' + 'pub.dev without a dependency override.', + failures, + ); + final lockSource = workspaceLock.readAsStringSync(); + final nakedUiLockEntry = _lockEntry(lockSource, 'naked_ui'); + _expect( + nakedUiLockEntry != null && + RegExp( + r'^ source: hosted$', + multiLine: true, + ).hasMatch(nakedUiLockEntry) && + RegExp( + '^ version: "${RegExp.escape(_expectedNakedUiVersion)}"\\s*\$', + multiLine: true, + ).hasMatch(nakedUiLockEntry), + 'The workspace lockfile must resolve naked_ui ' + '$_expectedNakedUiVersion from pub.dev.', + failures, + ); +} + +bool _hasDependencyOverride(String source, String dependency) { + var inDependencyOverrides = false; + for (final line in const LineSplitter().convert(source)) { + final trimmed = line.trim(); + if (trimmed.isEmpty || trimmed.startsWith('#')) continue; + final indentation = line.length - line.trimLeft().length; + if (indentation == 0) { + inDependencyOverrides = trimmed == 'dependency_overrides:'; + continue; + } + if (inDependencyOverrides && + indentation == 2 && + trimmed == '$dependency:') { + return true; + } + } + return false; +} + +String? _lockEntry(String source, String dependency) { + final lines = const LineSplitter().convert(source); + final start = lines.indexOf(' $dependency:'); + if (start == -1) return null; + var end = lines.length; + for (var index = start + 1; index < lines.length; index++) { + if (RegExp(r'^ [^ ]').hasMatch(lines[index])) { + end = index; + break; + } + } + return lines.sublist(start, end).join('\n'); +} + +void _checkVariantConstructors(Directory packageRoot, List failures) { + final componentRoot = Directory('${packageRoot.path}/lib/src/components'); + for (final entity in componentRoot.listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('_styles.dart')) continue; + final source = entity.readAsStringSync(); + + for (final enumMatch in RegExp( + r'enum\s+(Fortal[A-Za-z0-9_]+Variant)\s*\{([^}]*)\}', + dotAll: true, + ).allMatches(source)) { + final enumName = enumMatch.group(1)!; + final className = enumName.substring( + 0, + enumName.length - 'Variant'.length, + ); + if (!RegExp( + 'class\\s+${RegExp.escape(className)}(?:<|\\s)', + ).hasMatch(source)) { + continue; + } + + final enumBody = enumMatch + .group(2)! + .replaceAll(RegExp(r'//[^\n]*'), '') + .replaceAll(RegExp(r'/\*.*?\*/', dotAll: true), ''); + final variants = enumBody + .split(',') + .map((value) => value.trim()) + .where((value) => value.isNotEmpty) + .map((value) => RegExp(r'^[A-Za-z0-9_]+').stringMatch(value)) + .nonNulls + .toSet(); + final constructors = RegExp( + 'const\\s+${RegExp.escape(className)}\\.([A-Za-z0-9_]+)\\s*\\(', + ).allMatches(source).map((match) => match.group(1)!).toSet(); + final relatedVariants = + RegExp( + 'enum\\s+${RegExp.escape(className)}[A-Za-z0-9_]*Variant\\s*\\{([^}]*)\\}', + dotAll: true, + ).allMatches(source).expand((match) { + return match + .group(1)! + .replaceAll(RegExp(r'//[^\n]*'), '') + .replaceAll(RegExp(r'/\*.*?\*/', dotAll: true), '') + .split(',') + .map((value) => value.trim()) + .where((value) => value.isNotEmpty) + .map((value) => RegExp(r'^[A-Za-z0-9_]+').stringMatch(value)) + .nonNulls; + }).toSet(); + + final missing = variants.difference(constructors); + final unexpected = constructors.difference(relatedVariants); + if (missing.isNotEmpty) { + failures.add( + '${entity.path} is missing $className variant constructors: $missing.', + ); + } + if (unexpected.isNotEmpty) { + failures.add( + '${entity.path} has non-variant $className constructors: $unexpected.', + ); + } + } + } +} + +void _checkApproximations( + Map manifest, + List failures, +) { + final approximations = manifest['approximations']; + if (approximations is! List) { + failures.add('approximations must be a JSON array.'); + return; + } + for (final (index, value) in approximations.indexed) { + final approximation = _object(value, 'approximations[$index]', failures); + if (approximation == null) continue; + for (final field in [ + 'family', + 'upstream', + 'flutter', + 'reason', + 'tolerance', + ]) { + if (approximation[field] is! String || + (approximation[field]! as String).isEmpty) { + failures.add( + 'approximations[$index].$field must document the parity boundary.', + ); + } + } + } +} + +Map? _object( + Object? value, + String path, + List failures, +) { + if (value is Map) return value; + failures.add('$path must be a JSON object.'); + return null; +} + +List _strings(Object? value, String path, List failures) { + if (value is List && value.every((item) => item is String)) { + return value.cast(); + } + failures.add('$path must be an array of strings.'); + return const []; +} + +bool _sameSet(Set left, Set right) => + left.length == right.length && left.containsAll(right); + +void _expect(bool condition, String message, List failures) { + if (!condition) failures.add(message); +} + +Never _finish(List failures) { + if (failures.isNotEmpty) { + stderr.writeln( + 'Fortal parity contract failed (${failures.length} findings):', + ); + for (final failure in failures) { + stderr.writeln('- $failure'); + } + exit(1); + } + stdout.writeln( + 'Verified @radix-ui/themes 3.3.0 contract: ' + '20 mapped families, 3 Fortal extensions, Chromium fixtures, ' + 'coverage ledger, hosted Naked $_expectedNakedUiVersion pin, and no ' + 'undocumented approximations.', + ); + exit(0); +} diff --git a/packages/remix/tool/fortal_parity/chromium/fixture.html b/packages/remix/tool/fortal_parity/chromium/fixture.html new file mode 100644 index 00000000..8d00e1ff --- /dev/null +++ b/packages/remix/tool/fortal_parity/chromium/fixture.html @@ -0,0 +1,113 @@ + + + + + + Radix Themes 3.3.0 Fortal reference + + + + +
+

Radix Themes 3.3.0 — Fortal mapped families

+
+
AvatarRT
+
BadgeBadge
+
Button
+
Callout
!

Callout text

+
Card
+
Checkbox
+
Dialog
Dialog titleDialog content
+
Divider
+
Menu
+
IconButton
+
Popover
Popover content
+
Progress
+
Radio
+
Select
Item
+
Slider
+
Spinner
+
Switch
+
Tabs
+
TextField
+
Tooltip

Tooltip content

+
+
+ + + diff --git a/packages/remix/tool/fortal_parity/chromium/generate.mjs b/packages/remix/tool/fortal_parity/chromium/generate.mjs new file mode 100644 index 00000000..15152d23 --- /dev/null +++ b/packages/remix/tool/fortal_parity/chromium/generate.mjs @@ -0,0 +1,166 @@ +import { execFileSync, spawn } from 'node:child_process'; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const toolDirectory = dirname(fileURLToPath(import.meta.url)); +const fixture = join(toolDirectory, 'fixture.html'); +const outputDirectory = join( + toolDirectory, + '..', + '..', + '..', + 'reference', + 'radix_themes_3_3_0', + 'chromium', +); +const packageMetadata = JSON.parse( + readFileSync(join(toolDirectory, 'node_modules', '@radix-ui', 'themes', 'package.json')), +); +const lockfile = JSON.parse(readFileSync(join(toolDirectory, 'package-lock.json'))); +const lockedPackage = lockfile.packages['node_modules/@radix-ui/themes']; + +const chromium = + process.env.FORTAL_CHROMIUM_BIN ?? + [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/usr/bin/google-chrome', + '/usr/bin/chromium', + ].find(existsSync); + +if (chromium === undefined) { + throw new Error( + 'Chromium was not found. Set FORTAL_CHROMIUM_BIN to a Chromium-compatible executable.', + ); +} +if (packageMetadata.version !== '3.3.0' || lockedPackage.version !== '3.3.0') { + throw new Error('The browser fixture must use @radix-ui/themes 3.3.0 exactly.'); +} + +const commonArguments = [ + '--headless=new', + '--disable-gpu', + '--disable-extensions', + '--disable-background-networking', + '--disable-default-apps', + '--disable-sync', + '--hide-scrollbars', + '--no-first-run', + '--allow-file-access-from-files', + '--force-color-profile=srgb', + '--font-render-hinting=none', + '--run-all-compositor-stages-before-draw', + '--virtual-time-budget=2000', +]; + +const chromiumVersion = execFileSync(chromium, ['--version'], { encoding: 'utf8' }).trim(); +const dumpedDocument = await runChromium( + ['--dump-dom', pathToFileURL(fixture).href], + ({ stdout }) => stdout.includes(''), +); +const match = dumpedDocument.match( + /