diff --git a/apps/design_system_gallery/lib/config/theme_configuration.dart b/apps/design_system_gallery/lib/config/theme_configuration.dart index e70a7833..733359e7 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,11 @@ class ThemeConfiguration extends ChangeNotifier { } void resetToDefaults() { + // Brand _brandPrimaryColor = null; + // Chrome + _chromePrimaryColor = null; + // Accent _accentPrimary = null; _accentSuccess = null; @@ -364,130 +511,88 @@ class ThemeConfiguration extends ChangeNotifier { _systemScrollbar = null; // Avatar _avatarPalette = null; - // Brand - _brandPrimaryColor = null; - // Chrome - _chromePrimaryColor = null; _rebuildTheme(); notifyListeners(); } 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 StreamColorScheme.fromSeed: an explicit chrome color wins; + // otherwise, when a brand color is set but chrome isn't, derive chrome from brand at + // 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, + chroma: StreamColorScheme.neutralChroma, + ) : 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..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', @@ -219,13 +219,15 @@ class _ThemeCustomizationPanelState extends State { child: ColorPickerTile( label: 'brandPrimary', color: config.brandPrimaryColor, + isDefault: !config.brandIsCustom, onColorChanged: config.setBrandPrimaryColor, + onReset: config.resetBrand, ), ); } Widget _buildChromeSection(BuildContext context) { - final config = context.read(); + final config = context.watch(); return SectionCard( title: 'Chrome Color', subtitle: 'chrome', @@ -233,13 +235,15 @@ class _ThemeCustomizationPanelState extends State { child: ColorPickerTile( label: 'chromePrimary', color: config.chromePrimaryColor, + isDefault: !config.chromeIsCustom, onColorChanged: config.setChromePrimaryColor, + onReset: config.resetChrome, ), ); } Widget _buildAccentColorsSection(BuildContext context) { - final config = context.read(); + final config = context.watch(); return SectionCard( title: 'Accent Colors', subtitle: 'accent*', @@ -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, ), ], ), @@ -277,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*', @@ -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, ), ], ), @@ -320,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', @@ -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, ), ], ), @@ -451,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*', @@ -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, ), ], ), @@ -499,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*', @@ -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, ), ], ), @@ -557,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*', @@ -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, ), ], ), @@ -580,9 +675,12 @@ 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; + 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/melos.yaml b/melos.yaml index ab7c4346..47541470 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.11.0 <0.14.0' meta: ^1.15.0 mime: ^2.0.0 plugin_platform_interface: ^2.1.8 diff --git a/packages/stream_core_flutter/CHANGELOG.md b/packages/stream_core_flutter/CHANGELOG.md index 32daff8e..da016bb0 100644 --- a/packages/stream_core_flutter/CHANGELOG.md +++ b/packages/stream_core_flutter/CHANGELOG.md @@ -11,6 +11,8 @@ - Added optional `semanticsLabel` to `StreamAvatar`, `StreamAvatarGroup`, and `StreamAvatarStack`. On `StreamAvatar`, `null` (default) drops the placeholder's initials from the semantics tree via `ExcludeSemantics`; a non-null value exposes it as a labeled image node. On `StreamAvatarGroup` / `StreamAvatarStack`, `null` composes through — each child's own `semanticsLabel` applies — while a non-null value collapses the group into a single labeled image node and hides children and the "+N" overflow badge. - 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 `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 6b80fbaa..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,90 +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. + /// + /// 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? chroma, }) { - final hslBase = HSLColor.fromColor(baseColor); - 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 6c51b226..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,11 +269,22 @@ abstract final class StreamColors { class StreamColorSwatch extends ColorSwatch { const StreamColorSwatch(super.primary, super._swatch); - factory StreamColorSwatch.fromColor(Color color, {Brightness brightness = Brightness.light}) { - return StreamColorSwatch( - color.toARGB32(), - StreamColorSwatchHelper.generateShadeMap(color, brightness: brightness), - ); + /// 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? chroma, + }) { + final shadeMap = StreamColorSwatchHelper.generateShadeMap(color, brightness: brightness, chroma: chroma); + return StreamColorSwatch(shadeMap[500]!.toARGB32(), shadeMap); } /// The lightest shade. 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..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 @@ -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,34 @@ 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, + 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, chroma: neutralChroma), + ); + } + const StreamColorScheme.raw({ + this.brightness = Brightness.light, required this.brand, required this.chrome, // Accent @@ -550,6 +580,16 @@ 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; + // ---- 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 05960c99..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,5 +1,3 @@ -// ignore_for_file: avoid_redundant_argument_values - import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; @@ -104,7 +102,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, @@ -151,8 +149,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(); @@ -204,7 +208,6 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { switchTheme ??= const StreamSwitchThemeData(); return .raw( - brightness: brightness, icons: icons, radius: radius, spacing: spacing, @@ -251,20 +254,20 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { ); } - /// Creates a dark theme configuration. - /// - /// This is a convenience factory that calls [StreamTheme] with - /// [Brightness.dark]. - factory StreamTheme.dark() => StreamTheme(brightness: .dark); - /// Creates a light theme configuration. /// /// This is a convenience factory that calls [StreamTheme] with /// [Brightness.light]. 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() => 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, @@ -340,7 +343,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; @@ -498,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 52b0a961..2fe2653c 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.11.0 <0.14.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/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 00000000..7d7100d4 Binary files /dev/null and b/packages/stream_core_flutter/test/theme/goldens/ci/stream_theme_color_generation.png differ 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..393c64e4 --- /dev/null +++ b/packages/stream_core_flutter/test/theme/stream_theme_color_generation_golden_test.dart @@ -0,0 +1,92 @@ +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('StreamColorScheme 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 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}); + + final Color seedColor; + + @override + Widget build(BuildContext context) { + final light = StreamColorScheme.fromSeed(brand: seedColor); + final dark = StreamColorScheme.fromSeed(brand: seedColor, brightness: .dark); + + 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..e9100e38 --- /dev/null +++ b/packages/stream_core_flutter/test/theme/stream_theme_color_generation_test.dart @@ -0,0 +1,331 @@ +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'; + +/// 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); +} + +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 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)); + }); + }); + + 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('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('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 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; + // 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( + _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', + ); + } + }); + } + }); + + 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', + ); + }); + } + }); + + 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('keeps the endpoints pinned to white and black', () { + final light = StreamColorScheme.fromSeed(brand: _defaultSeed); + final dark = StreamColorScheme.fromSeed(brand: _defaultSeed, brightness: .dark); + + 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)); + }); + }); + + 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); + + expect(swatch[500], isNot(seed)); + expect(_toneOf(swatch[500]!), closeTo(_expectedTones[500]!, 1)); + expect(_hueOf(swatch[500]!), closeTo(_hueOf(seed), 15)); + }); + + test('uses shade 500 as the swatch primary', () { + final swatch = StreamColorSwatch.fromColor(_defaultSeed); + + expect(swatch.toARGB32(), swatch[500]!.toARGB32()); + }); + + test('collapses to a neutral scale for an achromatic seed', () { + final swatch = StreamColorSwatch.fromColor(const Color(0xFF808080)); + + for (final shade in _expectedTones.keys) { + expect(_chromaOf(swatch[shade]!), lessThan(2)); + expect(_toneOf(swatch[shade]!), closeTo(_expectedTones[shade]!, 1)); + } + }); + + test('honors an explicit chroma override', () { + final vivid = StreamColorSwatch.fromColor(_defaultSeed); + final muted = StreamColorSwatch.fromColor(_defaultSeed, chroma: 8); + + 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)); + }); + }); +}