From 78c8411c0742584975f79d1d02bc7a1ebb7704bd Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 15 Jul 2026 15:54:56 +0200 Subject: [PATCH 01/14] add easy way to theme colors --- .../theme/semantics/stream_color_scheme.dart | 25 ++++++++++++++++++ .../lib/src/theme/stream_theme.dart | 26 +++++++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart index b7f0e192..137e95d1 100644 --- a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart @@ -484,6 +484,31 @@ class StreamColorScheme with _$StreamColorScheme { ); } + factory StreamColorScheme.fromColorScheme( + ColorScheme colorScheme, { + Brightness brightness = Brightness.light, + }) { + final brand = StreamColorSwatch.fromColor(colorScheme.primary, brightness: brightness); + final chrome = StreamColorSwatch.fromColor(colorScheme.surface, brightness: brightness); + + final base = brightness == Brightness.light + ? StreamColorScheme.light(brand: brand, chrome: chrome) + : StreamColorScheme.dark(brand: brand, chrome: chrome); + + return base.copyWith( + // Accent + accentError: colorScheme.error, + accentNeutral: colorScheme.secondary, + // // Text + textPrimary: colorScheme.onSurface, + textSecondary: colorScheme.onSurfaceVariant, + textTertiary: colorScheme.outline, + textLink: colorScheme.primary, + textOnAccent: colorScheme.onPrimary, + textOnInverse: colorScheme.onInverseSurface, + ); + } + const StreamColorScheme.raw({ required this.brand, required this.chrome, diff --git a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart index 05960c99..0177dfb1 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:stream_core/stream_core.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; import 'components/stream_app_bar_theme.dart'; @@ -40,6 +41,7 @@ import 'components/stream_snackbar_theme.dart'; import 'components/stream_stepper_theme.dart'; import 'components/stream_switch_theme.dart'; import 'components/stream_text_input_theme.dart'; +import 'primitives/stream_colors.dart'; import 'primitives/stream_icons.dart'; import 'primitives/stream_radius.dart'; import 'primitives/stream_spacing.dart'; @@ -255,13 +257,33 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// /// This is a convenience factory that calls [StreamTheme] with /// [Brightness.dark]. - factory StreamTheme.dark() => StreamTheme(brightness: .dark); + factory StreamTheme.dark({Color? brandColor, Color? chromeColor}) => StreamTheme( + brightness: .dark, + colorScheme: StreamColorScheme.dark( + brand: brandColor?.let( + (brandColor) => StreamColorSwatch.fromColor(brandColor, brightness: .dark), + ), + chrome: chromeColor?.let( + (chromeColor) => StreamColorSwatch.fromColor(chromeColor, brightness: .dark), + ), + ), + ); /// Creates a light theme configuration. /// /// This is a convenience factory that calls [StreamTheme] with /// [Brightness.light]. - factory StreamTheme.light() => StreamTheme(brightness: .light); + factory StreamTheme.light({Color? brandColor, Color? chromeColor}) => StreamTheme( + brightness: .light, + colorScheme: StreamColorScheme.light( + brand: brandColor?.let( + (color) => StreamColorSwatch.fromColor(brandColor), + ), + chrome: chromeColor?.let( + (color) => StreamColorSwatch.fromColor(chromeColor), + ), + ), + ); const StreamTheme.raw({ required this.brightness, From f0edd41ca40289ea26e726ef12cfbddec55c5f16 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 16 Jul 2026 17:11:05 +0200 Subject: [PATCH 02/14] Automatic chrome color generation --- .../lib/config/theme_configuration.dart | 262 ++++++++++++------ .../theme_studio/color_picker_tile.dart | 49 +++- .../theme_customization_panel.dart | 128 ++++++++- .../internal/stream_color_swatch_helper.dart | 12 +- .../src/theme/primitives/stream_colors.dart | 8 +- .../theme/semantics/stream_color_scheme.dart | 25 -- .../lib/src/theme/stream_theme.dart | 66 +++-- 7 files changed, 406 insertions(+), 144 deletions(-) diff --git a/apps/design_system_gallery/lib/config/theme_configuration.dart b/apps/design_system_gallery/lib/config/theme_configuration.dart index e70a7833..c4ea9965 100644 --- a/apps/design_system_gallery/lib/config/theme_configuration.dart +++ b/apps/design_system_gallery/lib/config/theme_configuration.dart @@ -300,6 +300,149 @@ class ThemeConfiguration extends ChangeNotifier { } } + // ========================================================================= + // Is Customized - whether a color has been overridden (vs. using the + // SDK default derived by [StreamColorScheme]). + // ========================================================================= + + // Brand & Chrome + bool get brandIsCustom => _brandPrimaryColor != null; + bool get chromeIsCustom => _chromePrimaryColor != null; + + // Accent + bool get accentPrimaryIsCustom => _accentPrimary != null; + bool get accentSuccessIsCustom => _accentSuccess != null; + bool get accentWarningIsCustom => _accentWarning != null; + bool get accentErrorIsCustom => _accentError != null; + bool get accentNeutralIsCustom => _accentNeutral != null; + + // Text + bool get textPrimaryIsCustom => _textPrimary != null; + bool get textSecondaryIsCustom => _textSecondary != null; + bool get textTertiaryIsCustom => _textTertiary != null; + bool get textDisabledIsCustom => _textDisabled != null; + bool get textLinkIsCustom => _textLink != null; + bool get textOnAccentIsCustom => _textOnAccent != null; + + // Background + bool get backgroundAppIsCustom => _backgroundApp != null; + bool get backgroundSurfaceIsCustom => _backgroundSurface != null; + bool get backgroundSurfaceSubtleIsCustom => _backgroundSurfaceSubtle != null; + bool get backgroundSurfaceStrongIsCustom => _backgroundSurfaceStrong != null; + bool get backgroundSurfaceCardIsCustom => _backgroundSurfaceCard != null; + bool get backgroundOnAccentIsCustom => _backgroundOnAccent != null; + bool get backgroundHighlightIsCustom => _backgroundHighlight != null; + bool get backgroundScrimIsCustom => _backgroundScrim != null; + bool get backgroundOverlayLightIsCustom => _backgroundOverlayLight != null; + bool get backgroundOverlayDarkIsCustom => _backgroundOverlayDark != null; + bool get backgroundDisabledIsCustom => _backgroundDisabled != null; + bool get backgroundHoverIsCustom => _backgroundHover != null; + bool get backgroundPressedIsCustom => _backgroundPressed != null; + bool get backgroundSelectedIsCustom => _backgroundSelected != null; + bool get backgroundInverseIsCustom => _backgroundInverse != null; + bool get backgroundElevation0IsCustom => _backgroundElevation0 != null; + bool get backgroundElevation1IsCustom => _backgroundElevation1 != null; + bool get backgroundElevation2IsCustom => _backgroundElevation2 != null; + bool get backgroundElevation3IsCustom => _backgroundElevation3 != null; + + // Border Core + bool get borderDefaultIsCustom => _borderDefault != null; + bool get borderSubtleIsCustom => _borderSubtle != null; + bool get borderStrongIsCustom => _borderStrong != null; + bool get borderOnAccentIsCustom => _borderOnAccent != null; + bool get borderOnSurfaceIsCustom => _borderOnSurface != null; + bool get borderOpacitySubtleIsCustom => _borderOpacitySubtle != null; + bool get borderOpacityStrongIsCustom => _borderOpacityStrong != null; + + // Border Utility + bool get borderFocusIsCustom => _borderFocus != null; + bool get borderDisabledIsCustom => _borderDisabled != null; + bool get borderHoverIsCustom => _borderHover != null; + bool get borderPressedIsCustom => _borderPressed != null; + bool get borderActiveIsCustom => _borderActive != null; + bool get borderErrorIsCustom => _borderError != null; + bool get borderWarningIsCustom => _borderWarning != null; + bool get borderSuccessIsCustom => _borderSuccess != null; + bool get borderSelectedIsCustom => _borderSelected != null; + + // System + bool get systemTextIsCustom => _systemText != null; + bool get systemScrollbarIsCustom => _systemScrollbar != null; + + // Avatar Palette + bool get avatarPaletteIsCustom => _avatarPalette != null; + + // ========================================================================= + // Per-field Reset - reverts a single color back to the SDK default. + // ========================================================================= + + // Brand & Chrome + void resetBrand() => _update(() => _brandPrimaryColor = null); + void resetChrome() => _update(() => _chromePrimaryColor = null); + + // Accent + void resetAccentPrimary() => _update(() => _accentPrimary = null); + void resetAccentSuccess() => _update(() => _accentSuccess = null); + void resetAccentWarning() => _update(() => _accentWarning = null); + void resetAccentError() => _update(() => _accentError = null); + void resetAccentNeutral() => _update(() => _accentNeutral = null); + + // Text + void resetTextPrimary() => _update(() => _textPrimary = null); + void resetTextSecondary() => _update(() => _textSecondary = null); + void resetTextTertiary() => _update(() => _textTertiary = null); + void resetTextDisabled() => _update(() => _textDisabled = null); + void resetTextLink() => _update(() => _textLink = null); + void resetTextOnAccent() => _update(() => _textOnAccent = null); + + // Background + void resetBackgroundApp() => _update(() => _backgroundApp = null); + void resetBackgroundSurface() => _update(() => _backgroundSurface = null); + void resetBackgroundSurfaceSubtle() => _update(() => _backgroundSurfaceSubtle = null); + void resetBackgroundSurfaceStrong() => _update(() => _backgroundSurfaceStrong = null); + void resetBackgroundSurfaceCard() => _update(() => _backgroundSurfaceCard = null); + void resetBackgroundOnAccent() => _update(() => _backgroundOnAccent = null); + void resetBackgroundHighlight() => _update(() => _backgroundHighlight = null); + void resetBackgroundScrim() => _update(() => _backgroundScrim = null); + void resetBackgroundOverlayLight() => _update(() => _backgroundOverlayLight = null); + void resetBackgroundOverlayDark() => _update(() => _backgroundOverlayDark = null); + void resetBackgroundDisabled() => _update(() => _backgroundDisabled = null); + void resetBackgroundHover() => _update(() => _backgroundHover = null); + void resetBackgroundPressed() => _update(() => _backgroundPressed = null); + void resetBackgroundSelected() => _update(() => _backgroundSelected = null); + void resetBackgroundInverse() => _update(() => _backgroundInverse = null); + void resetBackgroundElevation0() => _update(() => _backgroundElevation0 = null); + void resetBackgroundElevation1() => _update(() => _backgroundElevation1 = null); + void resetBackgroundElevation2() => _update(() => _backgroundElevation2 = null); + void resetBackgroundElevation3() => _update(() => _backgroundElevation3 = null); + + // Border Core + void resetBorderDefault() => _update(() => _borderDefault = null); + void resetBorderSubtle() => _update(() => _borderSubtle = null); + void resetBorderStrong() => _update(() => _borderStrong = null); + void resetBorderOnAccent() => _update(() => _borderOnAccent = null); + void resetBorderOnSurface() => _update(() => _borderOnSurface = null); + void resetBorderOpacitySubtle() => _update(() => _borderOpacitySubtle = null); + void resetBorderOpacityStrong() => _update(() => _borderOpacityStrong = null); + + // Border Utility + void resetBorderFocus() => _update(() => _borderFocus = null); + void resetBorderDisabled() => _update(() => _borderDisabled = null); + void resetBorderHover() => _update(() => _borderHover = null); + void resetBorderPressed() => _update(() => _borderPressed = null); + void resetBorderActive() => _update(() => _borderActive = null); + void resetBorderError() => _update(() => _borderError = null); + void resetBorderWarning() => _update(() => _borderWarning = null); + void resetBorderSuccess() => _update(() => _borderSuccess = null); + void resetBorderSelected() => _update(() => _borderSelected = null); + + // System + void resetSystemText() => _update(() => _systemText = null); + void resetSystemScrollbar() => _update(() => _systemScrollbar = null); + + // Avatar Palette + void resetAvatarPalette() => _update(() => _avatarPalette = null); + void _update(VoidCallback setter) { setter(); _rebuildTheme(); @@ -307,7 +450,6 @@ class ThemeConfiguration extends ChangeNotifier { } void resetToDefaults() { - _brandPrimaryColor = null; // Accent _accentPrimary = null; _accentSuccess = null; @@ -374,120 +516,78 @@ class ThemeConfiguration extends ChangeNotifier { } void _rebuildTheme() { - final baseColorScheme = _brightness == Brightness.dark ? StreamColorScheme.dark() : StreamColorScheme.light(); - final isDark = _brightness == Brightness.dark; - - // Compute effective brand swatch (if brand primary is customized) + // Brand swatch, if the brand ("primary") color is customized. final effectiveBrand = _brandPrimaryColor != null ? StreamColorSwatch.fromColor(_brandPrimaryColor!, brightness: _brightness) : null; - // Compute effective chrome swatch (if chrome primary is customized) + // Chrome swatch. Mirrors StreamTheme.light()/.dark(): an explicit chrome color wins; + // otherwise, when a brand color is set but chrome isn't, derive chrome from brand at + // low saturation so chrome-dependent colors still pick up the brand's hue. final effectiveChrome = _chromePrimaryColor != null ? StreamColorSwatch.fromColor(_chromePrimaryColor!, brightness: _brightness) + : _brandPrimaryColor != null + ? StreamColorSwatch.fromColor(_brandPrimaryColor!, brightness: _brightness, saturation: 0.1) : null; - // Derived from brand: accentPrimary defaults to brand.shade500 (light) / shade400 (dark) - final effectiveAccentPrimary = _accentPrimary ?? _brandPrimaryColor; - - // Derived from brand: borderFocus defaults to brand.shade150 - final effectiveBorderFocus = _borderFocus ?? effectiveBrand?.shade150; - - // Derived from accentPrimary: textLink and borderActive - final effectiveTextLink = _textLink ?? effectiveAccentPrimary; - final effectiveBorderActive = _borderActive ?? effectiveAccentPrimary; - - // Derived from other accents: border utility colors - final effectiveBorderError = _borderError ?? _accentError; - final effectiveBorderWarning = _borderWarning ?? _accentWarning; - final effectiveBorderSuccess = _borderSuccess ?? _accentSuccess; - - // Derived from chrome: all chrome-dependent semantic colors. - // When chrome is customized via copyWith, the base scheme still holds values - // derived from the default chrome, so we must re-derive all of them explicitly. - // Extract subscript lookups to avoid Dart parsing ambiguity with ?[] inside ternaries. - final chromeShade0 = effectiveChrome?[0]; - final chromeShade1000 = effectiveChrome?[1000]; - - final effectiveAccentNeutral = _accentNeutral ?? effectiveChrome?.shade500; - final effectiveTextPrimary = _textPrimary ?? effectiveChrome?.shade900; - final effectiveTextSecondary = _textSecondary ?? effectiveChrome?.shade700; - final effectiveTextTertiary = _textTertiary ?? effectiveChrome?.shade500; - final effectiveTextDisabled = _textDisabled ?? effectiveChrome?.shade300; - final effectiveTextOnAccent = _textOnAccent ?? chromeShade0; - final effectiveBackgroundSurface = _backgroundSurface ?? effectiveChrome?.shade100; - final effectiveBackgroundSurfaceSubtle = _backgroundSurfaceSubtle ?? effectiveChrome?.shade50; - final effectiveBackgroundSurfaceStrong = _backgroundSurfaceStrong ?? effectiveChrome?.shade150; - final effectiveBackgroundSurfaceCard = - _backgroundSurfaceCard ?? (isDark ? effectiveChrome?.shade100 : effectiveChrome?.shade50); - final effectiveBackgroundOnAccent = _backgroundOnAccent ?? chromeShade0; - final effectiveBackgroundDisabled = _backgroundDisabled ?? effectiveChrome?.shade100; - final effectiveBackgroundInverse = _backgroundInverse ?? effectiveChrome?.shade900; - final effectiveBackgroundElevation0 = _backgroundElevation0 ?? (isDark ? chromeShade1000 : chromeShade0); - final effectiveBackgroundElevation1 = _backgroundElevation1 ?? (isDark ? effectiveChrome?.shade50 : chromeShade0); - final effectiveBackgroundElevation2 = _backgroundElevation2 ?? (isDark ? effectiveChrome?.shade100 : chromeShade0); - final effectiveBackgroundElevation3 = _backgroundElevation3 ?? (isDark ? effectiveChrome?.shade200 : chromeShade0); - // backgroundApp derives from backgroundElevation0 in both themes - final effectiveBackgroundApp = _backgroundApp ?? effectiveBackgroundElevation0; - final effectiveBorderOnAccent = _borderOnAccent ?? chromeShade0; - final effectiveBorderOnSurface = _borderOnSurface ?? effectiveChrome?.shade300; - final effectiveBorderDisabled = _borderDisabled ?? effectiveChrome?.shade100; - - final colorScheme = baseColorScheme.copyWith( + // Every other override is passed through as-is: StreamColorScheme.light()/.dark() + // already treat null as "use the SDK default" and derive dependent colors internally. + final buildScheme = _brightness == Brightness.dark ? StreamColorScheme.dark : StreamColorScheme.light; + final colorScheme = buildScheme( // Brand brand: effectiveBrand, // Chrome chrome: effectiveChrome, // Accent - accentPrimary: effectiveAccentPrimary, + accentPrimary: _accentPrimary, accentSuccess: _accentSuccess, accentWarning: _accentWarning, accentError: _accentError, - accentNeutral: effectiveAccentNeutral, + accentNeutral: _accentNeutral, // Text - textPrimary: effectiveTextPrimary, - textSecondary: effectiveTextSecondary, - textTertiary: effectiveTextTertiary, - textDisabled: effectiveTextDisabled, - textLink: effectiveTextLink, - textOnAccent: effectiveTextOnAccent, + textPrimary: _textPrimary, + textSecondary: _textSecondary, + textTertiary: _textTertiary, + textDisabled: _textDisabled, + textLink: _textLink, + textOnAccent: _textOnAccent, // Background - backgroundApp: effectiveBackgroundApp, - backgroundSurface: effectiveBackgroundSurface, - backgroundSurfaceSubtle: effectiveBackgroundSurfaceSubtle, - backgroundSurfaceStrong: effectiveBackgroundSurfaceStrong, - backgroundSurfaceCard: effectiveBackgroundSurfaceCard, - backgroundOnAccent: effectiveBackgroundOnAccent, + backgroundApp: _backgroundApp, + backgroundSurface: _backgroundSurface, + backgroundSurfaceSubtle: _backgroundSurfaceSubtle, + backgroundSurfaceStrong: _backgroundSurfaceStrong, + backgroundSurfaceCard: _backgroundSurfaceCard, + backgroundOnAccent: _backgroundOnAccent, backgroundHighlight: _backgroundHighlight, backgroundScrim: _backgroundScrim, backgroundOverlayLight: _backgroundOverlayLight, backgroundOverlayDark: _backgroundOverlayDark, - backgroundDisabled: effectiveBackgroundDisabled, + backgroundDisabled: _backgroundDisabled, backgroundHover: _backgroundHover, backgroundPressed: _backgroundPressed, backgroundSelected: _backgroundSelected, - backgroundInverse: effectiveBackgroundInverse, - backgroundElevation0: effectiveBackgroundElevation0, - backgroundElevation1: effectiveBackgroundElevation1, - backgroundElevation2: effectiveBackgroundElevation2, - backgroundElevation3: effectiveBackgroundElevation3, + backgroundInverse: _backgroundInverse, + backgroundElevation0: _backgroundElevation0, + backgroundElevation1: _backgroundElevation1, + backgroundElevation2: _backgroundElevation2, + backgroundElevation3: _backgroundElevation3, // Border Core borderDefault: _borderDefault, borderSubtle: _borderSubtle, borderStrong: _borderStrong, - borderOnAccent: effectiveBorderOnAccent, - borderOnSurface: effectiveBorderOnSurface, + borderOnAccent: _borderOnAccent, + borderOnSurface: _borderOnSurface, borderOpacitySubtle: _borderOpacitySubtle, borderOpacityStrong: _borderOpacityStrong, // Border Utility - borderFocus: effectiveBorderFocus, - borderDisabled: effectiveBorderDisabled, + borderFocus: _borderFocus, + borderDisabled: _borderDisabled, borderHover: _borderHover, borderPressed: _borderPressed, - borderActive: effectiveBorderActive, - borderError: effectiveBorderError, - borderWarning: effectiveBorderWarning, - borderSuccess: effectiveBorderSuccess, + borderActive: _borderActive, + borderError: _borderError, + borderWarning: _borderWarning, + borderSuccess: _borderSuccess, borderSelected: _borderSelected, // System systemText: _systemText, diff --git a/apps/design_system_gallery/lib/widgets/theme_studio/color_picker_tile.dart b/apps/design_system_gallery/lib/widgets/theme_studio/color_picker_tile.dart index c6a6aa9d..711952be 100644 --- a/apps/design_system_gallery/lib/widgets/theme_studio/color_picker_tile.dart +++ b/apps/design_system_gallery/lib/widgets/theme_studio/color_picker_tile.dart @@ -9,12 +9,25 @@ class ColorPickerTile extends StatelessWidget { required this.label, required this.color, required this.onColorChanged, + this.isDefault = false, + this.onReset, }); final String label; final Color color; final ValueChanged onColorChanged; + /// Whether [color] is still the SDK default (i.e. not overridden). + /// + /// When true, a "default" subtitle is shown below [label] and no reset + /// control is displayed. + final bool isDefault; + + /// Reverts this color back to its SDK default. + /// + /// Ignored (no reset control shown) when [isDefault] is true. + final VoidCallback? onReset; + @override Widget build(BuildContext context) { final colorScheme = context.streamColorScheme; @@ -59,12 +72,25 @@ class ColorPickerTile extends StatelessWidget { ), SizedBox(width: spacing.sm + spacing.xxs), Expanded( - child: Text( - label, - style: textTheme.metadataDefault.copyWith( - color: colorScheme.textPrimary, - fontFamily: 'monospace', - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textPrimary, + fontFamily: 'monospace', + ), + ), + if (isDefault) + Text( + 'default', + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, + ), + ), + ], ), ), Text( @@ -74,6 +100,17 @@ class ColorPickerTile extends StatelessWidget { fontFamily: 'monospace', ), ), + if (!isDefault && onReset != null) ...[ + SizedBox(width: spacing.xs + spacing.xxs), + Tooltip( + message: 'Reset to default', + child: InkWell( + onTap: onReset, + borderRadius: BorderRadius.all(radius.xs), + child: Icon(Icons.restart_alt, color: colorScheme.textSecondary, size: 14), + ), + ), + ], SizedBox(width: spacing.xs + spacing.xxs), Icon(Icons.edit, color: colorScheme.textSecondary, size: 12), ], diff --git a/apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart b/apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart index bb526982..3bcce9bb 100644 --- a/apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart +++ b/apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart @@ -219,7 +219,9 @@ class _ThemeCustomizationPanelState extends State { child: ColorPickerTile( label: 'brandPrimary', color: config.brandPrimaryColor, + isDefault: !config.brandIsCustom, onColorChanged: config.setBrandPrimaryColor, + onReset: config.resetBrand, ), ); } @@ -233,7 +235,9 @@ class _ThemeCustomizationPanelState extends State { child: ColorPickerTile( label: 'chromePrimary', color: config.chromePrimaryColor, + isDefault: !config.chromeIsCustom, onColorChanged: config.setChromePrimaryColor, + onReset: config.resetChrome, ), ); } @@ -249,27 +253,37 @@ class _ThemeCustomizationPanelState extends State { ColorPickerTile( label: 'accentPrimary', color: config.accentPrimary, + isDefault: !config.accentPrimaryIsCustom, onColorChanged: config.setAccentPrimary, + onReset: config.resetAccentPrimary, ), ColorPickerTile( label: 'accentSuccess', color: config.accentSuccess, + isDefault: !config.accentSuccessIsCustom, onColorChanged: config.setAccentSuccess, + onReset: config.resetAccentSuccess, ), ColorPickerTile( label: 'accentWarning', color: config.accentWarning, + isDefault: !config.accentWarningIsCustom, onColorChanged: config.setAccentWarning, + onReset: config.resetAccentWarning, ), ColorPickerTile( label: 'accentError', color: config.accentError, + isDefault: !config.accentErrorIsCustom, onColorChanged: config.setAccentError, + onReset: config.resetAccentError, ), ColorPickerTile( label: 'accentNeutral', color: config.accentNeutral, + isDefault: !config.accentNeutralIsCustom, onColorChanged: config.setAccentNeutral, + onReset: config.resetAccentNeutral, ), ], ), @@ -287,32 +301,44 @@ class _ThemeCustomizationPanelState extends State { ColorPickerTile( label: 'textPrimary', color: config.textPrimary, + isDefault: !config.textPrimaryIsCustom, onColorChanged: config.setTextPrimary, + onReset: config.resetTextPrimary, ), ColorPickerTile( label: 'textSecondary', color: config.textSecondary, + isDefault: !config.textSecondaryIsCustom, onColorChanged: config.setTextSecondary, + onReset: config.resetTextSecondary, ), ColorPickerTile( label: 'textTertiary', color: config.textTertiary, + isDefault: !config.textTertiaryIsCustom, onColorChanged: config.setTextTertiary, + onReset: config.resetTextTertiary, ), ColorPickerTile( label: 'textDisabled', color: config.textDisabled, + isDefault: !config.textDisabledIsCustom, onColorChanged: config.setTextDisabled, + onReset: config.resetTextDisabled, ), ColorPickerTile( label: 'textLink', color: config.textLink, + isDefault: !config.textLinkIsCustom, onColorChanged: config.setTextLink, + onReset: config.resetTextLink, ), ColorPickerTile( label: 'textOnAccent', color: config.textOnAccent, + isDefault: !config.textOnAccentIsCustom, onColorChanged: config.setTextOnAccent, + onReset: config.resetTextOnAccent, ), ], ), @@ -332,57 +358,79 @@ class _ThemeCustomizationPanelState extends State { ColorPickerTile( label: 'backgroundApp', color: config.backgroundApp, + isDefault: !config.backgroundAppIsCustom, onColorChanged: config.setBackgroundApp, + onReset: config.resetBackgroundApp, ), ColorPickerTile( label: 'backgroundInverse', color: config.backgroundInverse, + isDefault: !config.backgroundInverseIsCustom, onColorChanged: config.setBackgroundInverse, + onReset: config.resetBackgroundInverse, ), ColorPickerTile( label: 'backgroundOnAccent', color: config.backgroundOnAccent, + isDefault: !config.backgroundOnAccentIsCustom, onColorChanged: config.setBackgroundOnAccent, + onReset: config.resetBackgroundOnAccent, ), ColorPickerTile( label: 'backgroundHighlight', color: config.backgroundHighlight, + isDefault: !config.backgroundHighlightIsCustom, onColorChanged: config.setBackgroundHighlight, + onReset: config.resetBackgroundHighlight, ), ColorPickerTile( label: 'backgroundScrim', color: config.backgroundScrim, + isDefault: !config.backgroundScrimIsCustom, onColorChanged: config.setBackgroundScrim, + onReset: config.resetBackgroundScrim, ), ColorPickerTile( label: 'backgroundOverlayLight', color: config.backgroundOverlayLight, + isDefault: !config.backgroundOverlayLightIsCustom, onColorChanged: config.setBackgroundOverlayLight, + onReset: config.resetBackgroundOverlayLight, ), ColorPickerTile( label: 'backgroundOverlayDark', color: config.backgroundOverlayDark, + isDefault: !config.backgroundOverlayDarkIsCustom, onColorChanged: config.setBackgroundOverlayDark, + onReset: config.resetBackgroundOverlayDark, ), ColorPickerTile( label: 'backgroundDisabled', color: config.backgroundDisabled, + isDefault: !config.backgroundDisabledIsCustom, onColorChanged: config.setBackgroundDisabled, + onReset: config.resetBackgroundDisabled, ), ColorPickerTile( label: 'backgroundHover', color: config.backgroundHover, + isDefault: !config.backgroundHoverIsCustom, onColorChanged: config.setBackgroundHover, + onReset: config.resetBackgroundHover, ), ColorPickerTile( label: 'backgroundPressed', color: config.backgroundPressed, + isDefault: !config.backgroundPressedIsCustom, onColorChanged: config.setBackgroundPressed, + onReset: config.resetBackgroundPressed, ), ColorPickerTile( label: 'backgroundSelected', color: config.backgroundSelected, + isDefault: !config.backgroundSelectedIsCustom, onColorChanged: config.setBackgroundSelected, + onReset: config.resetBackgroundSelected, ), SizedBox(height: spacing.xs), Text( @@ -395,22 +443,30 @@ class _ThemeCustomizationPanelState extends State { ColorPickerTile( label: 'backgroundSurface', color: config.backgroundSurface, + isDefault: !config.backgroundSurfaceIsCustom, onColorChanged: config.setBackgroundSurface, + onReset: config.resetBackgroundSurface, ), ColorPickerTile( label: 'backgroundSurfaceSubtle', color: config.backgroundSurfaceSubtle, + isDefault: !config.backgroundSurfaceSubtleIsCustom, onColorChanged: config.setBackgroundSurfaceSubtle, + onReset: config.resetBackgroundSurfaceSubtle, ), ColorPickerTile( label: 'backgroundSurfaceStrong', color: config.backgroundSurfaceStrong, + isDefault: !config.backgroundSurfaceStrongIsCustom, onColorChanged: config.setBackgroundSurfaceStrong, + onReset: config.resetBackgroundSurfaceStrong, ), ColorPickerTile( label: 'backgroundSurfaceCard', color: config.backgroundSurfaceCard, + isDefault: !config.backgroundSurfaceCardIsCustom, onColorChanged: config.setBackgroundSurfaceCard, + onReset: config.resetBackgroundSurfaceCard, ), SizedBox(height: spacing.xs), Text( @@ -423,27 +479,30 @@ class _ThemeCustomizationPanelState extends State { ColorPickerTile( label: 'backgroundElevation0', color: config.backgroundElevation0, + isDefault: !config.backgroundElevation0IsCustom, onColorChanged: config.setBackgroundElevation0, + onReset: config.resetBackgroundElevation0, ), ColorPickerTile( label: 'backgroundElevation1', color: config.backgroundElevation1, + isDefault: !config.backgroundElevation1IsCustom, onColorChanged: config.setBackgroundElevation1, + onReset: config.resetBackgroundElevation1, ), ColorPickerTile( label: 'backgroundElevation2', color: config.backgroundElevation2, + isDefault: !config.backgroundElevation2IsCustom, onColorChanged: config.setBackgroundElevation2, + onReset: config.resetBackgroundElevation2, ), ColorPickerTile( label: 'backgroundElevation3', color: config.backgroundElevation3, + isDefault: !config.backgroundElevation3IsCustom, onColorChanged: config.setBackgroundElevation3, - ), - ColorPickerTile( - label: 'backgroundHighlight', - color: config.backgroundHighlight, - onColorChanged: config.setBackgroundHighlight, + onReset: config.resetBackgroundElevation3, ), ], ), @@ -461,37 +520,51 @@ class _ThemeCustomizationPanelState extends State { ColorPickerTile( label: 'borderDefault', color: config.borderDefault, + isDefault: !config.borderDefaultIsCustom, onColorChanged: config.setBorderDefault, + onReset: config.resetBorderDefault, ), ColorPickerTile( label: 'borderSubtle', color: config.borderSubtle, + isDefault: !config.borderSubtleIsCustom, onColorChanged: config.setBorderSubtle, + onReset: config.resetBorderSubtle, ), ColorPickerTile( label: 'borderStrong', color: config.borderStrong, + isDefault: !config.borderStrongIsCustom, onColorChanged: config.setBorderStrong, + onReset: config.resetBorderStrong, ), ColorPickerTile( label: 'borderOnAccent', color: config.borderOnAccent, + isDefault: !config.borderOnAccentIsCustom, onColorChanged: config.setBorderOnAccent, + onReset: config.resetBorderOnAccent, ), ColorPickerTile( label: 'borderOnSurface', color: config.borderOnSurface, + isDefault: !config.borderOnSurfaceIsCustom, onColorChanged: config.setBorderOnSurface, + onReset: config.resetBorderOnSurface, ), ColorPickerTile( label: 'borderOpacitySubtle', color: config.borderOpacitySubtle, + isDefault: !config.borderOpacitySubtleIsCustom, onColorChanged: config.setBorderOpacitySubtle, + onReset: config.resetBorderOpacitySubtle, ), ColorPickerTile( label: 'borderOpacityStrong', color: config.borderOpacityStrong, + isDefault: !config.borderOpacityStrongIsCustom, onColorChanged: config.setBorderOpacityStrong, + onReset: config.resetBorderOpacityStrong, ), ], ), @@ -509,47 +582,65 @@ class _ThemeCustomizationPanelState extends State { ColorPickerTile( label: 'borderFocus', color: config.borderFocus, + isDefault: !config.borderFocusIsCustom, onColorChanged: config.setBorderFocus, + onReset: config.resetBorderFocus, ), ColorPickerTile( label: 'borderActive', color: config.borderActive, + isDefault: !config.borderActiveIsCustom, onColorChanged: config.setBorderActive, + onReset: config.resetBorderActive, ), ColorPickerTile( label: 'borderHover', color: config.borderHover, + isDefault: !config.borderHoverIsCustom, onColorChanged: config.setBorderHover, + onReset: config.resetBorderHover, ), ColorPickerTile( label: 'borderPressed', color: config.borderPressed, + isDefault: !config.borderPressedIsCustom, onColorChanged: config.setBorderPressed, + onReset: config.resetBorderPressed, ), ColorPickerTile( label: 'borderDisabled', color: config.borderDisabled, + isDefault: !config.borderDisabledIsCustom, onColorChanged: config.setBorderDisabled, + onReset: config.resetBorderDisabled, ), ColorPickerTile( label: 'borderError', color: config.borderError, + isDefault: !config.borderErrorIsCustom, onColorChanged: config.setBorderError, + onReset: config.resetBorderError, ), ColorPickerTile( label: 'borderWarning', color: config.borderWarning, + isDefault: !config.borderWarningIsCustom, onColorChanged: config.setBorderWarning, + onReset: config.resetBorderWarning, ), ColorPickerTile( label: 'borderSuccess', color: config.borderSuccess, + isDefault: !config.borderSuccessIsCustom, onColorChanged: config.setBorderSuccess, + onReset: config.resetBorderSuccess, ), ColorPickerTile( label: 'borderSelected', color: config.borderSelected, + isDefault: !config.borderSelectedIsCustom, onColorChanged: config.setBorderSelected, + onReset: config.resetBorderSelected, ), ], ), @@ -567,12 +658,16 @@ class _ThemeCustomizationPanelState extends State { ColorPickerTile( label: 'systemText', color: config.systemText, + isDefault: !config.systemTextIsCustom, onColorChanged: config.setSystemText, + onReset: config.resetSystemText, ), ColorPickerTile( label: 'systemScrollbar', color: config.systemScrollbar, + isDefault: !config.systemScrollbarIsCustom, onColorChanged: config.setSystemScrollbar, + onReset: config.resetSystemScrollbar, ), ], ), @@ -582,7 +677,10 @@ class _ThemeCustomizationPanelState extends State { Widget _buildAvatarPaletteSection(BuildContext context) { final config = context.read(); final palette = config.avatarPalette; + final isDefault = !config.avatarPaletteIsCustom; final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; return SectionCard( title: 'Avatar Palette', @@ -623,6 +721,26 @@ class _ThemeCustomizationPanelState extends State { config.addAvatarPaletteEntry(_generateRandomAvatarPair(isDark: isDark)); }, ), + if (!isDefault) ...[ + SizedBox(height: spacing.sm), + InkWell( + onTap: config.resetAvatarPalette, + borderRadius: BorderRadius.all(context.streamRadius.sm), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.restart_alt, color: colorScheme.textTertiary, size: 14), + SizedBox(width: spacing.xs), + Text( + 'Reset to default palette', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textTertiary, + ), + ), + ], + ), + ), + ], ], ), ); diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/internal/stream_color_swatch_helper.dart b/packages/stream_core_flutter/lib/src/theme/primitives/internal/stream_color_swatch_helper.dart index 6b80fbaa..e3084f58 100644 --- a/packages/stream_core_flutter/lib/src/theme/primitives/internal/stream_color_swatch_helper.dart +++ b/packages/stream_core_flutter/lib/src/theme/primitives/internal/stream_color_swatch_helper.dart @@ -15,11 +15,21 @@ class StreamColorSwatchHelper { /// When [brightness] is [Brightness.dark], the lightness values are inverted /// so that lower shade numbers (e.g., 50) are darker and higher shade numbers /// (e.g., 900) are lighter. + /// + /// The [saturation] parameter controls the saturation of the base color. + /// The default value is null, which means the saturation is not changed. + /// + /// The return value is a map of color shades, indexed by the shade number. static Map generateShadeMap( Color baseColor, { Brightness brightness = Brightness.light, + double? saturation, }) { - final hslBase = HSLColor.fromColor(baseColor); + var hslBase = HSLColor.fromColor(baseColor); + if (saturation != null) { + hslBase = hslBase.withSaturation(saturation); + } + final centerLightness = hslBase.lightness; final shades = [50, 100, 150, 200, 300, 400, 500, 600, 700, 800, 900]; diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/stream_colors.dart b/packages/stream_core_flutter/lib/src/theme/primitives/stream_colors.dart index 6c51b226..e92dca73 100644 --- a/packages/stream_core_flutter/lib/src/theme/primitives/stream_colors.dart +++ b/packages/stream_core_flutter/lib/src/theme/primitives/stream_colors.dart @@ -269,10 +269,14 @@ abstract final class StreamColors { class StreamColorSwatch extends ColorSwatch { const StreamColorSwatch(super.primary, super._swatch); - factory StreamColorSwatch.fromColor(Color color, {Brightness brightness = Brightness.light}) { + factory StreamColorSwatch.fromColor( + Color color, { + Brightness brightness = Brightness.light, + double? saturation, + }) { return StreamColorSwatch( color.toARGB32(), - StreamColorSwatchHelper.generateShadeMap(color, brightness: brightness), + StreamColorSwatchHelper.generateShadeMap(color, brightness: brightness, saturation: saturation), ); } diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart index 137e95d1..b7f0e192 100644 --- a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart @@ -484,31 +484,6 @@ class StreamColorScheme with _$StreamColorScheme { ); } - factory StreamColorScheme.fromColorScheme( - ColorScheme colorScheme, { - Brightness brightness = Brightness.light, - }) { - final brand = StreamColorSwatch.fromColor(colorScheme.primary, brightness: brightness); - final chrome = StreamColorSwatch.fromColor(colorScheme.surface, brightness: brightness); - - final base = brightness == Brightness.light - ? StreamColorScheme.light(brand: brand, chrome: chrome) - : StreamColorScheme.dark(brand: brand, chrome: chrome); - - return base.copyWith( - // Accent - accentError: colorScheme.error, - accentNeutral: colorScheme.secondary, - // // Text - textPrimary: colorScheme.onSurface, - textSecondary: colorScheme.onSurfaceVariant, - textTertiary: colorScheme.outline, - textLink: colorScheme.primary, - textOnAccent: colorScheme.onPrimary, - textOnInverse: colorScheme.onInverseSurface, - ); - } - const StreamColorScheme.raw({ required this.brand, required this.chrome, diff --git a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart index 0177dfb1..462b278e 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -253,37 +253,55 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { ); } - /// Creates a dark theme configuration. + /// Creates a light theme configuration. /// /// This is a convenience factory that calls [StreamTheme] with - /// [Brightness.dark]. - factory StreamTheme.dark({Color? brandColor, Color? chromeColor}) => StreamTheme( - brightness: .dark, - colorScheme: StreamColorScheme.dark( - brand: brandColor?.let( - (brandColor) => StreamColorSwatch.fromColor(brandColor, brightness: .dark), - ), - chrome: chromeColor?.let( - (chromeColor) => StreamColorSwatch.fromColor(chromeColor, brightness: .dark), + /// [Brightness.light]. + factory StreamTheme.light({Color? brandColor, Color? chromeColor}) { + final brand = brandColor?.let( + (brandColor) => StreamColorSwatch.fromColor(brandColor, brightness: .light), + ); + final chrome = + chromeColor?.let( + (chromeColor) => StreamColorSwatch.fromColor(chromeColor, brightness: .light), + ) ?? + brandColor?.let( + (brandColor) => StreamColorSwatch.fromColor(brandColor, brightness: .light, saturation: 0.1), + ); + + return StreamTheme( + brightness: .light, + colorScheme: StreamColorScheme.light( + brand: brand, + chrome: chrome, ), - ), - ); + ); + } - /// Creates a light theme configuration. + /// Creates a dark theme configuration. /// /// This is a convenience factory that calls [StreamTheme] with - /// [Brightness.light]. - factory StreamTheme.light({Color? brandColor, Color? chromeColor}) => StreamTheme( - brightness: .light, - colorScheme: StreamColorScheme.light( - brand: brandColor?.let( - (color) => StreamColorSwatch.fromColor(brandColor), - ), - chrome: chromeColor?.let( - (color) => StreamColorSwatch.fromColor(chromeColor), + /// [Brightness.dark]. + factory StreamTheme.dark({Color? brandColor, Color? chromeColor}) { + final brand = brandColor?.let( + (brandColor) => StreamColorSwatch.fromColor(brandColor, brightness: .dark), + ); + final chrome = + chromeColor?.let( + (chromeColor) => StreamColorSwatch.fromColor(chromeColor, brightness: .dark), + ) ?? + brandColor?.let( + (brandColor) => StreamColorSwatch.fromColor(brandColor, brightness: .dark, saturation: 0.1), + ); + + return StreamTheme( + brightness: .dark, + colorScheme: StreamColorScheme.dark( + brand: brand, + chrome: chrome, ), - ), - ); + ); + } const StreamTheme.raw({ required this.brightness, From 9be24e79615932e6027ea64452c58d3077ce4e74 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 17 Jul 2026 10:44:51 +0200 Subject: [PATCH 03/14] update changelog --- packages/stream_core_flutter/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/stream_core_flutter/CHANGELOG.md b/packages/stream_core_flutter/CHANGELOG.md index 80f0c538..04ae358d 100644 --- a/packages/stream_core_flutter/CHANGELOG.md +++ b/packages/stream_core_flutter/CHANGELOG.md @@ -10,6 +10,7 @@ - Added optional `semanticLabel` to `StreamBadgeNotification`, `StreamFileTypeIcon`, `StreamStepper`, `StreamMessageComposerAttachment`, and `StreamMessageComposerMediaAttachment`. - Added `excludeHeaderSemantics` to `StreamAppBar` and `StreamSheetHeader` for opting out of the default heading role and route naming on the title. - Added `onVisible` callback to `StreamSnackbar` — fires after the entrance animation completes (or synchronously when a screen reader is active). +- Added options for brand and chrome color customization to `StreamTheme.light` and `StreamTheme.dark`. ### 🐞 Fixed From aec4ef4cb54ebe96fece1894985dba445fa1b457 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 17 Jul 2026 11:02:50 +0200 Subject: [PATCH 04/14] Add tests for various seeds --- ...am_theme_color_generation_golden_test.dart | 90 ++++ .../stream_theme_color_generation_test.dart | 457 ++++++++++++++++++ 2 files changed, 547 insertions(+) create mode 100644 packages/stream_core_flutter/test/theme/stream_theme_color_generation_golden_test.dart create mode 100644 packages/stream_core_flutter/test/theme/stream_theme_color_generation_test.dart diff --git a/packages/stream_core_flutter/test/theme/stream_theme_color_generation_golden_test.dart b/packages/stream_core_flutter/test/theme/stream_theme_color_generation_golden_test.dart new file mode 100644 index 00000000..2a536612 --- /dev/null +++ b/packages/stream_core_flutter/test/theme/stream_theme_color_generation_golden_test.dart @@ -0,0 +1,90 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/core.dart'; + +/// The shades every generated [StreamColorSwatch] exposes (see +/// `StreamColorSwatchHelper.generateShadeMap`). +const _shades = [0, 50, 100, 150, 200, 300, 400, 500, 600, 700, 800, 900, 1000]; + +void main() { + group('StreamTheme color generation Golden Tests', () { + goldenTest( + 'renders the generated brand and chrome scales for custom seed colors', + fileName: 'stream_theme_color_generation', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: 1200), + children: [ + GoldenTestScenario( + name: 'deep orange seed', + child: const _SeedColorPreview(seedColor: Color(0xFFFF5722)), + ), + GoldenTestScenario( + name: 'purple seed', + child: const _SeedColorPreview(seedColor: Color(0xFF9C27B0)), + ), + GoldenTestScenario( + name: 'yellow seed', + child: const _SeedColorPreview(seedColor: Color(0xFFFFEB3B)), + ), + GoldenTestScenario( + name: 'green seed', + child: const _SeedColorPreview(seedColor: Color(0xFF4CAF50)), + ), + GoldenTestScenario( + name: 'blue seed', + child: const _SeedColorPreview(seedColor: Color(0xFF2196F3)), + ), + GoldenTestScenario( + name: 'red seed', + child: const _SeedColorPreview(seedColor: Color(0xFFF44336)), + ), + ], + ), + ); + }); +} + +/// Renders the [StreamTheme.light] and [StreamTheme.dark] brand/chrome +/// scales generated from a single [seedColor] as plain color swatches, +/// pinning the HSL-based shade generation in `StreamColorSwatchHelper` +/// against regressions. See `stream_theme_color_generation_test.dart` for +/// assertions on the exact generated values. +class _SeedColorPreview extends StatelessWidget { + const _SeedColorPreview({required this.seedColor}); + + final Color seedColor; + + @override + Widget build(BuildContext context) { + final light = StreamTheme.light(brandColor: seedColor).colorScheme; + final dark = StreamTheme.dark(brandColor: seedColor).colorScheme; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ColorScaleRow(swatch: light.brand), + _ColorScaleRow(swatch: light.chrome), + const SizedBox(height: 8), + _ColorScaleRow(swatch: dark.brand), + _ColorScaleRow(swatch: dark.chrome), + ], + ); + } +} + +class _ColorScaleRow extends StatelessWidget { + const _ColorScaleRow({required this.swatch}); + + final StreamColorSwatch swatch; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final shade in _shades) Container(width: 76, height: 56, color: swatch[shade]), + ], + ); + } +} diff --git a/packages/stream_core_flutter/test/theme/stream_theme_color_generation_test.dart b/packages/stream_core_flutter/test/theme/stream_theme_color_generation_test.dart new file mode 100644 index 00000000..d6f45098 --- /dev/null +++ b/packages/stream_core_flutter/test/theme/stream_theme_color_generation_test.dart @@ -0,0 +1,457 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/core.dart'; + +void main() { + group('StreamTheme.light color generation', () { + test('generates the exact brand scale for a deep orange seed', () { + final brand = StreamTheme.light(brandColor: const Color(0xFFFF5722)).colorScheme.brand; + + expect(brand[0], const Color(0xFFFFFFFF)); + expect(brand[50], const Color(0xFFFFECE5)); + expect(brand[100], const Color(0xFFFFDBD0)); + expect(brand[150], const Color(0xFFFFCBBA)); + expect(brand[200], const Color(0xFFFFBAA4)); + expect(brand[300], const Color(0xFFFF9979)); + expect(brand[400], const Color(0xFFFF784D)); + expect(brand[500], const Color(0xFFFF5722)); + expect(brand[600], const Color(0xFFE83800)); + expect(brand[700], const Color(0xFFAF2A00)); + expect(brand[800], const Color(0xFF761C00)); + expect(brand[900], const Color(0xFF3D0F00)); + expect(brand[1000], const Color(0xFF000000)); + }); + + test('generates the exact auto-derived chrome scale for a deep orange seed', () { + final chrome = StreamTheme.light(brandColor: const Color(0xFFFF5722)).colorScheme.chrome; + + expect(chrome[0], const Color(0xFFFFFFFF)); + expect(chrome[50], const Color(0xFFF4F2F1)); + expect(chrome[100], const Color(0xFFEAE6E5)); + expect(chrome[150], const Color(0xFFE0DBD9)); + expect(chrome[200], const Color(0xFFD6CFCD)); + expect(chrome[300], const Color(0xFFC3B8B5)); + expect(chrome[400], const Color(0xFFAFA29D)); + expect(chrome[500], const Color(0xFF9C8B85)); + expect(chrome[600], const Color(0xFF806E68)); + expect(chrome[700], const Color(0xFF60534F)); + expect(chrome[800], const Color(0xFF413835)); + expect(chrome[900], const Color(0xFF221D1C)); + expect(chrome[1000], const Color(0xFF000000)); + }); + + test('generates the exact brand scale for a purple seed', () { + final brand = StreamTheme.light(brandColor: const Color(0xFF9C27B0)).colorScheme.brand; + + expect(brand[0], const Color(0xFFFFFFFF)); + expect(brand[50], const Color(0xFFF8EAFA)); + expect(brand[100], const Color(0xFFF0D2F5)); + expect(brand[150], const Color(0xFFE8B9F0)); + expect(brand[200], const Color(0xFFDFA1EA)); + expect(brand[300], const Color(0xFFCF70DF)); + expect(brand[400], const Color(0xFFBE3FD4)); + expect(brand[500], const Color(0xFF9C27B0)); + expect(brand[600], const Color(0xFF802091)); + expect(brand[700], const Color(0xFF641971)); + expect(brand[800], const Color(0xFF481252)); + expect(brand[900], const Color(0xFF2C0B32)); + expect(brand[1000], const Color(0xFF000000)); + }); + + test('generates the exact auto-derived chrome scale for a purple seed', () { + final chrome = StreamTheme.light(brandColor: const Color(0xFF9C27B0)).colorScheme.chrome; + + expect(chrome[0], const Color(0xFFFFFFFF)); + expect(chrome[50], const Color(0xFFF3F1F4)); + expect(chrome[100], const Color(0xFFE5E1E6)); + expect(chrome[150], const Color(0xFFD7D0D9)); + expect(chrome[200], const Color(0xFFC9C0CB)); + expect(chrome[300], const Color(0xFFAE9FB0)); + expect(chrome[400], const Color(0xFF927E95)); + expect(chrome[500], const Color(0xFF736176)); + expect(chrome[600], const Color(0xFF5F4F61)); + expect(chrome[700], const Color(0xFF4A3E4C)); + expect(chrome[800], const Color(0xFF352D37)); + expect(chrome[900], const Color(0xFF211C22)); + expect(chrome[1000], const Color(0xFF000000)); + }); + + test('generates the exact brand scale for a yellow seed', () { + final brand = StreamTheme.light(brandColor: const Color(0xFFFFEB3B)).colorScheme.brand; + + expect(brand[0], const Color(0xFFFFFFFF)); + expect(brand[50], const Color(0xFFFFFCE5)); + expect(brand[100], const Color(0xFFFFFAD3)); + expect(brand[150], const Color(0xFFFFF9C0)); + expect(brand[200], const Color(0xFFFFF7AD)); + expect(brand[300], const Color(0xFFFFF387)); + expect(brand[400], const Color(0xFFFFEF61)); + expect(brand[500], const Color(0xFFFFEB3B)); + expect(brand[600], const Color(0xFFFBE100)); + expect(brand[700], const Color(0xFFBCA800)); + expect(brand[800], const Color(0xFF7C7000)); + expect(brand[900], const Color(0xFF3D3700)); + expect(brand[1000], const Color(0xFF000000)); + }); + + test('generates the exact auto-derived chrome scale for a yellow seed', () { + final chrome = StreamTheme.light(brandColor: const Color(0xFFFFEB3B)).colorScheme.chrome; + + expect(chrome[0], const Color(0xFFFFFFFF)); + expect(chrome[50], const Color(0xFFF4F3F1)); + expect(chrome[100], const Color(0xFFEBEBE7)); + expect(chrome[150], const Color(0xFFE2E2DC)); + expect(chrome[200], const Color(0xFFDAD9D2)); + expect(chrome[300], const Color(0xFFC9C8BD)); + expect(chrome[400], const Color(0xFFB8B6A8)); + expect(chrome[500], const Color(0xFFA7A593)); + expect(chrome[600], const Color(0xFF8A8771)); + expect(chrome[700], const Color(0xFF676554)); + expect(chrome[800], const Color(0xFF444338)); + expect(chrome[900], const Color(0xFF22211C)); + expect(chrome[1000], const Color(0xFF000000)); + }); + + test('generates the exact brand scale for a green seed', () { + final brand = StreamTheme.light(brandColor: const Color(0xFF4CAF50)).colorScheme.brand; + + expect(brand[0], const Color(0xFFFFFFFF)); + expect(brand[50], const Color(0xFFEDF7EE)); + expect(brand[100], const Color(0xFFDBEFDC)); + expect(brand[150], const Color(0xFFC9E8CA)); + expect(brand[200], const Color(0xFFB7E0B9)); + expect(brand[300], const Color(0xFF93D095)); + expect(brand[400], const Color(0xFF6FC072)); + expect(brand[500], const Color(0xFF4CAF50)); + expect(brand[600], const Color(0xFF3E8E41)); + expect(brand[700], const Color(0xFF2F6D32)); + expect(brand[800], const Color(0xFF214C23)); + expect(brand[900], const Color(0xFF132B14)); + expect(brand[1000], const Color(0xFF000000)); + }); + + test('generates the exact auto-derived chrome scale for a green seed', () { + final chrome = StreamTheme.light(brandColor: const Color(0xFF4CAF50)).colorScheme.chrome; + + expect(chrome[0], const Color(0xFFFFFFFF)); + expect(chrome[50], const Color(0xFFF1F4F1)); + expect(chrome[100], const Color(0xFFE3E8E3)); + expect(chrome[150], const Color(0xFFD4DCD5)); + expect(chrome[200], const Color(0xFFC6D1C7)); + expect(chrome[300], const Color(0xFFAAB9AA)); + expect(chrome[400], const Color(0xFF8DA28E)); + expect(chrome[500], const Color(0xFF718A72)); + expect(chrome[600], const Color(0xFF5C705C)); + expect(chrome[700], const Color(0xFF465647)); + expect(chrome[800], const Color(0xFF313C31)); + expect(chrome[900], const Color(0xFF1C221C)); + expect(chrome[1000], const Color(0xFF000000)); + }); + + test('generates the exact brand scale for a blue seed', () { + final brand = StreamTheme.light(brandColor: const Color(0xFF2196F3)).colorScheme.brand; + + expect(brand[0], const Color(0xFFFFFFFF)); + expect(brand[50], const Color(0xFFE7F4FE)); + expect(brand[100], const Color(0xFFD1E9FD)); + expect(brand[150], const Color(0xFFBBDFFB)); + expect(brand[200], const Color(0xFFA5D4FA)); + expect(brand[300], const Color(0xFF79C0F8)); + expect(brand[400], const Color(0xFF4DABF5)); + expect(brand[500], const Color(0xFF2196F3)); + expect(brand[600], const Color(0xFF0B7BD3)); + expect(brand[700], const Color(0xFF095DA0)); + expect(brand[800], const Color(0xFF063F6D)); + expect(brand[900], const Color(0xFF03223A)); + expect(brand[1000], const Color(0xFF000000)); + }); + + test('generates the exact auto-derived chrome scale for a blue seed', () { + final chrome = StreamTheme.light(brandColor: const Color(0xFF2196F3)).colorScheme.chrome; + + expect(chrome[0], const Color(0xFFFFFFFF)); + expect(chrome[50], const Color(0xFFF1F2F4)); + expect(chrome[100], const Color(0xFFE4E7E9)); + expect(chrome[150], const Color(0xFFD7DBDF)); + expect(chrome[200], const Color(0xFFCBD0D4)); + expect(chrome[300], const Color(0xFFB1B9BF)); + expect(chrome[400], const Color(0xFF98A2AB)); + expect(chrome[500], const Color(0xFF7E8B96)); + expect(chrome[600], const Color(0xFF64707A)); + expect(chrome[700], const Color(0xFF4C555D)); + expect(chrome[800], const Color(0xFF343A3F)); + expect(chrome[900], const Color(0xFF1C1F22)); + expect(chrome[1000], const Color(0xFF000000)); + }); + + test('generates the exact brand scale for a red seed', () { + final brand = StreamTheme.light(brandColor: const Color(0xFFF44336)).colorScheme.brand; + + expect(brand[0], const Color(0xFFFFFFFF)); + expect(brand[50], const Color(0xFFFEE8E7)); + expect(brand[100], const Color(0xFFFDD6D3)); + expect(brand[150], const Color(0xFFFCC4C0)); + expect(brand[200], const Color(0xFFFAB1AC)); + expect(brand[300], const Color(0xFFF88D85)); + expect(brand[400], const Color(0xFFF6685D)); + expect(brand[500], const Color(0xFFF44336)); + expect(brand[600], const Color(0xFFE21B0C)); + expect(brand[700], const Color(0xFFAA1409)); + expect(brand[800], const Color(0xFF720E06)); + expect(brand[900], const Color(0xFF3A0703)); + expect(brand[1000], const Color(0xFF000000)); + }); + + test('generates the exact auto-derived chrome scale for a red seed', () { + final chrome = StreamTheme.light(brandColor: const Color(0xFFF44336)).colorScheme.chrome; + + expect(chrome[0], const Color(0xFFFFFFFF)); + expect(chrome[50], const Color(0xFFF4F1F1)); + expect(chrome[100], const Color(0xFFEAE6E6)); + expect(chrome[150], const Color(0xFFE1DBDA)); + expect(chrome[200], const Color(0xFFD8CFCF)); + expect(chrome[300], const Color(0xFFC5B9B8)); + expect(chrome[400], const Color(0xFFB2A2A1)); + expect(chrome[500], const Color(0xFFA08C8A)); + expect(chrome[600], const Color(0xFF836D6B)); + expect(chrome[700], const Color(0xFF635251)); + expect(chrome[800], const Color(0xFF423736)); + expect(chrome[900], const Color(0xFF221C1C)); + expect(chrome[1000], const Color(0xFF000000)); + }); + + test('uses the seed color unchanged as brand shade 500', () { + const seed = Color(0xFF2196F3); + + final brand = StreamTheme.light(brandColor: seed).colorScheme.brand; + + expect(brand[500], seed); + }); + }); + + group('StreamTheme.dark color generation', () { + test('generates the exact brand scale for a deep orange seed', () { + final brand = StreamTheme.dark(brandColor: const Color(0xFFFF5722)).colorScheme.brand; + + expect(brand[0], const Color(0xFF000000)); + expect(brand[50], const Color(0xFF3D0F00)); + expect(brand[100], const Color(0xFF571500)); + expect(brand[150], const Color(0xFF701B00)); + expect(brand[200], const Color(0xFF892100)); + expect(brand[300], const Color(0xFFBC2D00)); + expect(brand[400], const Color(0xFFEE3900)); + expect(brand[500], const Color(0xFFFF5722)); + expect(brand[600], const Color(0xFFFF7C53)); + expect(brand[700], const Color(0xFFFFA184)); + expect(brand[800], const Color(0xFFFFC6B5)); + expect(brand[900], const Color(0xFFFFECE5)); + expect(brand[1000], const Color(0xFFFFFFFF)); + }); + + test('generates the exact auto-derived chrome scale for a deep orange seed', () { + final chrome = StreamTheme.dark(brandColor: const Color(0xFFFF5722)).colorScheme.chrome; + + expect(chrome[0], const Color(0xFF000000)); + expect(chrome[50], const Color(0xFF221D1C)); + expect(chrome[100], const Color(0xFF302927)); + expect(chrome[150], const Color(0xFF3E3532)); + expect(chrome[200], const Color(0xFF4B413E)); + expect(chrome[300], const Color(0xFF675954)); + expect(chrome[400], const Color(0xFF83716B)); + expect(chrome[500], const Color(0xFF9C8B85)); + expect(chrome[600], const Color(0xFFB2A4A0)); + expect(chrome[700], const Color(0xFFC8BEBB)); + expect(chrome[800], const Color(0xFFDED8D6)); + expect(chrome[900], const Color(0xFFF4F2F1)); + expect(chrome[1000], const Color(0xFFFFFFFF)); + }); + + test('generates the exact brand scale for a purple seed', () { + final brand = StreamTheme.dark(brandColor: const Color(0xFF9C27B0)).colorScheme.brand; + + expect(brand[0], const Color(0xFF000000)); + expect(brand[50], const Color(0xFF2C0B32)); + expect(brand[100], const Color(0xFF390E40)); + expect(brand[150], const Color(0xFF45114E)); + expect(brand[200], const Color(0xFF52145C)); + expect(brand[300], const Color(0xFF6A1B78)); + expect(brand[400], const Color(0xFF832194)); + expect(brand[500], const Color(0xFF9C27B0)); + expect(brand[600], const Color(0xFFC145D6)); + expect(brand[700], const Color(0xFFD37CE2)); + expect(brand[800], const Color(0xFFE6B3EE)); + expect(brand[900], const Color(0xFFF8EAFA)); + expect(brand[1000], const Color(0xFFFFFFFF)); + }); + + test('generates the exact auto-derived chrome scale for a purple seed', () { + final chrome = StreamTheme.dark(brandColor: const Color(0xFF9C27B0)).colorScheme.chrome; + + expect(chrome[0], const Color(0xFF000000)); + expect(chrome[50], const Color(0xFF211C22)); + expect(chrome[100], const Color(0xFF2A232B)); + expect(chrome[150], const Color(0xFF332B34)); + expect(chrome[200], const Color(0xFF3C333E)); + expect(chrome[300], const Color(0xFF4F4251)); + expect(chrome[400], const Color(0xFF615163)); + expect(chrome[500], const Color(0xFF736176)); + expect(chrome[600], const Color(0xFF958299)); + expect(chrome[700], const Color(0xFFB5A7B7)); + expect(chrome[800], const Color(0xFFD4CCD5)); + expect(chrome[900], const Color(0xFFF3F1F4)); + expect(chrome[1000], const Color(0xFFFFFFFF)); + }); + + test('generates the exact brand scale for a yellow seed', () { + final brand = StreamTheme.dark(brandColor: const Color(0xFFFFEB3B)).colorScheme.brand; + + expect(brand[0], const Color(0xFF000000)); + expect(brand[50], const Color(0xFF3D3700)); + expect(brand[100], const Color(0xFF595000)); + expect(brand[150], const Color(0xFF756900)); + expect(brand[200], const Color(0xFF918300)); + expect(brand[300], const Color(0xFFCAB500)); + expect(brand[400], const Color(0xFFFFE503)); + expect(brand[500], const Color(0xFFFFEB3B)); + expect(brand[600], const Color(0xFFFFEF66)); + expect(brand[700], const Color(0xFFFFF490)); + expect(brand[800], const Color(0xFFFFF8BB)); + expect(brand[900], const Color(0xFFFFFCE5)); + expect(brand[1000], const Color(0xFFFFFFFF)); + }); + + test('generates the exact auto-derived chrome scale for a yellow seed', () { + final chrome = StreamTheme.dark(brandColor: const Color(0xFFFFEB3B)).colorScheme.chrome; + + expect(chrome[0], const Color(0xFF000000)); + expect(chrome[50], const Color(0xFF22211C)); + expect(chrome[100], const Color(0xFF313028)); + expect(chrome[150], const Color(0xFF413F35)); + expect(chrome[200], const Color(0xFF504F41)); + expect(chrome[300], const Color(0xFF6F6D5B)); + expect(chrome[400], const Color(0xFF8E8B74)); + expect(chrome[500], const Color(0xFFA7A593)); + expect(chrome[600], const Color(0xFFBAB8AB)); + expect(chrome[700], const Color(0xFFCDCCC2)); + expect(chrome[800], const Color(0xFFE0E0DA)); + expect(chrome[900], const Color(0xFFF4F3F1)); + expect(chrome[1000], const Color(0xFFFFFFFF)); + }); + + test('generates the exact brand scale for a green seed', () { + final brand = StreamTheme.dark(brandColor: const Color(0xFF4CAF50)).colorScheme.brand; + + expect(brand[0], const Color(0xFF000000)); + expect(brand[50], const Color(0xFF132B14)); + expect(brand[100], const Color(0xFF19391A)); + expect(brand[150], const Color(0xFF1F4821)); + expect(brand[200], const Color(0xFF265728)); + expect(brand[300], const Color(0xFF327435)); + expect(brand[400], const Color(0xFF3F9243)); + expect(brand[500], const Color(0xFF4CAF50)); + expect(brand[600], const Color(0xFF73C276)); + expect(brand[700], const Color(0xFF9CD49E)); + expect(brand[800], const Color(0xFFC5E6C6)); + expect(brand[900], const Color(0xFFEDF7EE)); + expect(brand[1000], const Color(0xFFFFFFFF)); + }); + + test('generates the exact auto-derived chrome scale for a green seed', () { + final chrome = StreamTheme.dark(brandColor: const Color(0xFF4CAF50)).colorScheme.chrome; + + expect(chrome[0], const Color(0xFF000000)); + expect(chrome[50], const Color(0xFF1C221C)); + expect(chrome[100], const Color(0xFF252D25)); + expect(chrome[150], const Color(0xFF2F392F)); + expect(chrome[200], const Color(0xFF384439)); + expect(chrome[300], const Color(0xFF4B5C4C)); + expect(chrome[400], const Color(0xFF5E735F)); + expect(chrome[500], const Color(0xFF718A72)); + expect(chrome[600], const Color(0xFF91A591)); + expect(chrome[700], const Color(0xFFB1BFB1)); + expect(chrome[800], const Color(0xFFD1D9D1)); + expect(chrome[900], const Color(0xFFF1F4F1)); + expect(chrome[1000], const Color(0xFFFFFFFF)); + }); + + test('generates the exact brand scale for a blue seed', () { + final brand = StreamTheme.dark(brandColor: const Color(0xFF2196F3)).colorScheme.brand; + + expect(brand[0], const Color(0xFF000000)); + expect(brand[50], const Color(0xFF03223A)); + expect(brand[100], const Color(0xFF042F51)); + expect(brand[150], const Color(0xFF063C67)); + expect(brand[200], const Color(0xFF07497E)); + expect(brand[300], const Color(0xFF0964AB)); + expect(brand[400], const Color(0xFF0C7ED9)); + expect(brand[500], const Color(0xFF2196F3)); + expect(brand[600], const Color(0xFF52ADF6)); + expect(brand[700], const Color(0xFF84C5F8)); + expect(brand[800], const Color(0xFFB5DCFB)); + expect(brand[900], const Color(0xFFE7F4FE)); + expect(brand[1000], const Color(0xFFFFFFFF)); + }); + + test('generates the exact auto-derived chrome scale for a blue seed', () { + final chrome = StreamTheme.dark(brandColor: const Color(0xFF2196F3)).colorScheme.chrome; + + expect(chrome[0], const Color(0xFF000000)); + expect(chrome[50], const Color(0xFF1C1F22)); + expect(chrome[100], const Color(0xFF262B2F)); + expect(chrome[150], const Color(0xFF31373C)); + expect(chrome[200], const Color(0xFF3C4349)); + expect(chrome[300], const Color(0xFF515B63)); + expect(chrome[400], const Color(0xFF67737E)); + expect(chrome[500], const Color(0xFF7E8B96)); + expect(chrome[600], const Color(0xFF9BA5AD)); + expect(chrome[700], const Color(0xFFB8BFC5)); + expect(chrome[800], const Color(0xFFD4D9DC)); + expect(chrome[900], const Color(0xFFF1F2F4)); + expect(chrome[1000], const Color(0xFFFFFFFF)); + }); + + test('generates the exact brand scale for a red seed', () { + final brand = StreamTheme.dark(brandColor: const Color(0xFFF44336)).colorScheme.brand; + + expect(brand[0], const Color(0xFF000000)); + expect(brand[50], const Color(0xFF3A0703)); + expect(brand[100], const Color(0xFF530A05)); + expect(brand[150], const Color(0xFF6C0D06)); + expect(brand[200], const Color(0xFF851007)); + expect(brand[300], const Color(0xFFB7160A)); + expect(brand[400], const Color(0xFFE91C0D)); + expect(brand[500], const Color(0xFFF44336)); + expect(brand[600], const Color(0xFFF66C62)); + expect(brand[700], const Color(0xFFF9968E)); + expect(brand[800], const Color(0xFFFBBFBB)); + expect(brand[900], const Color(0xFFFEE8E7)); + expect(brand[1000], const Color(0xFFFFFFFF)); + }); + + test('generates the exact auto-derived chrome scale for a red seed', () { + final chrome = StreamTheme.dark(brandColor: const Color(0xFFF44336)).colorScheme.chrome; + + expect(chrome[0], const Color(0xFF000000)); + expect(chrome[50], const Color(0xFF221C1C)); + expect(chrome[100], const Color(0xFF302827)); + expect(chrome[150], const Color(0xFF3F3433)); + expect(chrome[200], const Color(0xFF4D403F)); + expect(chrome[300], const Color(0xFF6A5857)); + expect(chrome[400], const Color(0xFF87706E)); + expect(chrome[500], const Color(0xFFA08C8A)); + expect(chrome[600], const Color(0xFFB5A5A4)); + expect(chrome[700], const Color(0xFFCABEBE)); + expect(chrome[800], const Color(0xFFDFD8D7)); + expect(chrome[900], const Color(0xFFF4F1F1)); + expect(chrome[1000], const Color(0xFFFFFFFF)); + }); + + test('uses the seed color unchanged as brand shade 500', () { + const seed = Color(0xFF2196F3); + + final brand = StreamTheme.dark(brandColor: seed).colorScheme.brand; + + expect(brand[500], seed); + }); + }); +} From 4232a03d0a89bcaaf39d2d1b084f724323cd0514 Mon Sep 17 00:00:00 2001 From: renefloor <15101411+renefloor@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:04:23 +0000 Subject: [PATCH 05/14] chore: Update Goldens --- .../goldens/ci/stream_theme_color_generation.png | Bin 0 -> 9731 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 packages/stream_core_flutter/test/theme/goldens/ci/stream_theme_color_generation.png diff --git a/packages/stream_core_flutter/test/theme/goldens/ci/stream_theme_color_generation.png b/packages/stream_core_flutter/test/theme/goldens/ci/stream_theme_color_generation.png new file mode 100644 index 0000000000000000000000000000000000000000..7ac5830cdb1fd46df97bd3d77ebe47447e81ddf5 GIT binary patch literal 9731 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 Date: Fri, 17 Jul 2026 15:09:45 +0200 Subject: [PATCH 06/14] simplify stream theme construction --- .../theme/semantics/stream_color_scheme.dart | 26 +++++++++ .../lib/src/theme/stream_theme.dart | 54 ++++-------------- ...am_theme_color_generation_golden_test.dart | 6 +- .../stream_theme_color_generation_test.dart | 56 +++++++++---------- 4 files changed, 68 insertions(+), 74 deletions(-) diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart index b7f0e192..a6f4a266 100644 --- a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:stream_core/stream_core.dart' show Standard; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; import '../../theme/primitives/stream_colors.dart'; @@ -207,6 +208,7 @@ class StreamColorScheme with _$StreamColorScheme { ]; return .raw( + brightness: .light, brand: brand, chrome: chrome, accentPrimary: accentPrimary, @@ -427,6 +429,7 @@ class StreamColorScheme with _$StreamColorScheme { ]; return .raw( + brightness: .dark, brand: brand, chrome: chrome, accentPrimary: accentPrimary, @@ -484,7 +487,27 @@ class StreamColorScheme with _$StreamColorScheme { ); } + factory StreamColorScheme.fromSeed({ + required Color brand, + Color? chrome, + Brightness brightness = Brightness.light, + }) { + final colorScheme = brightness == Brightness.light ? StreamColorScheme.light : StreamColorScheme.dark; + return colorScheme( + brand: StreamColorSwatch.fromColor(brand, brightness: brightness), + chrome: + chrome?.let( + (chromeColor) => StreamColorSwatch.fromColor( + chromeColor, + brightness: brightness, + ), + ) ?? + StreamColorSwatch.fromColor(brand, brightness: brightness, saturation: 0.1), + ); + } + const StreamColorScheme.raw({ + this.brightness = Brightness.light, required this.brand, required this.chrome, // Accent @@ -550,6 +573,9 @@ class StreamColorScheme with _$StreamColorScheme { required this.avatarPalette, }); + /// The brightness of the color scheme. + final Brightness brightness; + // ---- Brand ---- /// The brand color swatch with shades from 50 to 950. diff --git a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart index 462b278e..46582697 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -106,7 +106,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// * [StreamTheme.light], which creates a light theme. /// * [StreamTheme.dark], which creates a dark theme. factory StreamTheme({ - Brightness brightness = .light, + Brightness? brightness, TargetPlatform? platform, StreamIcons? icons, StreamRadius? radius, @@ -153,8 +153,14 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { StreamStepperThemeData? stepperTheme, StreamSwitchThemeData? switchTheme, }) { + assert( + colorScheme == null || brightness == null || colorScheme.brightness == brightness, + 'colorScheme brightness must match theme brightness if provided', + ); + platform ??= defaultTargetPlatform; - final isDark = brightness == Brightness.dark; + final effectiveBrightness = brightness ?? colorScheme?.brightness ?? .light; + final isDark = effectiveBrightness == Brightness.dark; // Primitives icons ??= const StreamIcons(); @@ -206,7 +212,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { switchTheme ??= const StreamSwitchThemeData(); return .raw( - brightness: brightness, + brightness: effectiveBrightness, icons: icons, radius: radius, spacing: spacing, @@ -257,51 +263,13 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// /// This is a convenience factory that calls [StreamTheme] with /// [Brightness.light]. - factory StreamTheme.light({Color? brandColor, Color? chromeColor}) { - final brand = brandColor?.let( - (brandColor) => StreamColorSwatch.fromColor(brandColor, brightness: .light), - ); - final chrome = - chromeColor?.let( - (chromeColor) => StreamColorSwatch.fromColor(chromeColor, brightness: .light), - ) ?? - brandColor?.let( - (brandColor) => StreamColorSwatch.fromColor(brandColor, brightness: .light, saturation: 0.1), - ); - - return StreamTheme( - brightness: .light, - colorScheme: StreamColorScheme.light( - brand: brand, - chrome: chrome, - ), - ); - } + factory StreamTheme.light() => StreamTheme(brightness: .light); /// Creates a dark theme configuration. /// /// This is a convenience factory that calls [StreamTheme] with /// [Brightness.dark]. - factory StreamTheme.dark({Color? brandColor, Color? chromeColor}) { - final brand = brandColor?.let( - (brandColor) => StreamColorSwatch.fromColor(brandColor, brightness: .dark), - ); - final chrome = - chromeColor?.let( - (chromeColor) => StreamColorSwatch.fromColor(chromeColor, brightness: .dark), - ) ?? - brandColor?.let( - (brandColor) => StreamColorSwatch.fromColor(brandColor, brightness: .dark, saturation: 0.1), - ); - - return StreamTheme( - brightness: .dark, - colorScheme: StreamColorScheme.dark( - brand: brand, - chrome: chrome, - ), - ); - } + factory StreamTheme.dark() => StreamTheme(brightness: .dark); const StreamTheme.raw({ required this.brightness, diff --git a/packages/stream_core_flutter/test/theme/stream_theme_color_generation_golden_test.dart b/packages/stream_core_flutter/test/theme/stream_theme_color_generation_golden_test.dart index 2a536612..f45dbd1e 100644 --- a/packages/stream_core_flutter/test/theme/stream_theme_color_generation_golden_test.dart +++ b/packages/stream_core_flutter/test/theme/stream_theme_color_generation_golden_test.dart @@ -8,7 +8,7 @@ import 'package:stream_core_flutter/core.dart'; const _shades = [0, 50, 100, 150, 200, 300, 400, 500, 600, 700, 800, 900, 1000]; void main() { - group('StreamTheme color generation Golden Tests', () { + group('StreamColorScheme color generation Golden Tests', () { goldenTest( 'renders the generated brand and chrome scales for custom seed colors', fileName: 'stream_theme_color_generation', @@ -57,8 +57,8 @@ class _SeedColorPreview extends StatelessWidget { @override Widget build(BuildContext context) { - final light = StreamTheme.light(brandColor: seedColor).colorScheme; - final dark = StreamTheme.dark(brandColor: seedColor).colorScheme; + final light = StreamColorScheme.fromSeed(brand: seedColor); + final dark = StreamColorScheme.fromSeed(brand: seedColor, brightness: .dark); return Column( mainAxisSize: MainAxisSize.min, diff --git a/packages/stream_core_flutter/test/theme/stream_theme_color_generation_test.dart b/packages/stream_core_flutter/test/theme/stream_theme_color_generation_test.dart index d6f45098..3df77805 100644 --- a/packages/stream_core_flutter/test/theme/stream_theme_color_generation_test.dart +++ b/packages/stream_core_flutter/test/theme/stream_theme_color_generation_test.dart @@ -3,9 +3,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:stream_core_flutter/core.dart'; void main() { - group('StreamTheme.light color generation', () { + group('StreamColorScheme.fromSeed light color generation', () { test('generates the exact brand scale for a deep orange seed', () { - final brand = StreamTheme.light(brandColor: const Color(0xFFFF5722)).colorScheme.brand; + final brand = StreamColorScheme.fromSeed(brand: const Color(0xFFFF5722)).brand; expect(brand[0], const Color(0xFFFFFFFF)); expect(brand[50], const Color(0xFFFFECE5)); @@ -23,7 +23,7 @@ void main() { }); test('generates the exact auto-derived chrome scale for a deep orange seed', () { - final chrome = StreamTheme.light(brandColor: const Color(0xFFFF5722)).colorScheme.chrome; + final chrome = StreamColorScheme.fromSeed(brand: const Color(0xFFFF5722)).chrome; expect(chrome[0], const Color(0xFFFFFFFF)); expect(chrome[50], const Color(0xFFF4F2F1)); @@ -41,7 +41,7 @@ void main() { }); test('generates the exact brand scale for a purple seed', () { - final brand = StreamTheme.light(brandColor: const Color(0xFF9C27B0)).colorScheme.brand; + final brand = StreamColorScheme.fromSeed(brand: const Color(0xFF9C27B0)).brand; expect(brand[0], const Color(0xFFFFFFFF)); expect(brand[50], const Color(0xFFF8EAFA)); @@ -59,7 +59,7 @@ void main() { }); test('generates the exact auto-derived chrome scale for a purple seed', () { - final chrome = StreamTheme.light(brandColor: const Color(0xFF9C27B0)).colorScheme.chrome; + final chrome = StreamColorScheme.fromSeed(brand: const Color(0xFF9C27B0)).chrome; expect(chrome[0], const Color(0xFFFFFFFF)); expect(chrome[50], const Color(0xFFF3F1F4)); @@ -77,7 +77,7 @@ void main() { }); test('generates the exact brand scale for a yellow seed', () { - final brand = StreamTheme.light(brandColor: const Color(0xFFFFEB3B)).colorScheme.brand; + final brand = StreamColorScheme.fromSeed(brand: const Color(0xFFFFEB3B)).brand; expect(brand[0], const Color(0xFFFFFFFF)); expect(brand[50], const Color(0xFFFFFCE5)); @@ -95,7 +95,7 @@ void main() { }); test('generates the exact auto-derived chrome scale for a yellow seed', () { - final chrome = StreamTheme.light(brandColor: const Color(0xFFFFEB3B)).colorScheme.chrome; + final chrome = StreamColorScheme.fromSeed(brand: const Color(0xFFFFEB3B)).chrome; expect(chrome[0], const Color(0xFFFFFFFF)); expect(chrome[50], const Color(0xFFF4F3F1)); @@ -113,7 +113,7 @@ void main() { }); test('generates the exact brand scale for a green seed', () { - final brand = StreamTheme.light(brandColor: const Color(0xFF4CAF50)).colorScheme.brand; + final brand = StreamColorScheme.fromSeed(brand: const Color(0xFF4CAF50)).brand; expect(brand[0], const Color(0xFFFFFFFF)); expect(brand[50], const Color(0xFFEDF7EE)); @@ -131,7 +131,7 @@ void main() { }); test('generates the exact auto-derived chrome scale for a green seed', () { - final chrome = StreamTheme.light(brandColor: const Color(0xFF4CAF50)).colorScheme.chrome; + final chrome = StreamColorScheme.fromSeed(brand: const Color(0xFF4CAF50)).chrome; expect(chrome[0], const Color(0xFFFFFFFF)); expect(chrome[50], const Color(0xFFF1F4F1)); @@ -149,7 +149,7 @@ void main() { }); test('generates the exact brand scale for a blue seed', () { - final brand = StreamTheme.light(brandColor: const Color(0xFF2196F3)).colorScheme.brand; + final brand = StreamColorScheme.fromSeed(brand: const Color(0xFF2196F3)).brand; expect(brand[0], const Color(0xFFFFFFFF)); expect(brand[50], const Color(0xFFE7F4FE)); @@ -167,7 +167,7 @@ void main() { }); test('generates the exact auto-derived chrome scale for a blue seed', () { - final chrome = StreamTheme.light(brandColor: const Color(0xFF2196F3)).colorScheme.chrome; + final chrome = StreamColorScheme.fromSeed(brand: const Color(0xFF2196F3)).chrome; expect(chrome[0], const Color(0xFFFFFFFF)); expect(chrome[50], const Color(0xFFF1F2F4)); @@ -185,7 +185,7 @@ void main() { }); test('generates the exact brand scale for a red seed', () { - final brand = StreamTheme.light(brandColor: const Color(0xFFF44336)).colorScheme.brand; + final brand = StreamColorScheme.fromSeed(brand: const Color(0xFFF44336)).brand; expect(brand[0], const Color(0xFFFFFFFF)); expect(brand[50], const Color(0xFFFEE8E7)); @@ -203,7 +203,7 @@ void main() { }); test('generates the exact auto-derived chrome scale for a red seed', () { - final chrome = StreamTheme.light(brandColor: const Color(0xFFF44336)).colorScheme.chrome; + final chrome = StreamColorScheme.fromSeed(brand: const Color(0xFFF44336)).chrome; expect(chrome[0], const Color(0xFFFFFFFF)); expect(chrome[50], const Color(0xFFF4F1F1)); @@ -223,15 +223,15 @@ void main() { test('uses the seed color unchanged as brand shade 500', () { const seed = Color(0xFF2196F3); - final brand = StreamTheme.light(brandColor: seed).colorScheme.brand; + final brand = StreamColorScheme.fromSeed(brand: seed).brand; expect(brand[500], seed); }); }); - group('StreamTheme.dark color generation', () { + group('StreamColorScheme.fromSeed dark color generation', () { test('generates the exact brand scale for a deep orange seed', () { - final brand = StreamTheme.dark(brandColor: const Color(0xFFFF5722)).colorScheme.brand; + final brand = StreamColorScheme.fromSeed(brand: const Color(0xFFFF5722), brightness: .dark).brand; expect(brand[0], const Color(0xFF000000)); expect(brand[50], const Color(0xFF3D0F00)); @@ -249,7 +249,7 @@ void main() { }); test('generates the exact auto-derived chrome scale for a deep orange seed', () { - final chrome = StreamTheme.dark(brandColor: const Color(0xFFFF5722)).colorScheme.chrome; + final chrome = StreamColorScheme.fromSeed(brand: const Color(0xFFFF5722), brightness: .dark).chrome; expect(chrome[0], const Color(0xFF000000)); expect(chrome[50], const Color(0xFF221D1C)); @@ -267,7 +267,7 @@ void main() { }); test('generates the exact brand scale for a purple seed', () { - final brand = StreamTheme.dark(brandColor: const Color(0xFF9C27B0)).colorScheme.brand; + final brand = StreamColorScheme.fromSeed(brand: const Color(0xFF9C27B0), brightness: .dark).brand; expect(brand[0], const Color(0xFF000000)); expect(brand[50], const Color(0xFF2C0B32)); @@ -285,7 +285,7 @@ void main() { }); test('generates the exact auto-derived chrome scale for a purple seed', () { - final chrome = StreamTheme.dark(brandColor: const Color(0xFF9C27B0)).colorScheme.chrome; + final chrome = StreamColorScheme.fromSeed(brand: const Color(0xFF9C27B0), brightness: .dark).chrome; expect(chrome[0], const Color(0xFF000000)); expect(chrome[50], const Color(0xFF211C22)); @@ -303,7 +303,7 @@ void main() { }); test('generates the exact brand scale for a yellow seed', () { - final brand = StreamTheme.dark(brandColor: const Color(0xFFFFEB3B)).colorScheme.brand; + final brand = StreamColorScheme.fromSeed(brand: const Color(0xFFFFEB3B), brightness: .dark).brand; expect(brand[0], const Color(0xFF000000)); expect(brand[50], const Color(0xFF3D3700)); @@ -321,7 +321,7 @@ void main() { }); test('generates the exact auto-derived chrome scale for a yellow seed', () { - final chrome = StreamTheme.dark(brandColor: const Color(0xFFFFEB3B)).colorScheme.chrome; + final chrome = StreamColorScheme.fromSeed(brand: const Color(0xFFFFEB3B), brightness: .dark).chrome; expect(chrome[0], const Color(0xFF000000)); expect(chrome[50], const Color(0xFF22211C)); @@ -339,7 +339,7 @@ void main() { }); test('generates the exact brand scale for a green seed', () { - final brand = StreamTheme.dark(brandColor: const Color(0xFF4CAF50)).colorScheme.brand; + final brand = StreamColorScheme.fromSeed(brand: const Color(0xFF4CAF50), brightness: .dark).brand; expect(brand[0], const Color(0xFF000000)); expect(brand[50], const Color(0xFF132B14)); @@ -357,7 +357,7 @@ void main() { }); test('generates the exact auto-derived chrome scale for a green seed', () { - final chrome = StreamTheme.dark(brandColor: const Color(0xFF4CAF50)).colorScheme.chrome; + final chrome = StreamColorScheme.fromSeed(brand: const Color(0xFF4CAF50), brightness: .dark).chrome; expect(chrome[0], const Color(0xFF000000)); expect(chrome[50], const Color(0xFF1C221C)); @@ -375,7 +375,7 @@ void main() { }); test('generates the exact brand scale for a blue seed', () { - final brand = StreamTheme.dark(brandColor: const Color(0xFF2196F3)).colorScheme.brand; + final brand = StreamColorScheme.fromSeed(brand: const Color(0xFF2196F3), brightness: .dark).brand; expect(brand[0], const Color(0xFF000000)); expect(brand[50], const Color(0xFF03223A)); @@ -393,7 +393,7 @@ void main() { }); test('generates the exact auto-derived chrome scale for a blue seed', () { - final chrome = StreamTheme.dark(brandColor: const Color(0xFF2196F3)).colorScheme.chrome; + final chrome = StreamColorScheme.fromSeed(brand: const Color(0xFF2196F3), brightness: .dark).chrome; expect(chrome[0], const Color(0xFF000000)); expect(chrome[50], const Color(0xFF1C1F22)); @@ -411,7 +411,7 @@ void main() { }); test('generates the exact brand scale for a red seed', () { - final brand = StreamTheme.dark(brandColor: const Color(0xFFF44336)).colorScheme.brand; + final brand = StreamColorScheme.fromSeed(brand: const Color(0xFFF44336), brightness: .dark).brand; expect(brand[0], const Color(0xFF000000)); expect(brand[50], const Color(0xFF3A0703)); @@ -429,7 +429,7 @@ void main() { }); test('generates the exact auto-derived chrome scale for a red seed', () { - final chrome = StreamTheme.dark(brandColor: const Color(0xFFF44336)).colorScheme.chrome; + final chrome = StreamColorScheme.fromSeed(brand: const Color(0xFFF44336), brightness: .dark).chrome; expect(chrome[0], const Color(0xFF000000)); expect(chrome[50], const Color(0xFF221C1C)); @@ -449,7 +449,7 @@ void main() { test('uses the seed color unchanged as brand shade 500', () { const seed = Color(0xFF2196F3); - final brand = StreamTheme.dark(brandColor: seed).colorScheme.brand; + final brand = StreamColorScheme.fromSeed(brand: seed, brightness: .dark).brand; expect(brand[500], seed); }); From 75a581e5270e67344c2d82665073c2f9ecf07a44 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 17 Jul 2026 15:11:53 +0200 Subject: [PATCH 07/14] fix fromColor map with saturation --- .../lib/src/theme/primitives/stream_colors.dart | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/stream_colors.dart b/packages/stream_core_flutter/lib/src/theme/primitives/stream_colors.dart index e92dca73..4f5d4ae3 100644 --- a/packages/stream_core_flutter/lib/src/theme/primitives/stream_colors.dart +++ b/packages/stream_core_flutter/lib/src/theme/primitives/stream_colors.dart @@ -274,10 +274,8 @@ class StreamColorSwatch extends ColorSwatch { Brightness brightness = Brightness.light, double? saturation, }) { - return StreamColorSwatch( - color.toARGB32(), - StreamColorSwatchHelper.generateShadeMap(color, brightness: brightness, saturation: saturation), - ); + final shadeMap = StreamColorSwatchHelper.generateShadeMap(color, brightness: brightness, saturation: saturation); + return StreamColorSwatch(shadeMap[500]!.toARGB32(), shadeMap); } /// The lightest shade. From 9467acdcfa3fe255558afe537eb31cae915428b3 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 17 Jul 2026 15:24:27 +0200 Subject: [PATCH 08/14] switch contex.read to context.watch --- .../theme_customization_panel.dart | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart b/apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart index 3bcce9bb..3ce90aa8 100644 --- a/apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart +++ b/apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart @@ -179,7 +179,7 @@ class _ThemeCustomizationPanelState extends State { } Widget _buildAppearanceSection(BuildContext context) { - final config = context.read(); + final config = context.watch(); final spacing = context.streamSpacing; return SectionCard( @@ -211,7 +211,7 @@ class _ThemeCustomizationPanelState extends State { } Widget _buildBrandSection(BuildContext context) { - final config = context.read(); + final config = context.watch(); return SectionCard( title: 'Brand Color', subtitle: 'brand', @@ -227,7 +227,7 @@ class _ThemeCustomizationPanelState extends State { } Widget _buildChromeSection(BuildContext context) { - final config = context.read(); + final config = context.watch(); return SectionCard( title: 'Chrome Color', subtitle: 'chrome', @@ -243,7 +243,7 @@ class _ThemeCustomizationPanelState extends State { } Widget _buildAccentColorsSection(BuildContext context) { - final config = context.read(); + final config = context.watch(); return SectionCard( title: 'Accent Colors', subtitle: 'accent*', @@ -291,7 +291,7 @@ class _ThemeCustomizationPanelState extends State { } Widget _buildTextColorsSection(BuildContext context) { - final config = context.read(); + final config = context.watch(); return SectionCard( title: 'Text Colors', subtitle: 'text*', @@ -346,7 +346,7 @@ class _ThemeCustomizationPanelState extends State { } Widget _buildBackgroundColorsSection(BuildContext context) { - final config = context.read(); + final config = context.watch(); final spacing = context.streamSpacing; return SectionCard( title: 'Background Colors', @@ -510,7 +510,7 @@ class _ThemeCustomizationPanelState extends State { } Widget _buildBorderCoreSection(BuildContext context) { - final config = context.read(); + final config = context.watch(); return SectionCard( title: 'Border Colors - Core', subtitle: 'border*', @@ -572,7 +572,7 @@ class _ThemeCustomizationPanelState extends State { } Widget _buildBorderUtilitySection(BuildContext context) { - final config = context.read(); + final config = context.watch(); return SectionCard( title: 'Border Colors - Utility', subtitle: 'border*', @@ -648,7 +648,7 @@ class _ThemeCustomizationPanelState extends State { } Widget _buildSystemColorsSection(BuildContext context) { - final config = context.read(); + final config = context.watch(); return SectionCard( title: 'System Colors', subtitle: 'system*', @@ -675,7 +675,7 @@ class _ThemeCustomizationPanelState extends State { } Widget _buildAvatarPaletteSection(BuildContext context) { - final config = context.read(); + final config = context.watch(); final palette = config.avatarPalette; final isDefault = !config.avatarPaletteIsCustom; final spacing = context.streamSpacing; From 6a77ae7e4061875ad97d5d6956c861adb1b89ca3 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 28 Jul 2026 13:43:31 +0200 Subject: [PATCH 09/14] move brand reset --- .../lib/config/theme_configuration.dart | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/design_system_gallery/lib/config/theme_configuration.dart b/apps/design_system_gallery/lib/config/theme_configuration.dart index c4ea9965..2978433b 100644 --- a/apps/design_system_gallery/lib/config/theme_configuration.dart +++ b/apps/design_system_gallery/lib/config/theme_configuration.dart @@ -450,6 +450,11 @@ class ThemeConfiguration extends ChangeNotifier { } void resetToDefaults() { + // Brand + _brandPrimaryColor = null; + // Chrome + _chromePrimaryColor = null; + // Accent _accentPrimary = null; _accentSuccess = null; @@ -506,10 +511,6 @@ class ThemeConfiguration extends ChangeNotifier { _systemScrollbar = null; // Avatar _avatarPalette = null; - // Brand - _brandPrimaryColor = null; - // Chrome - _chromePrimaryColor = null; _rebuildTheme(); notifyListeners(); From 7d2f42877b34b40fb7103dff752606dd857db106 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 28 Jul 2026 13:52:35 +0200 Subject: [PATCH 10/14] make brightness getter to colorScheme --- packages/stream_core_flutter/lib/src/theme/stream_theme.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart index 46582697..ddce64b8 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -272,7 +272,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { factory StreamTheme.dark() => StreamTheme(brightness: .dark); const StreamTheme.raw({ - required this.brightness, + @Deprecated('Use colorScheme.brightness instead') Brightness? brightness, required this.icons, required this.radius, required this.spacing, @@ -348,7 +348,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { } /// The brightness of this theme. - final Brightness brightness; + Brightness get brightness => colorScheme.brightness; /// The icons for this theme. final StreamIcons icons; From c0654affbd0d629f360bc99f1483c4b0078b521d Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 28 Jul 2026 14:59:49 +0200 Subject: [PATCH 11/14] Move from HSL to HCT colors --- .../lib/config/theme_configuration.dart | 12 +- melos.yaml | 1 + packages/stream_core_flutter/CHANGELOG.md | 3 +- .../internal/stream_color_swatch_helper.dart | 175 +++-- .../src/theme/primitives/stream_colors.dart | 13 +- .../theme/semantics/stream_color_scheme.dart | 16 +- .../lib/src/theme/stream_theme.dart | 6 - packages/stream_core_flutter/pubspec.yaml | 1 + ...am_theme_color_generation_golden_test.dart | 12 +- .../stream_theme_color_generation_test.dart | 703 +++++++----------- 10 files changed, 432 insertions(+), 510 deletions(-) diff --git a/apps/design_system_gallery/lib/config/theme_configuration.dart b/apps/design_system_gallery/lib/config/theme_configuration.dart index 2978433b..733359e7 100644 --- a/apps/design_system_gallery/lib/config/theme_configuration.dart +++ b/apps/design_system_gallery/lib/config/theme_configuration.dart @@ -454,7 +454,7 @@ class ThemeConfiguration extends ChangeNotifier { _brandPrimaryColor = null; // Chrome _chromePrimaryColor = null; - + // Accent _accentPrimary = null; _accentSuccess = null; @@ -522,13 +522,17 @@ class ThemeConfiguration extends ChangeNotifier { ? StreamColorSwatch.fromColor(_brandPrimaryColor!, brightness: _brightness) : null; - // Chrome swatch. Mirrors StreamTheme.light()/.dark(): an explicit chrome color wins; + // Chrome swatch. Mirrors StreamColorScheme.fromSeed: an explicit chrome color wins; // otherwise, when a brand color is set but chrome isn't, derive chrome from brand at - // low saturation so chrome-dependent colors still pick up the brand's hue. + // neutral chroma so chrome-dependent colors still pick up the brand's hue. final effectiveChrome = _chromePrimaryColor != null ? StreamColorSwatch.fromColor(_chromePrimaryColor!, brightness: _brightness) : _brandPrimaryColor != null - ? StreamColorSwatch.fromColor(_brandPrimaryColor!, brightness: _brightness, saturation: 0.1) + ? StreamColorSwatch.fromColor( + _brandPrimaryColor!, + brightness: _brightness, + chroma: StreamColorScheme.neutralChroma, + ) : null; // Every other override is passed through as-is: StreamColorScheme.light()/.dark() diff --git a/melos.yaml b/melos.yaml index 725072b5..4154e4db 100644 --- a/melos.yaml +++ b/melos.yaml @@ -30,6 +30,7 @@ command: jose: ^0.3.4 json_annotation: ^4.9.0 markdown: ^7.3.0 + material_color_utilities: ^0.13.0 meta: ^1.15.0 mime: ^2.0.0 rxdart: ^0.28.0 diff --git a/packages/stream_core_flutter/CHANGELOG.md b/packages/stream_core_flutter/CHANGELOG.md index 04ae358d..77dae1a5 100644 --- a/packages/stream_core_flutter/CHANGELOG.md +++ b/packages/stream_core_flutter/CHANGELOG.md @@ -10,7 +10,8 @@ - Added optional `semanticLabel` to `StreamBadgeNotification`, `StreamFileTypeIcon`, `StreamStepper`, `StreamMessageComposerAttachment`, and `StreamMessageComposerMediaAttachment`. - Added `excludeHeaderSemantics` to `StreamAppBar` and `StreamSheetHeader` for opting out of the default heading role and route naming on the title. - Added `onVisible` callback to `StreamSnackbar` — fires after the entrance animation completes (or synchronously when a screen reader is active). -- Added options for brand and chrome color customization to `StreamTheme.light` and `StreamTheme.dark`. +- Added `StreamColorScheme.fromSeed` — builds a complete light or dark color scheme from a single brand color, optionally with a custom chrome color. When chrome is omitted it is derived from the brand hue at `StreamColorScheme.neutralChroma`. +- `StreamColorSwatch.fromColor` now generates shades in the HCT color space instead of HSL. Each shade takes its tone from a fixed ladder measured from the Stream design tokens, so a shade's contrast is predictable regardless of the seed's hue — seeding a light color such as yellow now yields an accent that can carry white text. Two consequences: the seed is no longer reproduced verbatim at shade 500 (it is normalized onto the ladder), and dark scales now mirror the ladder so the seed's tone lands on shade 300, matching the default dark palette. ### 🐞 Fixed diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/internal/stream_color_swatch_helper.dart b/packages/stream_core_flutter/lib/src/theme/primitives/internal/stream_color_swatch_helper.dart index e3084f58..74bb2628 100644 --- a/packages/stream_core_flutter/lib/src/theme/primitives/internal/stream_color_swatch_helper.dart +++ b/packages/stream_core_flutter/lib/src/theme/primitives/internal/stream_color_swatch_helper.dart @@ -1,100 +1,135 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; +import 'package:material_color_utilities/material_color_utilities.dart'; import '../stream_colors.dart'; class StreamColorSwatchHelper { const StreamColorSwatchHelper._(); - /// Generates a map of color shades based on the provided base colors. + /// Tone (CIE L*) assigned to each shade in light mode. /// - /// This internal method creates the shade variations using HSL color space - /// for more natural color transitions. The center shade (500) uses the original - /// color's lightness, and other shades are calculated proportionally. + /// These values were measured from the Figma design tokens in + /// `theme/primitives/internal/tokens/`. All four default scales (brand and chrome, + /// light and dark) sit on this single ladder within 3.3 tone, so generating against + /// it keeps a custom seed visually consistent with the shipped palette. /// - /// When [brightness] is [Brightness.dark], the lightness values are inverted - /// so that lower shade numbers (e.g., 50) are darker and higher shade numbers - /// (e.g., 900) are lighter. + /// Because tone is CIE L*, it maps directly onto WCAG relative luminance. A shade's + /// contrast against white or black is therefore fixed by its position on this ladder + /// and does not vary with hue — shade 500 always yields roughly 5.2:1 against white. + static const _toneLadder = { + 50: 97, + 100: 93.5, + 150: 86, + 200: 79, + 300: 69, + 400: 58, + 500: 46, + 600: 38, + 700: 29, + 800: 20.5, + 900: 10, + }; + + /// Chroma multiplier applied to each shade, as a fraction of the seed's own chroma. + /// + /// The default scales do not hold chroma constant: it peaks at shade 500 and tapers + /// toward both ends (the default brand scale runs 7.7 → 76.8 → 27.1). These ratios + /// are measured from that scale. + /// + /// The taper matters most at the dark end. Towards the light end, HCT already clamps + /// chroma to what is representable in sRGB at a given tone, which does most of the + /// work for a vivid seed; the explicit taper is what keeps a low-chroma seed — such + /// as a generated chrome scale — from reading as tinted in its lightest shades. + static const _chromaEnvelope = { + 50: 0.10, + 100: 0.18, + 150: 0.34, + 200: 0.48, + 300: 0.67, + 400: 0.85, + 500: 1, + 600: 0.78, + 700: 0.62, + 800: 0.48, + 900: 0.35, + }; + + /// Maps a shade onto the shade whose ladder position it takes in dark mode. + /// + /// The default dark scales are the light scales reversed — `dark[50..900]` equals + /// `light[900..50]` — so a dark scale ascends the same ladder. One consequence is + /// that the seed's own tone lands on shade 300 in dark mode rather than 500. + static const _darkMirror = { + 50: 900, + 100: 800, + 150: 700, + 200: 600, + 300: 500, + 400: 400, + 500: 300, + 600: 200, + 700: 150, + 800: 100, + 900: 50, + }; + + /// Generates a map of color shades based on the provided base color. + /// + /// Shades are generated in the HCT color space: the seed's hue is held constant + /// across the whole scale, each shade takes its tone from a fixed ladder measured + /// from the design tokens, and chroma is scaled by an envelope that peaks at the + /// seed's own chroma. This makes a shade's lightness — and therefore its contrast + /// against text — predictable regardless of which hue was seeded. + /// + /// Note that the seed is **not** reproduced verbatim at shade 500; it is normalized + /// onto the ladder's tone. Seeding a very light color such as yellow yields a shade + /// 500 dark enough to carry white text, which is what makes the accent slots usable + /// for any seed. /// - /// The [saturation] parameter controls the saturation of the base color. - /// The default value is null, which means the saturation is not changed. + /// When [brightness] is [Brightness.dark] the ladder is mirrored, so lower shade + /// numbers are darker and higher shade numbers are lighter, matching the default + /// dark scales. + /// + /// The [chroma] parameter overrides the seed's chroma, in HCT units. Pass a low + /// value to derive a near-neutral scale that still carries the seed's hue. The + /// default value is null, which means the seed's own chroma is used. /// /// The return value is a map of color shades, indexed by the shade number. static Map generateShadeMap( Color baseColor, { Brightness brightness = Brightness.light, - double? saturation, + double? chroma, }) { - var hslBase = HSLColor.fromColor(baseColor); - if (saturation != null) { - hslBase = hslBase.withSaturation(saturation); - } - - final centerLightness = hslBase.lightness; - - final shades = [50, 100, 150, 200, 300, 400, 500, 600, 700, 800, 900]; + final base = Hct.fromInt(baseColor.toARGB32()); + final baseChroma = chroma ?? base.chroma; final isLight = brightness == Brightness.light; return { 0: isLight ? StreamColors.white : StreamColors.black, - for (final shade in shades) - shade: _adjustLightness( - hslBase, - _calculateLightness(shade, centerLightness, brightness: brightness), + for (final shade in _toneLadder.keys) + shade: _shade( + hue: base.hue, + chroma: baseChroma, + shade: isLight ? shade : _darkMirror[shade]!, ), 1000: isLight ? StreamColors.black : StreamColors.white, }; } - /// Calculates the target lightness for a given shade number based on the center lightness. - /// - /// The formula maps shade numbers (50-900) to lightness values where: - /// - For [Brightness.light]: - /// - 50 is the lightest (0.95) - /// - 500 uses the center lightness (original color's lightness) - /// - 900 is the darkest (0.12) - /// - For [Brightness.dark]: - /// - 50 is the darkest (0.12) - /// - 500 uses the center lightness (original color's lightness) - /// - 900 is the lightest (0.95) + /// Builds the color for a single [shade], reading its tone and chroma multiplier + /// from the ladder and envelope. /// - /// For shades lighter than 500: lightness increases proportionally from center to 0.95 (light mode) - /// For shades darker than 500: lightness decreases proportionally from center to 0.12 (light mode) - /// In dark mode, these mappings are inverted. - static double _calculateLightness( - int shade, - double centerLightness, { - Brightness brightness = Brightness.light, + /// HCT clamps the requested chroma down to what is achievable in sRGB at the target + /// tone, so the tone is always honored exactly even when the hue cannot sustain that + /// much chroma. + static Color _shade({ + required double hue, + required double chroma, + required int shade, }) { - const minLightness = 0.12; // Darkest shade - const maxLightness = 0.95; // Lightest shade - const centerShade = 500; - - if (shade == centerShade) { - return centerLightness; - } - - final isDark = brightness == Brightness.dark; - - if (shade < centerShade) { - // For light mode: lighter shades (toward maxLightness) - // For dark mode: darker shades (toward minLightness) - final targetLightness = isDark ? minLightness : maxLightness; - final t = (shade - 50) / (centerShade - 50); - return targetLightness - (targetLightness - centerLightness) * t; - } else { - // For light mode: darker shades (toward minLightness) - // For dark mode: lighter shades (toward maxLightness) - final targetLightness = isDark ? maxLightness : minLightness; - final t = (shade - centerShade) / (900 - centerShade); - return centerLightness - (centerLightness - targetLightness) * t; - } - } - - /// Adjusts the lightness of an HSL color while maintaining its hue and saturation. - /// - /// [lightness] should be between 0.0 and 1.0. - static Color _adjustLightness(HSLColor hslColor, double lightness) { - return hslColor.withLightness(lightness.clamp(0.0, 1.0)).toColor(); + final tone = _toneLadder[shade]!; + final effectiveChroma = chroma * _chromaEnvelope[shade]!; + return Color(Hct.from(hue, effectiveChroma, tone).toInt()); } } diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/stream_colors.dart b/packages/stream_core_flutter/lib/src/theme/primitives/stream_colors.dart index 4f5d4ae3..53484e2d 100644 --- a/packages/stream_core_flutter/lib/src/theme/primitives/stream_colors.dart +++ b/packages/stream_core_flutter/lib/src/theme/primitives/stream_colors.dart @@ -269,12 +269,21 @@ abstract final class StreamColors { class StreamColorSwatch extends ColorSwatch { const StreamColorSwatch(super.primary, super._swatch); + /// Generates a full shade scale from a single seed [color]. + /// + /// Shades are derived in the HCT color space, holding the seed's hue constant while + /// stepping tone along a fixed ladder measured from the Stream design tokens. This + /// keeps each shade's contrast predictable across hues, but means [color] is + /// normalized onto the ladder rather than reproduced verbatim at shade 500. + /// + /// Pass [chroma] to override the seed's chroma, in HCT units — a low value yields a + /// near-neutral scale that still carries the seed's hue. factory StreamColorSwatch.fromColor( Color color, { Brightness brightness = Brightness.light, - double? saturation, + double? chroma, }) { - final shadeMap = StreamColorSwatchHelper.generateShadeMap(color, brightness: brightness, saturation: saturation); + final shadeMap = StreamColorSwatchHelper.generateShadeMap(color, brightness: brightness, chroma: chroma); return StreamColorSwatch(shadeMap[500]!.toARGB32(), shadeMap); } diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart index a6f4a266..3f8ad7d3 100644 --- a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart @@ -487,6 +487,13 @@ class StreamColorScheme with _$StreamColorScheme { ); } + /// Builds a complete color scheme from a single [brand] seed color. + /// + /// The brand and chrome scales are generated in HCT — see + /// [StreamColorSwatch.fromColor]. When [chrome] is omitted it is derived from + /// [brand] at [neutralChroma], so chrome-dependent colors still pick up the brand's + /// hue. Every semantic color not covered by those two scales falls back to the SDK + /// default for [brightness]. factory StreamColorScheme.fromSeed({ required Color brand, Color? chrome, @@ -502,7 +509,7 @@ class StreamColorScheme with _$StreamColorScheme { brightness: brightness, ), ) ?? - StreamColorSwatch.fromColor(brand, brightness: brightness, saturation: 0.1), + StreamColorSwatch.fromColor(brand, brightness: brightness, chroma: neutralChroma), ); } @@ -573,6 +580,13 @@ class StreamColorScheme with _$StreamColorScheme { required this.avatarPalette, }); + /// Chroma, in HCT units, used to derive a chrome scale from a brand color. + /// + /// Measured from the default chrome scale, whose chroma peaks at 15.9 on shade 500. + /// Low enough that the result reads as neutral, high enough that it still carries the + /// brand's hue rather than collapsing to grey. + static const neutralChroma = 16.0; + /// The brightness of the color scheme. final Brightness brightness; diff --git a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart index ddce64b8..7102a56c 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -1,8 +1,5 @@ -// ignore_for_file: avoid_redundant_argument_values - import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:stream_core/stream_core.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; import 'components/stream_app_bar_theme.dart'; @@ -41,7 +38,6 @@ import 'components/stream_snackbar_theme.dart'; import 'components/stream_stepper_theme.dart'; import 'components/stream_switch_theme.dart'; import 'components/stream_text_input_theme.dart'; -import 'primitives/stream_colors.dart'; import 'primitives/stream_icons.dart'; import 'primitives/stream_radius.dart'; import 'primitives/stream_spacing.dart'; @@ -212,7 +208,6 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { switchTheme ??= const StreamSwitchThemeData(); return .raw( - brightness: effectiveBrightness, icons: icons, radius: radius, spacing: spacing, @@ -506,7 +501,6 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { final newTextTheme = StreamTextTheme(typography: newTypography).apply(color: colorScheme.systemText); return StreamTheme.raw( - brightness: brightness, icons: icons, radius: radius, spacing: spacing, diff --git a/packages/stream_core_flutter/pubspec.yaml b/packages/stream_core_flutter/pubspec.yaml index a4340cc2..eb0cb5a1 100644 --- a/packages/stream_core_flutter/pubspec.yaml +++ b/packages/stream_core_flutter/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: flutter_markdown_plus: ^1.0.7 flutter_svg: ^2.2.3 markdown: ^7.3.0 + material_color_utilities: ^0.13.0 shimmer: ^3.0.0 stream_core: ^0.4.0 svg_icon_widget: ^0.0.1+1 diff --git a/packages/stream_core_flutter/test/theme/stream_theme_color_generation_golden_test.dart b/packages/stream_core_flutter/test/theme/stream_theme_color_generation_golden_test.dart index f45dbd1e..393c64e4 100644 --- a/packages/stream_core_flutter/test/theme/stream_theme_color_generation_golden_test.dart +++ b/packages/stream_core_flutter/test/theme/stream_theme_color_generation_golden_test.dart @@ -45,11 +45,13 @@ void main() { }); } -/// Renders the [StreamTheme.light] and [StreamTheme.dark] brand/chrome -/// scales generated from a single [seedColor] as plain color swatches, -/// pinning the HSL-based shade generation in `StreamColorSwatchHelper` -/// against regressions. See `stream_theme_color_generation_test.dart` for -/// assertions on the exact generated values. +/// Renders the light and dark brand/chrome scales generated from a single +/// [seedColor] as plain color swatches, pinning the HCT tone-ladder shade +/// generation in `StreamColorSwatchHelper` against regressions. +/// +/// Only color is painted here, so this golden cannot catch a contrast +/// regression — see `stream_theme_color_generation_test.dart` for the tone +/// ladder, contrast and hue assertions. class _SeedColorPreview extends StatelessWidget { const _SeedColorPreview({required this.seedColor}); diff --git a/packages/stream_core_flutter/test/theme/stream_theme_color_generation_test.dart b/packages/stream_core_flutter/test/theme/stream_theme_color_generation_test.dart index 3df77805..769790dc 100644 --- a/packages/stream_core_flutter/test/theme/stream_theme_color_generation_test.dart +++ b/packages/stream_core_flutter/test/theme/stream_theme_color_generation_test.dart @@ -1,457 +1,318 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_color_utilities/material_color_utilities.dart'; import 'package:stream_core_flutter/core.dart'; -void main() { - group('StreamColorScheme.fromSeed light color generation', () { - test('generates the exact brand scale for a deep orange seed', () { - final brand = StreamColorScheme.fromSeed(brand: const Color(0xFFFF5722)).brand; - - expect(brand[0], const Color(0xFFFFFFFF)); - expect(brand[50], const Color(0xFFFFECE5)); - expect(brand[100], const Color(0xFFFFDBD0)); - expect(brand[150], const Color(0xFFFFCBBA)); - expect(brand[200], const Color(0xFFFFBAA4)); - expect(brand[300], const Color(0xFFFF9979)); - expect(brand[400], const Color(0xFFFF784D)); - expect(brand[500], const Color(0xFFFF5722)); - expect(brand[600], const Color(0xFFE83800)); - expect(brand[700], const Color(0xFFAF2A00)); - expect(brand[800], const Color(0xFF761C00)); - expect(brand[900], const Color(0xFF3D0F00)); - expect(brand[1000], const Color(0xFF000000)); - }); - - test('generates the exact auto-derived chrome scale for a deep orange seed', () { - final chrome = StreamColorScheme.fromSeed(brand: const Color(0xFFFF5722)).chrome; - - expect(chrome[0], const Color(0xFFFFFFFF)); - expect(chrome[50], const Color(0xFFF4F2F1)); - expect(chrome[100], const Color(0xFFEAE6E5)); - expect(chrome[150], const Color(0xFFE0DBD9)); - expect(chrome[200], const Color(0xFFD6CFCD)); - expect(chrome[300], const Color(0xFFC3B8B5)); - expect(chrome[400], const Color(0xFFAFA29D)); - expect(chrome[500], const Color(0xFF9C8B85)); - expect(chrome[600], const Color(0xFF806E68)); - expect(chrome[700], const Color(0xFF60534F)); - expect(chrome[800], const Color(0xFF413835)); - expect(chrome[900], const Color(0xFF221D1C)); - expect(chrome[1000], const Color(0xFF000000)); - }); - - test('generates the exact brand scale for a purple seed', () { - final brand = StreamColorScheme.fromSeed(brand: const Color(0xFF9C27B0)).brand; - - expect(brand[0], const Color(0xFFFFFFFF)); - expect(brand[50], const Color(0xFFF8EAFA)); - expect(brand[100], const Color(0xFFF0D2F5)); - expect(brand[150], const Color(0xFFE8B9F0)); - expect(brand[200], const Color(0xFFDFA1EA)); - expect(brand[300], const Color(0xFFCF70DF)); - expect(brand[400], const Color(0xFFBE3FD4)); - expect(brand[500], const Color(0xFF9C27B0)); - expect(brand[600], const Color(0xFF802091)); - expect(brand[700], const Color(0xFF641971)); - expect(brand[800], const Color(0xFF481252)); - expect(brand[900], const Color(0xFF2C0B32)); - expect(brand[1000], const Color(0xFF000000)); - }); - - test('generates the exact auto-derived chrome scale for a purple seed', () { - final chrome = StreamColorScheme.fromSeed(brand: const Color(0xFF9C27B0)).chrome; - - expect(chrome[0], const Color(0xFFFFFFFF)); - expect(chrome[50], const Color(0xFFF3F1F4)); - expect(chrome[100], const Color(0xFFE5E1E6)); - expect(chrome[150], const Color(0xFFD7D0D9)); - expect(chrome[200], const Color(0xFFC9C0CB)); - expect(chrome[300], const Color(0xFFAE9FB0)); - expect(chrome[400], const Color(0xFF927E95)); - expect(chrome[500], const Color(0xFF736176)); - expect(chrome[600], const Color(0xFF5F4F61)); - expect(chrome[700], const Color(0xFF4A3E4C)); - expect(chrome[800], const Color(0xFF352D37)); - expect(chrome[900], const Color(0xFF211C22)); - expect(chrome[1000], const Color(0xFF000000)); - }); - - test('generates the exact brand scale for a yellow seed', () { - final brand = StreamColorScheme.fromSeed(brand: const Color(0xFFFFEB3B)).brand; - - expect(brand[0], const Color(0xFFFFFFFF)); - expect(brand[50], const Color(0xFFFFFCE5)); - expect(brand[100], const Color(0xFFFFFAD3)); - expect(brand[150], const Color(0xFFFFF9C0)); - expect(brand[200], const Color(0xFFFFF7AD)); - expect(brand[300], const Color(0xFFFFF387)); - expect(brand[400], const Color(0xFFFFEF61)); - expect(brand[500], const Color(0xFFFFEB3B)); - expect(brand[600], const Color(0xFFFBE100)); - expect(brand[700], const Color(0xFFBCA800)); - expect(brand[800], const Color(0xFF7C7000)); - expect(brand[900], const Color(0xFF3D3700)); - expect(brand[1000], const Color(0xFF000000)); - }); - - test('generates the exact auto-derived chrome scale for a yellow seed', () { - final chrome = StreamColorScheme.fromSeed(brand: const Color(0xFFFFEB3B)).chrome; - - expect(chrome[0], const Color(0xFFFFFFFF)); - expect(chrome[50], const Color(0xFFF4F3F1)); - expect(chrome[100], const Color(0xFFEBEBE7)); - expect(chrome[150], const Color(0xFFE2E2DC)); - expect(chrome[200], const Color(0xFFDAD9D2)); - expect(chrome[300], const Color(0xFFC9C8BD)); - expect(chrome[400], const Color(0xFFB8B6A8)); - expect(chrome[500], const Color(0xFFA7A593)); - expect(chrome[600], const Color(0xFF8A8771)); - expect(chrome[700], const Color(0xFF676554)); - expect(chrome[800], const Color(0xFF444338)); - expect(chrome[900], const Color(0xFF22211C)); - expect(chrome[1000], const Color(0xFF000000)); - }); - - test('generates the exact brand scale for a green seed', () { - final brand = StreamColorScheme.fromSeed(brand: const Color(0xFF4CAF50)).brand; - - expect(brand[0], const Color(0xFFFFFFFF)); - expect(brand[50], const Color(0xFFEDF7EE)); - expect(brand[100], const Color(0xFFDBEFDC)); - expect(brand[150], const Color(0xFFC9E8CA)); - expect(brand[200], const Color(0xFFB7E0B9)); - expect(brand[300], const Color(0xFF93D095)); - expect(brand[400], const Color(0xFF6FC072)); - expect(brand[500], const Color(0xFF4CAF50)); - expect(brand[600], const Color(0xFF3E8E41)); - expect(brand[700], const Color(0xFF2F6D32)); - expect(brand[800], const Color(0xFF214C23)); - expect(brand[900], const Color(0xFF132B14)); - expect(brand[1000], const Color(0xFF000000)); - }); - - test('generates the exact auto-derived chrome scale for a green seed', () { - final chrome = StreamColorScheme.fromSeed(brand: const Color(0xFF4CAF50)).chrome; - - expect(chrome[0], const Color(0xFFFFFFFF)); - expect(chrome[50], const Color(0xFFF1F4F1)); - expect(chrome[100], const Color(0xFFE3E8E3)); - expect(chrome[150], const Color(0xFFD4DCD5)); - expect(chrome[200], const Color(0xFFC6D1C7)); - expect(chrome[300], const Color(0xFFAAB9AA)); - expect(chrome[400], const Color(0xFF8DA28E)); - expect(chrome[500], const Color(0xFF718A72)); - expect(chrome[600], const Color(0xFF5C705C)); - expect(chrome[700], const Color(0xFF465647)); - expect(chrome[800], const Color(0xFF313C31)); - expect(chrome[900], const Color(0xFF1C221C)); - expect(chrome[1000], const Color(0xFF000000)); - }); +/// Every shade a generated [StreamColorSwatch] exposes. +const _shades = [0, 50, 100, 150, 200, 300, 400, 500, 600, 700, 800, 900, 1000]; + +/// The tone (CIE L*) each shade is expected to land on in light mode. Mirrored in dark +/// mode. Kept in sync with `StreamColorSwatchHelper._toneLadder`. +const _expectedTones = { + 50: 97.0, + 100: 93.5, + 150: 86.0, + 200: 79.0, + 300: 69.0, + 400: 58.0, + 500: 46.0, + 600: 38.0, + 700: 29.0, + 800: 20.5, + 900: 10.0, +}; + +/// Seeds spanning the hue circle, including the two pathological cases for HSL-based +/// generation: yellow (very light at full saturation) and purple (very dark). +const _seeds = { + 'deep orange': Color(0xFFFF5722), + 'blue': Color(0xFF2196F3), + 'green': Color(0xFF4CAF50), + 'purple': Color(0xFF9C27B0), + 'red': Color(0xFFF44336), + 'yellow': Color(0xFFFFEB3B), +}; + +/// The Stream brand color, which the default token palette is built from. +const _defaultSeed = Color(0xFF005FFF); + +double _toneOf(Color color) => Hct.fromInt(color.toARGB32()).tone; + +double _hueOf(Color color) => Hct.fromInt(color.toARGB32()).hue; + +double _chromaOf(Color color) => Hct.fromInt(color.toARGB32()).chroma; + +/// WCAG 2.1 contrast ratio between two opaque colors. +double _contrast(Color a, Color b) { + final la = a.computeLuminance(); + final lb = b.computeLuminance(); + final (hi, lo) = la > lb ? (la, lb) : (lb, la); + return (hi + 0.05) / (lo + 0.05); +} - test('generates the exact brand scale for a blue seed', () { - final brand = StreamColorScheme.fromSeed(brand: const Color(0xFF2196F3)).brand; - - expect(brand[0], const Color(0xFFFFFFFF)); - expect(brand[50], const Color(0xFFE7F4FE)); - expect(brand[100], const Color(0xFFD1E9FD)); - expect(brand[150], const Color(0xFFBBDFFB)); - expect(brand[200], const Color(0xFFA5D4FA)); - expect(brand[300], const Color(0xFF79C0F8)); - expect(brand[400], const Color(0xFF4DABF5)); - expect(brand[500], const Color(0xFF2196F3)); - expect(brand[600], const Color(0xFF0B7BD3)); - expect(brand[700], const Color(0xFF095DA0)); - expect(brand[800], const Color(0xFF063F6D)); - expect(brand[900], const Color(0xFF03223A)); - expect(brand[1000], const Color(0xFF000000)); +void main() { + group('StreamColorScheme.fromSeed exact values', () { + // A full snapshot for the default seed. The remaining seeds are covered by the + // property tests below and by the golden test, which is a more useful regression + // net than transcribing every hue's hex values. + test('generates the expected light scales for the Stream brand seed', () { + final scheme = StreamColorScheme.fromSeed(brand: _defaultSeed); + + expect(scheme.brand[0], const Color(0xFFFFFFFF)); + expect(scheme.brand[50], const Color(0xFFF6F5FF)); + expect(scheme.brand[100], const Color(0xFFE9EBFF)); + expect(scheme.brand[150], const Color(0xFFCCD6FF)); + expect(scheme.brand[200], const Color(0xFFB1C2FF)); + expect(scheme.brand[300], const Color(0xFF89A5FF)); + expect(scheme.brand[400], const Color(0xFF5984FF)); + expect(scheme.brand[500], const Color(0xFF005FFE)); + expect(scheme.brand[600], const Color(0xFF2651BE)); + expect(scheme.brand[700], const Color(0xFF223F8C)); + expect(scheme.brand[800], const Color(0xFF1B2E63)); + expect(scheme.brand[900], const Color(0xFF0E1A3B)); + expect(scheme.brand[1000], const Color(0xFF000000)); + + expect(scheme.chrome[0], const Color(0xFFFFFFFF)); + expect(scheme.chrome[50], const Color(0xFFF9F5F7)); + expect(scheme.chrome[100], const Color(0xFFEEEBEE)); + expect(scheme.chrome[150], const Color(0xFFD8D6DD)); + expect(scheme.chrome[200], const Color(0xFFC3C3CD)); + expect(scheme.chrome[300], const Color(0xFFA6A8B6)); + expect(scheme.chrome[400], const Color(0xFF878B9C)); + expect(scheme.chrome[500], const Color(0xFF676C81)); + expect(scheme.chrome[600], const Color(0xFF565968)); + expect(scheme.chrome[700], const Color(0xFF42444F)); + expect(scheme.chrome[800], const Color(0xFF303139)); + expect(scheme.chrome[900], const Color(0xFF1A1B20)); + expect(scheme.chrome[1000], const Color(0xFF000000)); }); - test('generates the exact auto-derived chrome scale for a blue seed', () { - final chrome = StreamColorScheme.fromSeed(brand: const Color(0xFF2196F3)).chrome; - - expect(chrome[0], const Color(0xFFFFFFFF)); - expect(chrome[50], const Color(0xFFF1F2F4)); - expect(chrome[100], const Color(0xFFE4E7E9)); - expect(chrome[150], const Color(0xFFD7DBDF)); - expect(chrome[200], const Color(0xFFCBD0D4)); - expect(chrome[300], const Color(0xFFB1B9BF)); - expect(chrome[400], const Color(0xFF98A2AB)); - expect(chrome[500], const Color(0xFF7E8B96)); - expect(chrome[600], const Color(0xFF64707A)); - expect(chrome[700], const Color(0xFF4C555D)); - expect(chrome[800], const Color(0xFF343A3F)); - expect(chrome[900], const Color(0xFF1C1F22)); - expect(chrome[1000], const Color(0xFF000000)); + test('generates the expected dark scales for the Stream brand seed', () { + final scheme = StreamColorScheme.fromSeed(brand: _defaultSeed, brightness: .dark); + + expect(scheme.brand[0], const Color(0xFF000000)); + expect(scheme.brand[50], const Color(0xFF0E1A3B)); + expect(scheme.brand[100], const Color(0xFF1B2E63)); + expect(scheme.brand[150], const Color(0xFF223F8C)); + expect(scheme.brand[200], const Color(0xFF2651BE)); + expect(scheme.brand[300], const Color(0xFF005FFE)); + expect(scheme.brand[400], const Color(0xFF5984FF)); + expect(scheme.brand[500], const Color(0xFF89A5FF)); + expect(scheme.brand[600], const Color(0xFFB1C2FF)); + expect(scheme.brand[700], const Color(0xFFCCD6FF)); + expect(scheme.brand[800], const Color(0xFFE9EBFF)); + expect(scheme.brand[900], const Color(0xFFF6F5FF)); + expect(scheme.brand[1000], const Color(0xFFFFFFFF)); + + expect(scheme.chrome[0], const Color(0xFF000000)); + expect(scheme.chrome[50], const Color(0xFF1A1B20)); + expect(scheme.chrome[900], const Color(0xFFF9F5F7)); + expect(scheme.chrome[1000], const Color(0xFFFFFFFF)); }); + }); - test('generates the exact brand scale for a red seed', () { - final brand = StreamColorScheme.fromSeed(brand: const Color(0xFFF44336)).brand; - - expect(brand[0], const Color(0xFFFFFFFF)); - expect(brand[50], const Color(0xFFFEE8E7)); - expect(brand[100], const Color(0xFFFDD6D3)); - expect(brand[150], const Color(0xFFFCC4C0)); - expect(brand[200], const Color(0xFFFAB1AC)); - expect(brand[300], const Color(0xFFF88D85)); - expect(brand[400], const Color(0xFFF6685D)); - expect(brand[500], const Color(0xFFF44336)); - expect(brand[600], const Color(0xFFE21B0C)); - expect(brand[700], const Color(0xFFAA1409)); - expect(brand[800], const Color(0xFF720E06)); - expect(brand[900], const Color(0xFF3A0703)); - expect(brand[1000], const Color(0xFF000000)); + group('StreamColorScheme.fromSeed reproduces the default palette', () { + // The payoff for generating in HCT: seeding the brand color the design tokens were + // built from lands back on those tokens. If this drifts, the tone ladder or chroma + // envelope in StreamColorSwatchHelper no longer matches Figma. + test('lands within 2 tone of the default light brand scale', () { + final generated = StreamColorScheme.fromSeed(brand: _defaultSeed).brand; + final tokens = StreamBrandColor.light(); + + for (final shade in _expectedTones.keys) { + expect( + _toneOf(generated[shade]!), + closeTo(_toneOf(tokens[shade]!), 2), + reason: 'brand shade $shade tone drifted from the design token', + ); + } }); - test('generates the exact auto-derived chrome scale for a red seed', () { - final chrome = StreamColorScheme.fromSeed(brand: const Color(0xFFF44336)).chrome; - - expect(chrome[0], const Color(0xFFFFFFFF)); - expect(chrome[50], const Color(0xFFF4F1F1)); - expect(chrome[100], const Color(0xFFEAE6E6)); - expect(chrome[150], const Color(0xFFE1DBDA)); - expect(chrome[200], const Color(0xFFD8CFCF)); - expect(chrome[300], const Color(0xFFC5B9B8)); - expect(chrome[400], const Color(0xFFB2A2A1)); - expect(chrome[500], const Color(0xFFA08C8A)); - expect(chrome[600], const Color(0xFF836D6B)); - expect(chrome[700], const Color(0xFF635251)); - expect(chrome[800], const Color(0xFF423736)); - expect(chrome[900], const Color(0xFF221C1C)); - expect(chrome[1000], const Color(0xFF000000)); + test('lands within 2 tone of the default dark brand scale', () { + final generated = StreamColorScheme.fromSeed(brand: _defaultSeed, brightness: .dark).brand; + final tokens = StreamBrandColor.dark(); + + for (final shade in _expectedTones.keys) { + expect( + _toneOf(generated[shade]!), + closeTo(_toneOf(tokens[shade]!), 2), + reason: 'brand shade $shade tone drifted from the design token', + ); + } }); - test('uses the seed color unchanged as brand shade 500', () { - const seed = Color(0xFF2196F3); - - final brand = StreamColorScheme.fromSeed(brand: seed).brand; - - expect(brand[500], seed); + test('stays close in chroma to the default light brand scale', () { + final generated = StreamColorScheme.fromSeed(brand: _defaultSeed).brand; + final tokens = StreamBrandColor.light(); + + for (final shade in _expectedTones.keys) { + expect( + _chromaOf(generated[shade]!), + closeTo(_chromaOf(tokens[shade]!), 3), + reason: 'brand shade $shade chroma drifted from the design token', + ); + } }); }); - group('StreamColorScheme.fromSeed dark color generation', () { - test('generates the exact brand scale for a deep orange seed', () { - final brand = StreamColorScheme.fromSeed(brand: const Color(0xFFFF5722), brightness: .dark).brand; - - expect(brand[0], const Color(0xFF000000)); - expect(brand[50], const Color(0xFF3D0F00)); - expect(brand[100], const Color(0xFF571500)); - expect(brand[150], const Color(0xFF701B00)); - expect(brand[200], const Color(0xFF892100)); - expect(brand[300], const Color(0xFFBC2D00)); - expect(brand[400], const Color(0xFFEE3900)); - expect(brand[500], const Color(0xFFFF5722)); - expect(brand[600], const Color(0xFFFF7C53)); - expect(brand[700], const Color(0xFFFFA184)); - expect(brand[800], const Color(0xFFFFC6B5)); - expect(brand[900], const Color(0xFFFFECE5)); - expect(brand[1000], const Color(0xFFFFFFFF)); - }); - - test('generates the exact auto-derived chrome scale for a deep orange seed', () { - final chrome = StreamColorScheme.fromSeed(brand: const Color(0xFFFF5722), brightness: .dark).chrome; - - expect(chrome[0], const Color(0xFF000000)); - expect(chrome[50], const Color(0xFF221D1C)); - expect(chrome[100], const Color(0xFF302927)); - expect(chrome[150], const Color(0xFF3E3532)); - expect(chrome[200], const Color(0xFF4B413E)); - expect(chrome[300], const Color(0xFF675954)); - expect(chrome[400], const Color(0xFF83716B)); - expect(chrome[500], const Color(0xFF9C8B85)); - expect(chrome[600], const Color(0xFFB2A4A0)); - expect(chrome[700], const Color(0xFFC8BEBB)); - expect(chrome[800], const Color(0xFFDED8D6)); - expect(chrome[900], const Color(0xFFF4F2F1)); - expect(chrome[1000], const Color(0xFFFFFFFF)); - }); + group('StreamColorScheme.fromSeed tone ladder', () { + for (final MapEntry(key: name, value: seed) in _seeds.entries) { + test('places every light $name shade on the expected tone', () { + final brand = StreamColorScheme.fromSeed(brand: seed).brand; + + for (final MapEntry(key: shade, value: tone) in _expectedTones.entries) { + expect( + _toneOf(brand[shade]!), + closeTo(tone, 1), + reason: '$name shade $shade should sit on tone $tone regardless of hue', + ); + } + }); + + test('mirrors the ladder for dark $name shades', () { + final brand = StreamColorScheme.fromSeed(brand: seed, brightness: .dark).brand; + const mirror = {50: 900, 100: 800, 150: 700, 200: 600, 300: 500, 400: 400}; + + for (final MapEntry(key: shade, value: mirrored) in mirror.entries) { + expect( + _toneOf(brand[shade]!), + closeTo(_expectedTones[mirrored]!, 1), + reason: 'dark $name shade $shade should take the tone of light shade $mirrored', + ); + } + }); + + test('produces a monotonically darkening light $name scale', () { + final brand = StreamColorScheme.fromSeed(brand: seed).brand; + + for (var i = 1; i < _shades.length; i++) { + expect( + _toneOf(brand[_shades[i]]!), + lessThan(_toneOf(brand[_shades[i - 1]]!)), + reason: '$name shade ${_shades[i]} should be darker than ${_shades[i - 1]}', + ); + } + }); + + test('preserves the $name seed hue across the scale', () { + final brand = StreamColorScheme.fromSeed(brand: seed).brand; + final seedHue = _hueOf(seed); + + for (final shade in _expectedTones.keys) { + final shadeColor = brand[shade]!; + // Hue is meaningless once chroma is clamped to near zero. + if (_chromaOf(shadeColor) < 5) continue; + final delta = (_hueOf(shadeColor) - seedHue).abs(); + expect( + delta < 15 || delta > 345, + isTrue, + reason: '$name shade $shade drifted to hue ${_hueOf(shadeColor)} from $seedHue', + ); + } + }); + } + }); - test('generates the exact brand scale for a purple seed', () { - final brand = StreamColorScheme.fromSeed(brand: const Color(0xFF9C27B0), brightness: .dark).brand; - - expect(brand[0], const Color(0xFF000000)); - expect(brand[50], const Color(0xFF2C0B32)); - expect(brand[100], const Color(0xFF390E40)); - expect(brand[150], const Color(0xFF45114E)); - expect(brand[200], const Color(0xFF52145C)); - expect(brand[300], const Color(0xFF6A1B78)); - expect(brand[400], const Color(0xFF832194)); - expect(brand[500], const Color(0xFF9C27B0)); - expect(brand[600], const Color(0xFFC145D6)); - expect(brand[700], const Color(0xFFD37CE2)); - expect(brand[800], const Color(0xFFE6B3EE)); - expect(brand[900], const Color(0xFFF8EAFA)); - expect(brand[1000], const Color(0xFFFFFFFF)); - }); + group('StreamColorScheme.fromSeed contrast guarantees', () { + // The reason for normalizing the seed onto a tone ladder rather than reproducing it + // verbatim at shade 500. Under the previous HSL generation a yellow seed produced an + // accent with 1.22:1 against white text. + for (final MapEntry(key: name, value: seed) in _seeds.entries) { + test('gives the light $name accent readable contrast against its on-color', () { + final scheme = StreamColorScheme.fromSeed(brand: seed); + + expect( + _contrast(scheme.accentPrimary, scheme.textOnAccent), + greaterThanOrEqualTo(4.5), + reason: 'white text on the $name accent must meet WCAG AA', + ); + }); + + test('gives $name brand text readable contrast on its tinted surface', () { + final brand = StreamColorScheme.fromSeed(brand: seed).brand; + + expect( + _contrast(brand.shade900, brand.shade100), + greaterThanOrEqualTo(4.5), + reason: 'brand 900 on brand 100 must meet WCAG AA for $name', + ); + }); + } + }); - test('generates the exact auto-derived chrome scale for a purple seed', () { - final chrome = StreamColorScheme.fromSeed(brand: const Color(0xFF9C27B0), brightness: .dark).chrome; - - expect(chrome[0], const Color(0xFF000000)); - expect(chrome[50], const Color(0xFF211C22)); - expect(chrome[100], const Color(0xFF2A232B)); - expect(chrome[150], const Color(0xFF332B34)); - expect(chrome[200], const Color(0xFF3C333E)); - expect(chrome[300], const Color(0xFF4F4251)); - expect(chrome[400], const Color(0xFF615163)); - expect(chrome[500], const Color(0xFF736176)); - expect(chrome[600], const Color(0xFF958299)); - expect(chrome[700], const Color(0xFFB5A7B7)); - expect(chrome[800], const Color(0xFFD4CCD5)); - expect(chrome[900], const Color(0xFFF3F1F4)); - expect(chrome[1000], const Color(0xFFFFFFFF)); + group('StreamColorScheme.fromSeed derived chrome', () { + for (final MapEntry(key: name, value: seed) in _seeds.entries) { + test('derives a near-neutral chrome scale from the $name brand', () { + final chrome = StreamColorScheme.fromSeed(brand: seed).chrome; + + for (final shade in _expectedTones.keys) { + expect( + _chromaOf(chrome[shade]!), + // Rounding into 8-bit sRGB can land a fraction above the requested chroma. + lessThanOrEqualTo(StreamColorScheme.neutralChroma + 0.5), + reason: 'derived chrome shade $shade should stay neutral for $name', + ); + } + }); + } + + test('honors an explicitly supplied chrome color', () { + final scheme = StreamColorScheme.fromSeed( + brand: _defaultSeed, + chrome: const Color(0xFF4CAF50), + ); + + // Chrome follows the supplied green rather than the blue brand hue. + expect(_hueOf(scheme.chrome.shade500), closeTo(_hueOf(const Color(0xFF4CAF50)), 15)); + expect(_toneOf(scheme.chrome.shade500), closeTo(_expectedTones[500]!, 1)); }); - test('generates the exact brand scale for a yellow seed', () { - final brand = StreamColorScheme.fromSeed(brand: const Color(0xFFFFEB3B), brightness: .dark).brand; - - expect(brand[0], const Color(0xFF000000)); - expect(brand[50], const Color(0xFF3D3700)); - expect(brand[100], const Color(0xFF595000)); - expect(brand[150], const Color(0xFF756900)); - expect(brand[200], const Color(0xFF918300)); - expect(brand[300], const Color(0xFFCAB500)); - expect(brand[400], const Color(0xFFFFE503)); - expect(brand[500], const Color(0xFFFFEB3B)); - expect(brand[600], const Color(0xFFFFEF66)); - expect(brand[700], const Color(0xFFFFF490)); - expect(brand[800], const Color(0xFFFFF8BB)); - expect(brand[900], const Color(0xFFFFFCE5)); - expect(brand[1000], const Color(0xFFFFFFFF)); - }); + test('keeps the endpoints pinned to white and black', () { + final light = StreamColorScheme.fromSeed(brand: _defaultSeed); + final dark = StreamColorScheme.fromSeed(brand: _defaultSeed, brightness: .dark); - test('generates the exact auto-derived chrome scale for a yellow seed', () { - final chrome = StreamColorScheme.fromSeed(brand: const Color(0xFFFFEB3B), brightness: .dark).chrome; - - expect(chrome[0], const Color(0xFF000000)); - expect(chrome[50], const Color(0xFF22211C)); - expect(chrome[100], const Color(0xFF313028)); - expect(chrome[150], const Color(0xFF413F35)); - expect(chrome[200], const Color(0xFF504F41)); - expect(chrome[300], const Color(0xFF6F6D5B)); - expect(chrome[400], const Color(0xFF8E8B74)); - expect(chrome[500], const Color(0xFFA7A593)); - expect(chrome[600], const Color(0xFFBAB8AB)); - expect(chrome[700], const Color(0xFFCDCCC2)); - expect(chrome[800], const Color(0xFFE0E0DA)); - expect(chrome[900], const Color(0xFFF4F3F1)); - expect(chrome[1000], const Color(0xFFFFFFFF)); + expect(light.chrome[0], const Color(0xFFFFFFFF)); + expect(light.chrome[1000], const Color(0xFF000000)); + expect(dark.chrome[0], const Color(0xFF000000)); + expect(dark.chrome[1000], const Color(0xFFFFFFFF)); }); + }); - test('generates the exact brand scale for a green seed', () { - final brand = StreamColorScheme.fromSeed(brand: const Color(0xFF4CAF50), brightness: .dark).brand; - - expect(brand[0], const Color(0xFF000000)); - expect(brand[50], const Color(0xFF132B14)); - expect(brand[100], const Color(0xFF19391A)); - expect(brand[150], const Color(0xFF1F4821)); - expect(brand[200], const Color(0xFF265728)); - expect(brand[300], const Color(0xFF327435)); - expect(brand[400], const Color(0xFF3F9243)); - expect(brand[500], const Color(0xFF4CAF50)); - expect(brand[600], const Color(0xFF73C276)); - expect(brand[700], const Color(0xFF9CD49E)); - expect(brand[800], const Color(0xFFC5E6C6)); - expect(brand[900], const Color(0xFFEDF7EE)); - expect(brand[1000], const Color(0xFFFFFFFF)); - }); + group('StreamColorSwatch.fromColor', () { + test('normalizes the seed onto the ladder rather than preserving it verbatim', () { + // Deliberate replacement for the old "shade 500 == seed" contract. A yellow seed + // is far too light to carry white text, so it is pulled down onto tone 46. + const seed = Color(0xFFFFEB3B); + final swatch = StreamColorSwatch.fromColor(seed); - test('generates the exact auto-derived chrome scale for a green seed', () { - final chrome = StreamColorScheme.fromSeed(brand: const Color(0xFF4CAF50), brightness: .dark).chrome; - - expect(chrome[0], const Color(0xFF000000)); - expect(chrome[50], const Color(0xFF1C221C)); - expect(chrome[100], const Color(0xFF252D25)); - expect(chrome[150], const Color(0xFF2F392F)); - expect(chrome[200], const Color(0xFF384439)); - expect(chrome[300], const Color(0xFF4B5C4C)); - expect(chrome[400], const Color(0xFF5E735F)); - expect(chrome[500], const Color(0xFF718A72)); - expect(chrome[600], const Color(0xFF91A591)); - expect(chrome[700], const Color(0xFFB1BFB1)); - expect(chrome[800], const Color(0xFFD1D9D1)); - expect(chrome[900], const Color(0xFFF1F4F1)); - expect(chrome[1000], const Color(0xFFFFFFFF)); + expect(swatch[500], isNot(seed)); + expect(_toneOf(swatch[500]!), closeTo(_expectedTones[500]!, 1)); + expect(_hueOf(swatch[500]!), closeTo(_hueOf(seed), 15)); }); - test('generates the exact brand scale for a blue seed', () { - final brand = StreamColorScheme.fromSeed(brand: const Color(0xFF2196F3), brightness: .dark).brand; - - expect(brand[0], const Color(0xFF000000)); - expect(brand[50], const Color(0xFF03223A)); - expect(brand[100], const Color(0xFF042F51)); - expect(brand[150], const Color(0xFF063C67)); - expect(brand[200], const Color(0xFF07497E)); - expect(brand[300], const Color(0xFF0964AB)); - expect(brand[400], const Color(0xFF0C7ED9)); - expect(brand[500], const Color(0xFF2196F3)); - expect(brand[600], const Color(0xFF52ADF6)); - expect(brand[700], const Color(0xFF84C5F8)); - expect(brand[800], const Color(0xFFB5DCFB)); - expect(brand[900], const Color(0xFFE7F4FE)); - expect(brand[1000], const Color(0xFFFFFFFF)); - }); + test('uses shade 500 as the swatch primary', () { + final swatch = StreamColorSwatch.fromColor(_defaultSeed); - test('generates the exact auto-derived chrome scale for a blue seed', () { - final chrome = StreamColorScheme.fromSeed(brand: const Color(0xFF2196F3), brightness: .dark).chrome; - - expect(chrome[0], const Color(0xFF000000)); - expect(chrome[50], const Color(0xFF1C1F22)); - expect(chrome[100], const Color(0xFF262B2F)); - expect(chrome[150], const Color(0xFF31373C)); - expect(chrome[200], const Color(0xFF3C4349)); - expect(chrome[300], const Color(0xFF515B63)); - expect(chrome[400], const Color(0xFF67737E)); - expect(chrome[500], const Color(0xFF7E8B96)); - expect(chrome[600], const Color(0xFF9BA5AD)); - expect(chrome[700], const Color(0xFFB8BFC5)); - expect(chrome[800], const Color(0xFFD4D9DC)); - expect(chrome[900], const Color(0xFFF1F2F4)); - expect(chrome[1000], const Color(0xFFFFFFFF)); + expect(swatch.toARGB32(), swatch[500]!.toARGB32()); }); - test('generates the exact brand scale for a red seed', () { - final brand = StreamColorScheme.fromSeed(brand: const Color(0xFFF44336), brightness: .dark).brand; - - expect(brand[0], const Color(0xFF000000)); - expect(brand[50], const Color(0xFF3A0703)); - expect(brand[100], const Color(0xFF530A05)); - expect(brand[150], const Color(0xFF6C0D06)); - expect(brand[200], const Color(0xFF851007)); - expect(brand[300], const Color(0xFFB7160A)); - expect(brand[400], const Color(0xFFE91C0D)); - expect(brand[500], const Color(0xFFF44336)); - expect(brand[600], const Color(0xFFF66C62)); - expect(brand[700], const Color(0xFFF9968E)); - expect(brand[800], const Color(0xFFFBBFBB)); - expect(brand[900], const Color(0xFFFEE8E7)); - expect(brand[1000], const Color(0xFFFFFFFF)); - }); + test('collapses to a neutral scale for an achromatic seed', () { + final swatch = StreamColorSwatch.fromColor(const Color(0xFF808080)); - test('generates the exact auto-derived chrome scale for a red seed', () { - final chrome = StreamColorScheme.fromSeed(brand: const Color(0xFFF44336), brightness: .dark).chrome; - - expect(chrome[0], const Color(0xFF000000)); - expect(chrome[50], const Color(0xFF221C1C)); - expect(chrome[100], const Color(0xFF302827)); - expect(chrome[150], const Color(0xFF3F3433)); - expect(chrome[200], const Color(0xFF4D403F)); - expect(chrome[300], const Color(0xFF6A5857)); - expect(chrome[400], const Color(0xFF87706E)); - expect(chrome[500], const Color(0xFFA08C8A)); - expect(chrome[600], const Color(0xFFB5A5A4)); - expect(chrome[700], const Color(0xFFCABEBE)); - expect(chrome[800], const Color(0xFFDFD8D7)); - expect(chrome[900], const Color(0xFFF4F1F1)); - expect(chrome[1000], const Color(0xFFFFFFFF)); + for (final shade in _expectedTones.keys) { + expect(_chromaOf(swatch[shade]!), lessThan(2)); + expect(_toneOf(swatch[shade]!), closeTo(_expectedTones[shade]!, 1)); + } }); - test('uses the seed color unchanged as brand shade 500', () { - const seed = Color(0xFF2196F3); - - final brand = StreamColorScheme.fromSeed(brand: seed, brightness: .dark).brand; + test('honors an explicit chroma override', () { + final vivid = StreamColorSwatch.fromColor(_defaultSeed); + final muted = StreamColorSwatch.fromColor(_defaultSeed, chroma: 8); - expect(brand[500], seed); + expect(_chromaOf(muted.shade500), lessThan(_chromaOf(vivid.shade500))); + expect(_chromaOf(muted.shade500), closeTo(8, 1)); + // The override changes chroma only; tone is still driven by the ladder. + expect(_toneOf(muted.shade500), closeTo(_toneOf(vivid.shade500), 1)); }); }); } From 0b276cd6fb483bb10aa4adcfd2cc4a016a9c7d26 Mon Sep 17 00:00:00 2001 From: renefloor <15101411+renefloor@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:02:16 +0000 Subject: [PATCH 12/14] chore: Update Goldens --- .../ci/stream_theme_color_generation.png | Bin 9731 -> 9862 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/stream_core_flutter/test/theme/goldens/ci/stream_theme_color_generation.png b/packages/stream_core_flutter/test/theme/goldens/ci/stream_theme_color_generation.png index 7ac5830cdb1fd46df97bd3d77ebe47447e81ddf5..7d7100d4e95527a2c3b4a9814aaac1ee00530868 100644 GIT binary patch literal 9862 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 QhlV^v|8?G@Xu;oNu-uLQfe?OgyDZGrbW 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 Date: Tue, 28 Jul 2026 15:04:20 +0200 Subject: [PATCH 13/14] update material_color_utilities range --- melos.yaml | 2 +- packages/stream_core_flutter/pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/melos.yaml b/melos.yaml index 4154e4db..a803e38a 100644 --- a/melos.yaml +++ b/melos.yaml @@ -30,7 +30,7 @@ command: jose: ^0.3.4 json_annotation: ^4.9.0 markdown: ^7.3.0 - material_color_utilities: ^0.13.0 + material_color_utilities: '>=0.11.0 <0.14.0' meta: ^1.15.0 mime: ^2.0.0 rxdart: ^0.28.0 diff --git a/packages/stream_core_flutter/pubspec.yaml b/packages/stream_core_flutter/pubspec.yaml index eb0cb5a1..caa81e0d 100644 --- a/packages/stream_core_flutter/pubspec.yaml +++ b/packages/stream_core_flutter/pubspec.yaml @@ -15,7 +15,7 @@ dependencies: flutter_markdown_plus: ^1.0.7 flutter_svg: ^2.2.3 markdown: ^7.3.0 - material_color_utilities: ^0.13.0 + material_color_utilities: ">=0.11.0 <0.14.0" shimmer: ^3.0.0 stream_core: ^0.4.0 svg_icon_widget: ^0.0.1+1 From 5decf271f3408cf53d936230c7452593a98c07b8 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 28 Jul 2026 15:14:06 +0200 Subject: [PATCH 14/14] improve unit test --- .../theme/stream_theme_color_generation_test.dart | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/stream_core_flutter/test/theme/stream_theme_color_generation_test.dart b/packages/stream_core_flutter/test/theme/stream_theme_color_generation_test.dart index 769790dc..e9100e38 100644 --- a/packages/stream_core_flutter/test/theme/stream_theme_color_generation_test.dart +++ b/packages/stream_core_flutter/test/theme/stream_theme_color_generation_test.dart @@ -171,7 +171,20 @@ void main() { test('mirrors the ladder for dark $name shades', () { final brand = StreamColorScheme.fromSeed(brand: seed, brightness: .dark).brand; - const mirror = {50: 900, 100: 800, 150: 700, 200: 600, 300: 500, 400: 400}; + // The full mapping from StreamColorSwatchHelper._darkMirror. + const mirror = { + 50: 900, + 100: 800, + 150: 700, + 200: 600, + 300: 500, + 400: 400, + 500: 300, + 600: 200, + 700: 150, + 800: 100, + 900: 50, + }; for (final MapEntry(key: shade, value: mirrored) in mirror.entries) { expect(