From 5fefec604f5a98d78ed13a110d586b2f8424b5fb Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 20 Oct 2026 13:48:12 -0400 Subject: [PATCH] fix(mobile): restore channel icons and align chat colors with desktop (#8478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Restores `#` and lock icons for public and private channels on Home, with matching loading placeholders. - Updates the dark-mode navigation colors to the desktop design-system tokens (read and unread text, section headings). - Replaces the chat color picker with the seven desktop accents (Gray, Chartreuse, Flame, Magenta, Iris, Cobalt, Jade). Saved legacy colors map to the closest one. - Uses white text in your own bubbles, and black where white falls below 4.5:1 (Chartreuse, dark-mode Gray). Links (underlined), quotes, tables and code use the same text color. - Insets code blocks in bubbles evenly, with corners concentric to the bubble and even inner padding. - The swipe-to-reply indicator uses the chosen chat color. ## Testing - `flutter analyze` and `dart format` are clean. The full mobile suite passes (3,705 tests). - Installed and checked on a Pixel 10 and an iPhone Air against a real community. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: kenny lopez Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> Co-authored-by: Queue <874bf2d815c2c4b459b90438c669c57f2702f3a76bb6e16fa97184259aac3e9e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 --- desktop/src/shared/theme/ThemeProvider.tsx | 78 +++++++- .../src/shared/theme/accentPrimary.test.mjs | 45 +++++ .../theme/communityThemePreference.test.mjs | 37 ++++ .../tests/e2e/buzz-theme-screenshots.spec.ts | 2 +- .../channels/channels_page/channel_tile.dart | 29 ++- .../channels/channels_page/sections.dart | 17 +- .../channels/channels_page/skeleton.dart | 11 ++ .../features/channels/message_content.dart | 184 ++++++++++++------ .../channels/message_swipe_reply.dart | 9 +- mobile/lib/shared/theme/accent_colors.dart | 6 + mobile/lib/shared/theme/buzz_theme.dart | 14 ++ mobile/lib/shared/theme/chat_colors.dart | 99 +++++++--- mobile/lib/shared/theme/code_style.dart | 3 +- mobile/lib/shared/theme/color_scheme.dart | 46 ++--- .../theme/community_theme_preference.dart | 15 +- .../lib/shared/theme/mobile_appearance.dart | 2 +- mobile/lib/shared/theme/theme_extensions.dart | 2 +- .../channels/channel_detail_page_test.dart | 10 +- .../swipe_reply_tests.dart | 4 +- .../features/channels/channels_page_test.dart | 77 +++++++- .../features/channels/chat_color_test.dart | 133 ++++++++++++- .../message_content_bubble_cases.dart | 10 +- .../settings/theme_picker_page_test.dart | 10 +- .../test/shared/theme/chat_colors_test.dart | 147 ++++++++++---- .../shared/theme/mobile_appearance_test.dart | 8 +- test-fixtures/community-theme-accents.json | 83 ++++++++ 26 files changed, 869 insertions(+), 212 deletions(-) create mode 100644 desktop/src/shared/theme/accentPrimary.test.mjs create mode 100644 test-fixtures/community-theme-accents.json diff --git a/desktop/src/shared/theme/ThemeProvider.tsx b/desktop/src/shared/theme/ThemeProvider.tsx index 63fd3adb4d..093467d814 100644 --- a/desktop/src/shared/theme/ThemeProvider.tsx +++ b/desktop/src/shared/theme/ThemeProvider.tsx @@ -52,8 +52,16 @@ export const ACCENT_COLORS = [ { name: "Lilac", value: "#c0a2f1" }, { name: "Purple", value: "#a855f7" }, { name: "Indigo", value: "#6366f1" }, + { name: "Chartreuse", value: "#d7d72e" }, ] as const; +/** + * Accents too light to read as `text-primary` on light themes. They darken + * there; the legacy accents keep their established rendering. + */ +const LIGHT_ONLY_ACCENTS = new Set(["#d7d72e"]); +const ACCENT_TEXT_CONTRAST = 4.5; + const DEFAULT_ACCENT = "#3b82f6"; type ThemeContextValue = { @@ -195,6 +203,62 @@ function rgbToHex({ r, g, b }: Rgb): string { .join("")}`; } +/** Parses a `--background`-style `H S% L%` triplet. */ +function hslTripletToRgb(value: string): Rgb | null { + const m = /^(-?[\d.]+)\s+([\d.]+)%\s+([\d.]+)%$/.exec(value.trim()); + if (!m) return null; + const h = Number(m[1]) / 360; + const s = Number(m[2]) / 100; + const l = Number(m[3]) / 100; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + const channel = (offset: number) => { + const t = (((h + offset) % 1) + 1) % 1; + if (t < 1 / 6) return (p + (q - p) * 6 * t) * 255; + if (t < 1 / 2) return q * 255; + if (t < 2 / 3) return (p + (q - p) * (2 / 3 - t) * 6) * 255; + return p * 255; + }; + return { r: channel(1 / 3), g: channel(0), b: channel(-1 / 3) }; +} + +/** + * The `--primary` fill and its ink for an accent on the theme `background`. + * `--primary` is also drawn directly as text, so a light-only accent darkens + * until it reads on that background. + */ +export function accentPrimary( + value: string, + background: string, +): { fill: string; ink: string } { + const accent = hexToRgb(value); + const surface = hslTripletToRgb(background); + let fill = value; + if ( + LIGHT_ONLY_ACCENTS.has(value) && + surface && + contrastRatio(accent, surface) < ACCENT_TEXT_CONTRAST + ) { + const black = { r: 0, g: 0, b: 0 }; + // Search over rounded hex so the published fill itself meets the ratio. + const darken = (amount: number) => rgbToHex(mixRgb(accent, black, amount)); + let low = 0; + let high = 1; + for (let i = 0; i < 20; i++) { + const mid = (low + high) / 2; + if ( + contrastRatio(hexToRgb(darken(mid)), surface) >= ACCENT_TEXT_CONTRAST + ) { + high = mid; + } else { + low = mid; + } + } + fill = darken(high); + } + return { fill, ink: getContrastColor(fill) }; +} + function applyAccentColor(value: string) { const root = document.documentElement; if (value === NEUTRAL_ACCENT) { @@ -219,14 +283,18 @@ function applyAccentColor(value: string) { return; } - const hex = value; - const accentHsl = hexToHsl(hex); - const fgHsl = hexToHsl(getContrastColor(hex)); + const { fill, ink } = accentPrimary( + value, + window.getComputedStyle(root).getPropertyValue("--background"), + ); + const accentHsl = hexToHsl(fill); + const fgHsl = hexToHsl(ink); root.style.setProperty("--buzz-selected-accent", accentHsl); - root.style.setProperty("--buzz-video-review-accent", accentHsl); + // The review chip sits on its own dark surface, so it keeps the raw accent. + root.style.setProperty("--buzz-video-review-accent", hexToHsl(value)); root.style.setProperty( "--buzz-video-review-accent-foreground", - getReviewAccentForeground(hex), + getReviewAccentForeground(value), ); root.style.setProperty("--primary", accentHsl); root.style.setProperty("--primary-foreground", fgHsl); diff --git a/desktop/src/shared/theme/accentPrimary.test.mjs b/desktop/src/shared/theme/accentPrimary.test.mjs new file mode 100644 index 0000000000..1b43a15a92 --- /dev/null +++ b/desktop/src/shared/theme/accentPrimary.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { hexToHsl } from "./adaptive-theme.ts"; +import { ACCENT_COLORS, accentPrimary } from "./ThemeProvider.tsx"; + +function luminance(hex) { + const channels = [1, 3, 5].map((i) => { + const value = parseInt(hex.slice(i, i + 2), 16) / 255; + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; +} + +function contrast(a, b) { + const [high, low] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (high + 0.05) / (low + 0.05); +} + +// Light backgrounds down to the adaptive engine's 0.5-luminance light cutoff. +const LIGHT = ["#ffffff", "#fafafa", "#f6f8fa", "#eff1f5", "#bcbcbc"]; +const DARK = ["#0d1117", "#24273a", "#1e1e1e"]; + +test("Chartreuse reads as text and as a filled control on every theme", () => { + for (const background of [...LIGHT, ...DARK]) { + const { fill, ink } = accentPrimary("#d7d72e", hexToHsl(background)); + assert.ok(contrast(fill, background) >= 4.5, `text on ${background}`); + assert.ok(contrast(ink, fill) >= 4.5, `ink on fill over ${background}`); + } + // Dark themes keep the raw accent. + for (const background of DARK) { + assert.equal( + accentPrimary("#d7d72e", hexToHsl(background)).fill, + "#d7d72e", + ); + } +}); + +test("legacy accents keep their established fill", () => { + for (const { value } of ACCENT_COLORS) { + if (value === "neutral" || value === "#d7d72e") continue; + for (const background of [...LIGHT, ...DARK]) { + assert.equal(accentPrimary(value, hexToHsl(background)).fill, value); + } + } +}); diff --git a/desktop/src/shared/theme/communityThemePreference.test.mjs b/desktop/src/shared/theme/communityThemePreference.test.mjs index 1cc689cf4b..0ecf7d1d60 100644 --- a/desktop/src/shared/theme/communityThemePreference.test.mjs +++ b/desktop/src/shared/theme/communityThemePreference.test.mjs @@ -1,5 +1,7 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; +import { ACCENT_COLORS } from "./ThemeProvider.tsx"; import { DEFAULT_COMMUNITY_THEME, cacheAndApplyCommunityTheme, @@ -48,6 +50,41 @@ test("parses only the versioned stable appearance contract", () => { ); }); +test("accents follow the cross-client v1 contract", () => { + // Mobile's chat color test reads the same fixture, so both clients agree on + // every value either can save. + const contract = JSON.parse( + readFileSync( + new URL( + "../../../../test-fixtures/community-theme-accents.json", + import.meta.url, + ), + "utf8", + ), + ); + const base = { version: 1, theme: "houston", followSystem: false }; + for (const { accent, desktop } of [...contract.emitted, ...contract.legacy]) { + assert.equal( + parseCommunityThemePreference({ ...base, accent })?.accent, + accent, + accent, + ); + assert.equal( + ACCENT_COLORS.find(({ value }) => value === accent)?.name, + desktop, + accent, + ); + } + for (const accent of contract.rejected) { + assert.equal(parseCommunityThemePreference({ ...base, accent }), null); + } + // Older clients reject an unknown accent with the whole preference, so the + // default published without a picker action stays in their set. + assert.ok( + contract.baseReaders.accepted.includes(DEFAULT_COMMUNITY_THEME.accent), + ); +}); + test("local preferences are isolated by pubkey and normalized relay", () => { globalThis.window = { localStorage: localStorageStub() }; const aliceA = { diff --git a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts index a075c7bd36..5287976274 100644 --- a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts +++ b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts @@ -1810,7 +1810,7 @@ test("accent picker reveals/hides when toggling Buzz", async ({ page }) => { return { x: box.x, y: box.y }; }), ); - expect(swatchBoxes).toHaveLength(10); + expect(swatchBoxes).toHaveLength(11); expect(new Set(swatchBoxes.map((box) => Math.round(box.y))).size).toBe(1); await expect(page.getByTestId("accent-color-options")).toHaveCSS( "overflow-x", diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index 553df6abaf..795052ba98 100644 --- a/mobile/lib/features/channels/channels_page/channel_tile.dart +++ b/mobile/lib/features/channels/channels_page/channel_tile.dart @@ -27,8 +27,8 @@ class _ChannelTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final contentColor = isMuted - ? navigationSecondaryForeground(context) - : navigationPrimaryForeground(context); + ? navigationMetadataForeground(context) + : channelNavigationForeground(context); return InkWell( borderRadius: BorderRadius.circular(Radii.md), @@ -55,6 +55,22 @@ class _ChannelTile extends ConsumerWidget { ), ), const SizedBox(width: _kChannelLabelGap), + ] else ...[ + SizedBox( + width: _kChannelLeadingWidth, + child: Align( + alignment: Alignment.centerLeft, + child: Icon( + channel.visibility == 'private' + ? BuzzIcons.lock + : BuzzIcons.hash, + key: ValueKey('channel-icon-${channel.id}'), + size: _kChannelIconSize, + color: contentColor, + ), + ), + ), + const SizedBox(width: _kChannelLabelGap), ], Expanded( child: Column( @@ -73,7 +89,12 @@ class _ChannelTile extends ConsumerWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: contentListTitleTextStyle.copyWith( - color: contentColor, + color: isMuted + ? contentColor + : channelNavigationForeground( + context, + unread: isUnread, + ), fontWeight: isUnread ? FontWeight.w700 : FontWeight.w400, ), ), @@ -92,7 +113,7 @@ class _ChannelTile extends ConsumerWidget { child: Icon( BuzzIcons.bellOff, size: 12, - color: context.colors.onSurface.withValues(alpha: 0.4), + color: navigationMetadataForeground(context), ), ), ), diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index 0987bb27a2..34416fa19a 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -115,9 +115,7 @@ class _CustomSectionHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final sectionColor = context.colors.brightness == Brightness.dark - ? const Color(0xFF595959) - : const Color(0xFF959595); + final sectionColor = navigationMetadataForeground(context); return GestureDetector( onTap: onToggle, behavior: HitTestBehavior.opaque, @@ -144,7 +142,7 @@ class _CustomSectionHeader extends StatelessWidget { icon: Icon( BuzzIcons.ellipsisVertical, size: _kChannelIconSize, - color: sectionColor, + color: navigationSecondaryForeground(context), ), onPressed: () async { final value = await showAnchoredPopover( @@ -468,9 +466,7 @@ class _SectionHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final sectionColor = context.colors.brightness == Brightness.dark - ? const Color(0xFF595959) - : const Color(0xFF959595); + final sectionColor = navigationMetadataForeground(context); return GestureDetector( onTap: onToggle, @@ -499,7 +495,7 @@ class _SectionHeader extends StatelessWidget { icon: Icon( BuzzIcons.ellipsisVertical, size: _kChannelIconSize, - color: sectionColor, + color: navigationSecondaryForeground(context), ), onPressed: () async { final value = await showAnchoredPopover( @@ -551,7 +547,10 @@ class _SectionLabel extends StatelessWidget { ), ), const SizedBox(width: Grid.quarter), - _SectionChevron(expanded: expanded, color: color), + _SectionChevron( + expanded: expanded, + color: navigationSecondaryForeground(context), + ), ], ); } diff --git a/mobile/lib/features/channels/channels_page/skeleton.dart b/mobile/lib/features/channels/channels_page/skeleton.dart index 0f8d305aa7..57de6ed777 100644 --- a/mobile/lib/features/channels/channels_page/skeleton.dart +++ b/mobile/lib/features/channels/channels_page/skeleton.dart @@ -83,6 +83,17 @@ class _ChannelSkeletonSection extends StatelessWidget { ), child: Row( children: [ + const SizedBox( + width: _kChannelLeadingWidth, + child: Align( + alignment: Alignment.centerLeft, + child: SkeletonBar( + width: _kChannelIconSize, + height: _kChannelIconSize, + ), + ), + ), + const SizedBox(width: _kChannelLabelGap), SkeletonBar( key: Key('channels-skeleton-row-label-$index'), width: widths[index], diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index a813a104e0..ca4b974415 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -200,12 +200,9 @@ class MessageContent extends HookConsumerWidget { final markdownTheme = GptMarkdownTheme.of(context); final chatColors = ChatColors.of(context); final ownTextColor = chatColors.foreground; - // Desktop uses blue links on neutral bubbles and the bubble ink on colors. - final linkColor = ownMessage && ownTextColor == Colors.white - ? ownTextColor - : context.colors.brightness == Brightness.dark - ? const Color(0xFF83C4FF) - : const Color(0xFF0B5FA8); + // Own-bubble links share the bubble ink; markdown marks them by underline. + final linkColor = ownMessage ? ownTextColor : context.colors.tertiary; + final bubbleInk = ownMessage ? ownTextColor : context.colors.onSurface; final baseTextStyle = (baseStyle ?? context.textTheme.bodyMedium?.copyWith( @@ -361,12 +358,27 @@ class MessageContent extends HookConsumerWidget { child: GptMarkdown( text, style: style, + styleSheet: bubble + ? GptMarkdownStyleSheet( + blockQuote: BlockQuoteStyle(barColor: bubbleInk), + hr: HrStyle(color: bubbleInk.withValues(alpha: 0.3)), + table: TableStyle( + borderColor: bubbleInk.withValues(alpha: 0.3), + headerBackground: Colors.transparent, + headerTextStyle: TextStyle(color: bubbleInk), + ), + ) + : null, followLinkColor: false, // normalizeBareLinks() already turns bare URLs into Markdown links; // gpt_markdown 1.2.0 autolinks by default, so both would run. autolink: false, - codeBuilder: (context, name, code, closed) => - _MessageCodeBlock(name: name, code: code), + codeBuilder: (context, name, code, closed) => _MessageCodeBlock( + name: name, + code: code, + bubble: bubble, + ink: bubble && ownMessage ? ownTextColor : null, + ), linkBuilder: (context, linkText, url, linkStyle) => _buildLink( context, ref, @@ -426,7 +438,7 @@ class MessageContent extends HookConsumerWidget { child: LocalSendBubbleSurface( color: ownMessage ? chatColors.outgoing - : context.colors.secondaryContainer, + : receivedMessageSurface(context.colors.brightness), shape: const _MessageBubbleShape(), child: Padding( padding: EdgeInsets.fromLTRB( @@ -445,6 +457,21 @@ class MessageContent extends HookConsumerWidget { ), child: GptMarkdownTheme( gptThemeData: markdownTheme.copyWith( + linkColor: linkColor, + // A neutral chip would put white ink on a light + // fill; tint the accent instead. + inlineCode: ownMessage + ? markdownTheme.inlineCode.copyWith( + color: ownTextColor, + backgroundColor: chatCodeTint( + ownTextColor, + 0.18, + ), + borderColor: ownTextColor.withValues( + alpha: 0.3, + ), + ) + : null, h1: markdownTheme.h1?.copyWith( color: ownMessage ? ownTextColor @@ -869,7 +896,18 @@ class _MessageCodeBlock extends HookWidget { final String name; final String code; - const _MessageCodeBlock({required this.name, required this.code}); + /// Whether the block sits inside a message bubble. + final bool bubble; + + /// Text color on an accent bubble; null keeps the neutral highlighted card. + final Color? ink; + + const _MessageCodeBlock({ + required this.name, + required this.code, + this.bubble = false, + this.ink, + }); @override Widget build(BuildContext context) { @@ -884,83 +922,101 @@ class _MessageCodeBlock extends HookWidget { }); } + final ink = this.ink; + // A fence's closing newline would otherwise render as a blank last line. + final shownCode = code.trimRight(); final codeBaseStyle = TextStyle( fontFamily: CodeStyle.fontFamily, fontSize: CodeStyle.fontSize, height: CodeStyle.lineHeight, - color: context.colors.onSurface, + color: ink ?? context.colors.onSurface, ); final isDark = context.theme.brightness == Brightness.dark; final codeTheme = isDark ? highlightDarkTheme : highlightLightTheme; + // Syntax colors are tuned for neutral cards and lose contrast on accents. final codeSpans = useMemoized( - () => highlightCode(code, name, codeTheme, codeBaseStyle), - [code, name, isDark], + () => ink == null + ? highlightCode(shownCode, name, codeTheme, codeBaseStyle) + : [TextSpan(text: shownCode)], + [code, name, isDark, ink], ); return Container( - margin: const EdgeInsets.only(top: Grid.half), + // In a bubble, the vertical margin brings the bubble's 8px vertical + // padding up to its 16px side padding, so the block is inset evenly and + // its corners stay concentric with the bubble's. + margin: bubble + ? const EdgeInsets.symmetric(vertical: Grid.xs - Grid.xxs) + : const EdgeInsets.only(top: Grid.half), decoration: BoxDecoration( - color: CodeStyle.background(context.colors), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: CodeStyle.border(context.colors)), + color: ink == null + ? CodeStyle.background(context.colors) + : chatCodeTint(ink, 0.14), + borderRadius: BorderRadius.circular( + bubble ? Radii.container - Grid.xs : 12, + ), + border: Border.all( + color: + ink?.withValues(alpha: 0.24) ?? CodeStyle.border(context.colors), + ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, + child: Stack( children: [ - if (name.isNotEmpty) - Padding( - padding: const EdgeInsets.only( - left: Grid.twelve, - top: Grid.half + Grid.quarter, - ), - child: Text( - name, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), + // Even 12px insets; the right side also reserves the copy button. + Padding( + padding: const EdgeInsets.fromLTRB( + Grid.twelve, + Grid.twelve, + Grid.twelve + 28, + Grid.twelve, ), - Stack( - children: [ - Padding( - padding: EdgeInsets.fromLTRB( - Grid.twelve, - name.isEmpty ? Grid.half + Grid.quarter : Grid.quarter, - 44, - Grid.half + Grid.quarter, - ), - child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (name.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: Grid.half), + child: Text( + name, + style: context.textTheme.labelSmall?.copyWith( + color: ink ?? context.colors.onSurfaceVariant, + ), + ), + ), + SingleChildScrollView( scrollDirection: Axis.horizontal, child: RichText( softWrap: false, text: TextSpan(style: codeBaseStyle, children: codeSpans), ), ), - ), - Positioned( - top: 0, - right: Grid.quarter, - child: SizedBox( - width: 28, - height: 28, - child: IconButton( - onPressed: handleCopy, - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - style: IconButton.styleFrom( - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - icon: Icon( - isCopied.value ? BuzzIcons.check : BuzzIcons.copy, - size: 14, - color: isCopied.value + ], + ), + ), + Positioned( + top: Grid.half + Grid.quarter, + right: Grid.half + Grid.quarter, + child: SizedBox( + width: 28, + height: 28, + child: IconButton( + onPressed: handleCopy, + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + style: IconButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: Icon( + isCopied.value ? BuzzIcons.check : BuzzIcons.copy, + size: 14, + color: + ink ?? + (isCopied.value ? context.colors.primary - : context.colors.onSurfaceVariant, - ), - ), + : context.colors.onSurfaceVariant), ), ), - ], + ), ), ], ), diff --git a/mobile/lib/features/channels/message_swipe_reply.dart b/mobile/lib/features/channels/message_swipe_reply.dart index e4448dfbe1..564d457711 100644 --- a/mobile/lib/features/channels/message_swipe_reply.dart +++ b/mobile/lib/features/channels/message_swipe_reply.dart @@ -139,11 +139,10 @@ class MessageSwipeReply extends HookConsumerWidget { child: child, builder: (context, child) { final progress = (offset.value / 72).clamp(0.0, 1.0); - final accent = context.appColors.accent; - final filledForeground = - ThemeData.estimateBrightnessForColor(accent) == Brightness.dark - ? Colors.white - : Colors.black; + // Follows the chosen message color, like the user's own bubbles. + final chatColors = ChatColors.of(context); + final accent = chatColors.outgoing; + final filledForeground = chatColors.foreground; return Stack( key: layoutKey, clipBehavior: Clip.none, diff --git a/mobile/lib/shared/theme/accent_colors.dart b/mobile/lib/shared/theme/accent_colors.dart index 0688a35754..f5708c83cc 100644 --- a/mobile/lib/shared/theme/accent_colors.dart +++ b/mobile/lib/shared/theme/accent_colors.dart @@ -79,6 +79,12 @@ const accentColors = [ dark: Color(0xFF818CF8), wireValue: '#6366f1', ), + AccentColor( + name: 'Chartreuse', + light: Color(0xFFD7D72E), + dark: Color(0xFFD7D72E), + wireValue: '#d7d72e', + ), ]; Color accentColorForScheme(ColorScheme scheme, int accentIndex) { diff --git a/mobile/lib/shared/theme/buzz_theme.dart b/mobile/lib/shared/theme/buzz_theme.dart index 22870ad3a5..d6b0eec984 100644 --- a/mobile/lib/shared/theme/buzz_theme.dart +++ b/mobile/lib/shared/theme/buzz_theme.dart @@ -100,3 +100,17 @@ LinearGradient? buzzTopSectionGradient( ], ); } + +/// Desktop navigation ink distinguishes read destinations from unread labels. +Color channelNavigationForeground(BuildContext context, {bool unread = false}) { + if (unread || Theme.of(context).brightness != Brightness.dark) { + return navigationPrimaryForeground(context); + } + return Theme.of(context).colorScheme.onSurfaceVariant; +} + +/// Metadata ink used by Home section headings and muted destinations. +Color navigationMetadataForeground(BuildContext context) => + Theme.of(context).brightness == Brightness.dark + ? const Color(0xFFA4A4A4) + : const Color(0xFF5F5F5F); diff --git a/mobile/lib/shared/theme/chat_colors.dart b/mobile/lib/shared/theme/chat_colors.dart index e2a939bc6e..8337194404 100644 --- a/mobile/lib/shared/theme/chat_colors.dart +++ b/mobile/lib/shared/theme/chat_colors.dart @@ -2,59 +2,82 @@ import 'package:flutter/material.dart'; import 'accent_colors.dart'; -/// Desktop message palette, in picker order. Wire values remain compatible -/// with existing community preferences; fills match buzz-app bubble tokens. +/// Desktop accent families in picker order. Fills are accent-9 from buzz-app's +/// design-system tokens (4ad00a8a). +/// +/// Wire values are desktop's v1 `community-theme` accents for the same color +/// family, so a synced preference means the same color on every client. The +/// cross-client mapping lives in `test-fixtures/community-theme-accents.json`. const chatColorChoices = [ AccentColor( - name: 'Neutral', - light: Color(0xFFE9E9EA), - dark: Color(0xFF2E2E2E), + name: 'Gray', + light: Color(0xFF525252), + dark: Color(0xFFC7C7C7), wireValue: 'neutral', ), AccentColor( - name: 'Blue', - light: Color(0xFF0077DB), - dark: Color(0xFF0077DB), - wireValue: '#3b82f6', + name: 'Chartreuse', + light: Color(0xFFD7D72E), + dark: Color(0xFFD7D72E), + wireValue: '#d7d72e', ), AccentColor( - name: 'Green', - light: Color(0xFF008934), - dark: Color(0xFF008934), - wireValue: '#22c55e', - ), - AccentColor( - name: 'Orange', - light: Color(0xFFC95100), - dark: Color(0xFFC95100), + name: 'Flame', + light: Color(0xFFD93A0C), + dark: Color(0xFFD93A0C), wireValue: '#f97316', ), AccentColor( - name: 'Pink', - light: Color(0xFFD33785), - dark: Color(0xFFD33785), + name: 'Magenta', + light: Color(0xFFCC1F7A), + dark: Color(0xFFCC1F7A), wireValue: '#ec4899', ), AccentColor( - name: 'Lilac', - light: Color(0xFFA94AD8), - dark: Color(0xFFA94AD8), + name: 'Iris', + light: Color(0xFF6C4EF0), + dark: Color(0xFF6C4EF0), wireValue: '#c0a2f1', ), + AccentColor( + name: 'Cobalt', + light: Color(0xFF2B4FE0), + dark: Color(0xFF2B4FE0), + wireValue: '#3b82f6', + ), + AccentColor( + name: 'Jade', + light: Color(0xFF0B8467), + dark: Color(0xFF0B8467), + wireValue: '#22c55e', + ), ]; -/// Resolves old community colors using desktop's retired-color aliases. +/// Resolves a v1 accent: each choice's own value, desktop's remaining legacy +/// values via their closest family, and family names written by earlier +/// preview builds. Anything else falls back to Chartreuse. int chatColorIndex(String wireValue) { final normalized = switch (wireValue) { - '#06b6d4' || '#6366f1' => '#3b82f6', + '#06b6d4' => '#22c55e', + '#6366f1' => '#3b82f6', '#ef4444' => '#ec4899', '#a855f7' => '#c0a2f1', _ => wireValue, }; final index = chatColorChoices.indexWhere( - (color) => color.wireValue == normalized, + (color) => + color.wireValue == normalized || color.name.toLowerCase() == normalized, ); - return index < 0 ? 0 : index; + return index < 0 ? 1 : index; +} + +/// Desktop-compatible value for a family name written by earlier preview +/// builds, so those preferences sync again once republished. +String? previewFamilyWireValue(String value) { + for (final color in chatColorChoices) { + if (color.name.toLowerCase() == value) return color.wireValue; + } + return null; } /// Resolves a picker swatch for the active appearance mode. @@ -62,3 +85,23 @@ Color chatColorForScheme(ColorScheme scheme, int index) { final color = chatColorChoices[index]; return scheme.brightness == Brightness.dark ? color.dark : color.light; } + +/// Bubble text is white unless white falls below 4.5:1 on the fill, as on +/// Chartreuse and dark-mode Gray; those light fills take black instead. +Color chatForegroundForScheme(ColorScheme scheme, int index) { + final fill = chatColorForScheme(scheme, index).computeLuminance(); + return 1.05 / (fill + .05) >= 4.5 ? Colors.white : Colors.black; +} + +/// Translucent code surface behind [ink] on an accent bubble. It shades toward +/// the opposite of the ink, so it can only raise contrast over the fill. +Color chatCodeTint(Color ink, double opacity) => + (ink == Colors.white ? Colors.black : Colors.white).withValues( + alpha: opacity, + ); + +/// Received messages use their own surface rather than a control fill. +Color receivedMessageSurface(Brightness brightness) => + brightness == Brightness.dark + ? const Color(0xFF292929) + : const Color(0xFFF3F3F4); diff --git a/mobile/lib/shared/theme/code_style.dart b/mobile/lib/shared/theme/code_style.dart index 3ed667f44c..bd562e9122 100644 --- a/mobile/lib/shared/theme/code_style.dart +++ b/mobile/lib/shared/theme/code_style.dart @@ -25,8 +25,7 @@ abstract final class CodeStyle { static final fontSizeFactor = fontSize / messageBodyTextStyle.fontSize!; /// Fill behind a code surface. - static Color background(ColorScheme scheme) => - scheme.surfaceContainerHighest.withValues(alpha: 0.6); + static Color background(ColorScheme scheme) => scheme.surfaceContainerHighest; /// Outline around a code surface. static Color border(ColorScheme scheme) => diff --git a/mobile/lib/shared/theme/color_scheme.dart b/mobile/lib/shared/theme/color_scheme.dart index be655ca493..0ec796736f 100644 --- a/mobile/lib/shared/theme/color_scheme.dart +++ b/mobile/lib/shared/theme/color_scheme.dart @@ -14,7 +14,7 @@ const lightColorScheme = ColorScheme( onSecondary: Color(0xFFFFFFFF), secondaryContainer: Color(0xFFF0F0F0), onSecondaryContainer: Color(0xFF0A0A0A), - tertiary: Color(0xFF0D74CE), + tertiary: Color(0xFF0B5FA8), onTertiary: Color(0xFFFFFFFF), tertiaryContainer: Color(0xFFE6F4FE), onTertiaryContainer: Color(0xFF113264), @@ -42,37 +42,37 @@ const lightColorScheme = ColorScheme( const darkColorScheme = ColorScheme( brightness: Brightness.dark, - primary: Color(0xFFE5E5E5), - onPrimary: Color(0xFF0F0F0F), - primaryContainer: Color(0xFF2A2A2A), - onPrimaryContainer: Color(0xFFF5F5F5), - secondary: Color(0xFFC1C1C1), - onSecondary: Color(0xFF0F0F0F), - secondaryContainer: Color(0xFF2A2A2A), - onSecondaryContainer: Color(0xFFF5F5F5), - tertiary: Color(0xFF70B8FF), - onTertiary: Color(0xFF0F0F0F), + primary: Color(0xFFFFFFFF), + onPrimary: Color(0xFF000000), + primaryContainer: Color(0xFF282828), + onPrimaryContainer: Color(0xFFFFFFFF), + secondary: Color(0xFFC7C7C7), + onSecondary: Color(0xFF000000), + secondaryContainer: Color(0xFF282828), + onSecondaryContainer: Color(0xFFFFFFFF), + tertiary: Color(0xFF83C4FF), + onTertiary: Color(0xFF000000), tertiaryContainer: Color(0xFF0D2847), onTertiaryContainer: Color(0xFFC2E6FF), error: Color(0xFFFF9592), onError: Color(0xFF191111), errorContainer: Color(0xFF3B1219), onErrorContainer: Color(0xFFFFD1D9), - surface: Color(0xFF1C1C1C), - onSurface: Color(0xFFF5F5F5), - onSurfaceVariant: Color(0xFFC1C1C1), - outline: Color(0xFF333333), - outlineVariant: Color(0xFF333333), - inverseSurface: Color(0xFFE5E5E5), - onInverseSurface: Color(0xFF0F0F0F), + surface: Color(0xFF000000), + onSurface: Color(0xFFFFFFFF), + onSurfaceVariant: Color(0xFFC7C7C7), + outline: Color(0xFF1A1A1A), + outlineVariant: Color(0xFF1A1A1A), + inverseSurface: Color(0xFFC7C7C7), + onInverseSurface: Color(0xFF000000), inversePrimary: Color(0xFF262626), shadow: Color(0xFF000000), scrim: Color(0xFF000000), - surfaceContainerLowest: Color(0xFF0F0F0F), - surfaceContainerLow: Color(0xFF161616), - surfaceContainer: Color(0xFF232323), - surfaceContainerHigh: Color(0xFF2A2A2A), - surfaceContainerHighest: Color(0xFF2A2A2A), + surfaceContainerLowest: Color(0xFF000000), + surfaceContainerLow: Color(0xFF101010), + surfaceContainer: Color(0xFF1A1A1A), + surfaceContainerHigh: Color(0xFF282828), + surfaceContainerHighest: Color(0xFF282828), surfaceTint: Colors.transparent, ); diff --git a/mobile/lib/shared/theme/community_theme_preference.dart b/mobile/lib/shared/theme/community_theme_preference.dart index 260c9520d4..3faa872215 100644 --- a/mobile/lib/shared/theme/community_theme_preference.dart +++ b/mobile/lib/shared/theme/community_theme_preference.dart @@ -4,13 +4,15 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'accent_colors.dart'; +import 'chat_colors.dart'; import 'theme_catalog.dart'; import 'theme_provider.dart' show effectiveTheme, schemeForAppearanceMode; const communityThemeDTag = 'community-theme'; const defaultCommunityTheme = CommunityThemePreference( theme: 'buzz', - accent: '#3b82f6', + // Seeded without a picker action, so it must stay readable by older clients. + accent: '#3b82f6', // Cobalt followSystem: true, ); @@ -32,13 +34,16 @@ class CommunityThemePreference { json['theme'] is! String || findTheme(json['theme'] as String) == null || json['accent'] is! String || - accentIndexForWireValue(json['accent'] as String) == null || + (accentIndexForWireValue(json['accent'] as String) == null && + previewFamilyWireValue(json['accent'] as String) == null) || json['followSystem'] is! bool) { throw const FormatException('Invalid community theme preference'); } return CommunityThemePreference( theme: json['theme'] as String, - accent: json['accent'] as String, + accent: + previewFamilyWireValue(json['accent'] as String) ?? + json['accent'] as String, followSystem: json['followSystem'] as bool, ); } @@ -157,7 +162,9 @@ class CommunityThemeStorage { }; return CommunityThemePreference( theme: resolvedTheme, - accent: legacyAccentWireValue(legacyAccent), + accent: legacyAccent == null + ? defaultCommunityTheme.accent + : legacyAccentWireValue(legacyAccent), followSystem: mode == ThemeMode.system, ); } diff --git a/mobile/lib/shared/theme/mobile_appearance.dart b/mobile/lib/shared/theme/mobile_appearance.dart index 2fdf8a34b1..b3a1587c2e 100644 --- a/mobile/lib/shared/theme/mobile_appearance.dart +++ b/mobile/lib/shared/theme/mobile_appearance.dart @@ -51,7 +51,7 @@ ThemeData mobileAppearanceTheme(Brightness brightness, String chatColor) { ...theme.extensions.values, ChatColors( outgoing: chatColorForScheme(theme.colorScheme, index), - foreground: index == 0 ? theme.colorScheme.onSurface : Colors.white, + foreground: chatForegroundForScheme(theme.colorScheme, index), ), ], ); diff --git a/mobile/lib/shared/theme/theme_extensions.dart b/mobile/lib/shared/theme/theme_extensions.dart index 77882f5cb3..92051e17e7 100644 --- a/mobile/lib/shared/theme/theme_extensions.dart +++ b/mobile/lib/shared/theme/theme_extensions.dart @@ -5,7 +5,7 @@ import 'app_colors.dart'; /// Shared neutral background for mobile controls and selected tabs. Color neutralControlSurface(Brightness brightness) => brightness == Brightness.dark - ? const Color(0xFF595959) + ? const Color(0xFF101010) : const Color(0xFFF0F0F0); extension AppThemeExtension on BuildContext { diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index ff77a0b220..c05a9a6b99 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -238,6 +238,7 @@ Widget _buildTestable({ ProfileNotifier? profileNotifier, String? signingPubkey, bool darkTheme = false, + String? chatColor, bool personalChat = false, required List messages, PresenceCacheNotifier? presenceCache, @@ -465,7 +466,14 @@ Widget _buildTestable({ ], child: MaterialApp( navigatorKey: navigatorKey, - theme: darkTheme ? AppTheme.dark() : AppTheme.light(), + theme: chatColor != null + ? mobileAppearanceTheme( + darkTheme ? Brightness.dark : Brightness.light, + chatColor, + ) + : darkTheme + ? AppTheme.dark() + : AppTheme.light(), builder: (context, child) => MediaQuery( data: MediaQuery.of(context).copyWith( textScaler: textScaler, diff --git a/mobile/test/features/channels/channel_detail_page_test/swipe_reply_tests.dart b/mobile/test/features/channels/channel_detail_page_test/swipe_reply_tests.dart index c5d9e08d70..e9502e18b2 100644 --- a/mobile/test/features/channels/channel_detail_page_test/swipe_reply_tests.dart +++ b/mobile/test/features/channels/channel_detail_page_test/swipe_reply_tests.dart @@ -16,6 +16,7 @@ void swipeReplyTests() { await tester.pumpWidget( _buildTestable( messages: events, + chatColor: 'magenta', home: thread ? ThreadDetailPage( threadHead: messages.first, @@ -86,7 +87,8 @@ void swipeReplyTests() { expect(tester.getRect(circle).left, initial.dx); final fill = tester.widget(circle).decoration as BoxDecoration; - expect(fill.color, tester.element(circle).appColors.accent); + // The armed circle uses the chosen message color, not the app accent. + expect(fill.color, const Color(0xFFCC1F7A)); expect(find.text('Reply'), findsOneWidget); final centerBeforePulse = tester.getCenter( find.byKey(const ValueKey('swipe-reply-feedback')), diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index fcd67f123c..9f0701889d 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -2,14 +2,18 @@ import 'package:buzz/features/home/home_page.dart'; import 'package:buzz/shared/widgets/app_route_observer.dart'; import 'package:buzz/features/pairing/pairing_provider.dart'; import 'package:buzz/shared/community/paired_community_landing.dart'; + import 'dart:async'; + import 'package:shared_preferences/shared_preferences.dart'; import '../profile/presence_snapshot_test.dart' show PresenceTestRelay, presenceEvent; + import 'dart:math'; import 'dart:io'; import 'dart:ui' as ui; + import 'package:buzz/shared/widgets/buzz_sheet_header.dart'; import 'package:flutter/rendering.dart'; import 'package:buzz/shared/community/community_membership_provider.dart'; @@ -340,6 +344,43 @@ void main() { ); }); + testWidgets('shows channel visibility icons and excludes Solo chats', ( + tester, + ) async { + final channels = [ + testChannels.first, + for (final type in ['stream', 'solo']) + Channel( + id: 'private-$type', + name: 'private-$type', + channelType: type, + visibility: 'private', + description: '', + createdBy: 'aabb', + createdAt: DateTime(2025), + memberCount: 1, + isMember: true, + ), + ]; + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(channels)), + ], + ), + ); + await tester.pumpAndSettle(); + + final publicIcon = find.byKey(const ValueKey('channel-icon-1')); + final privateIcon = find.byKey( + const ValueKey('channel-icon-private-stream'), + ); + expect(tester.widget(publicIcon).icon, BuzzIcons.hash); + expect(tester.widget(privateIcon).icon, BuzzIcons.lock); + expect(tester.getTopLeft(publicIcon).dx, tester.getTopLeft(privateIcon).dx); + expect(find.text('private-solo'), findsNothing); + }); + testWidgets('shows grouped channel list when data loads', (tester) async { // Valid fixture keys whose npub encodings were verified against the // NIP-19 codec independently of the code under test. @@ -454,7 +495,7 @@ void main() { Theme.of(tester.element(find.text(label))).colorScheme.onSurface, ); } - expect(find.byKey(const ValueKey('channel-icon-1')), findsNothing); + expect(find.byKey(const ValueKey('channel-icon-1')), findsOneWidget); final sectionTitle = tester.widget(find.text('Channels')); expect(sectionTitle.style?.fontSize, contentListTitleTextStyle.fontSize); expect(sectionTitle.style?.fontWeight, FontWeight.w400); @@ -679,7 +720,11 @@ void main() { ); await tester.pumpAndSettle(); - final lastChannel = tester.getRect(find.text('general')); + final lastChannel = tester + .getRect(find.text('general')) + .expandToInclude( + tester.getRect(find.byKey(const ValueKey('channel-icon-1'))), + ); final divider = tester.getRect(find.byType(Divider).last); final nextSectionHeader = tester.getRect(find.text('DMs')); @@ -1183,9 +1228,11 @@ void main() { expect(find.text('Community'), findsNothing); final sectionLabelX = tester.getTopLeft(find.text('Channels')).dx; final rowLabelX = tester.getTopLeft(find.text('general')).dx; - // The community title shares the leading row with its avatar. Channel - // labels stay aligned below it. - expect(sectionLabelX, rowLabelX); + final rowIconX = tester + .getTopLeft(find.byKey(const ValueKey('channel-icon-1'))) + .dx; + expect(sectionLabelX, rowIconX); + expect(rowLabelX, greaterThan(rowIconX)); relaySession.setReconnecting(); await tester.pump(); @@ -1202,7 +1249,7 @@ void main() { find.byKey(const Key('channels-skeleton-row-label-0')).first, ) .dx; - expect(skeletonSectionLabelX, skeletonRowLabelX); + expect(skeletonRowLabelX, rowLabelX); expect(skeletonSectionLabelX, sectionLabelX); }); @@ -3737,6 +3784,7 @@ void main() { await tester.pumpWidget( buildTestable( + brightness: Brightness.dark, overrides: [ channelsProvider.overrideWith( () => _FakeNotifier( @@ -3761,6 +3809,19 @@ void main() { Theme.of(tester.element(find.text('general'))).colorScheme.onSurface, ); + expect( + tester.widget(find.text('Channels')).style?.color, + const Color(0xFFA4A4A4), + ); + final options = find.descendant( + of: find.byTooltip('Channels options'), + matching: find.byIcon(BuzzIcons.ellipsisVertical), + ); + expect(tester.widget(options).color, const Color(0xFFC7C7C7)); + expect( + tester.widget(find.byKey(const ValueKey('channel-icon-1'))).color, + const Color(0xFFC7C7C7), + ); readState.markContextRead('1', 20); await tester.pump(); @@ -3768,6 +3829,10 @@ void main() { tester.widget(find.text('general')).style?.fontWeight, FontWeight.w400, ); + expect( + tester.widget(find.text('general')).style?.color, + const Color(0xFFC7C7C7), + ); }); testWidgets('bolds channels with unread thread activity without a badge', ( diff --git a/mobile/test/features/channels/chat_color_test.dart b/mobile/test/features/channels/chat_color_test.dart index ba4ea4b7ea..44fd5df60f 100644 --- a/mobile/test/features/channels/chat_color_test.dart +++ b/mobile/test/features/channels/chat_color_test.dart @@ -2,6 +2,7 @@ import 'package:buzz/features/channels/local_send_bubble_surface.dart'; import 'package:buzz/features/channels/message_content.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:flutter/material.dart'; +import 'package:gpt_markdown/gpt_markdown.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -37,7 +38,7 @@ void main() { ) .toList(); expect(bubbles[0].color, theme.extension()!.outgoing); - expect(bubbles[1].color, theme.colorScheme.secondaryContainer); + expect(bubbles[1].color, receivedMessageSurface(brightness)); final text = tester .widgetList(find.byType(RichText)) .firstWhere((text) => text.text.toPlainText() == 'My message'); @@ -59,6 +60,130 @@ void main() { }); } + for (final brightness in Brightness.values) { + for (final choice in chatColorChoices) { + testWidgets('markdown stays readable on ${choice.name} in $brightness', ( + tester, + ) async { + final theme = mobileAppearanceTheme(brightness, choice.wireValue); + final ink = theme.extension()!.foreground; + expect( + ink, + chatForegroundForScheme( + theme.colorScheme, + chatColorIndex(choice.wireValue), + ), + ); + final expectedLink = ink; + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: theme, + home: const AppMarkdownTheme( + child: Scaffold( + body: SingleChildScrollView( + child: MessageContent( + content: + '# Heading\n\n[Link](https://example.com) and `code`\n\n' + '> Quote\n\n| Header |\n| --- |\n| Cell |\n\n' + '```dart\nfinal x = 1;\n```', + bubble: true, + ownMessage: true, + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final markdown = find.byType(GptMarkdown); + final context = tester.element(markdown); + final data = GptMarkdownTheme.of(context); + expect(data.h1?.color, ink); + expect(data.linkColor, expectedLink); + expect(data.inlineCode.color, ink); + expect(Theme.of(context).colorScheme.primary, expectedLink); + final sheet = tester.widget(markdown).styleSheet!; + expect(sheet.blockQuote?.barColor, ink); + expect(sheet.table?.headerTextStyle?.color, ink); + // Code ink must stay readable on the composited tint, not just the fill. + final codes = []; + for (final rich in tester.widgetList( + find.byWidgetPredicate((widget) => widget is RichText), + )) { + rich.text.visitChildren((span) { + if (span is CodeTextSpan && span.text == 'code') codes.add(span); + return true; + }); + } + for (final label in ['Heading', 'Quote', 'Header', 'Cell']) { + final renderedColors = []; + void inspect(InlineSpan span, TextStyle? inherited) { + final effective = inherited?.merge(span.style) ?? span.style; + if (span is TextSpan) { + if (span.text?.trim() == label) { + renderedColors.add(effective?.color); + } + for (final child in span.children ?? const []) { + inspect(child, effective); + } + } + } + + for (final rich in tester.widgetList( + find.byWidgetPredicate((widget) => widget is RichText), + )) { + inspect(rich.text, null); + } + expect(renderedColors, isNotEmpty, reason: label); + expect(renderedColors, everyElement(ink), reason: label); + } + expect(codes, hasLength(1)); + expect(codes.single.style?.color, ink); + final fill = theme.extension()!.outgoing; + expect( + _contrast( + ink, + Color.alphaBlend(codes.single.codeStyle.backgroundColor!, fill), + ), + greaterThanOrEqualTo(4.5), + reason: 'inline code', + ); + final label = find.text('dart'); + final block = tester.widget( + find + .ancestor( + of: label, + matching: find.byWidgetPredicate( + (widget) => + widget is Container && + widget.decoration is BoxDecoration && + (widget.decoration! as BoxDecoration).borderRadius != + null, + ), + ) + .first, + ); + final blockSurface = Color.alphaBlend( + (block.decoration! as BoxDecoration).color!, + fill, + ); + for (final (name, color) in [ + ('code block', ink), + ('language label', tester.widget(label).style!.color!), + ]) { + expect( + _contrast(Color.alphaBlend(color, blockSurface), blockSurface), + greaterThanOrEqualTo(4.5), + reason: name, + ); + } + expect(tester.takeException(), isNull); + }); + } + } + testWidgets('System follows platform brightness with the same chat choice', ( tester, ) async { @@ -88,3 +213,9 @@ void main() { } }); } + +double _contrast(Color a, Color b) { + final la = a.computeLuminance(); + final lb = b.computeLuminance(); + return la > lb ? (la + .05) / (lb + .05) : (lb + .05) / (la + .05); +} diff --git a/mobile/test/features/channels/message_content_bubble_cases.dart b/mobile/test/features/channels/message_content_bubble_cases.dart index c4a91bdb75..6179c51ac6 100644 --- a/mobile/test/features/channels/message_content_bubble_cases.dart +++ b/mobile/test/features/channels/message_content_bubble_cases.dart @@ -57,7 +57,7 @@ void bubbleContentTests() { surface.color, own ? theme.extension()!.outgoing - : theme.colorScheme.secondaryContainer, + : receivedMessageSurface(brightness), ); } } @@ -105,11 +105,9 @@ void bubbleContentTests() { inspect(text.text, null); expect(colors, [foreground]); } - final linkColor = foreground == Colors.white - ? foreground - : dark - ? const Color(0xFF83C4FF) - : const Color(0xFF0B5FA8); + final linkColor = ChatColors.of( + tester.element(find.byType(MessageContent)), + ).foreground; for (final label in ['Alice', 'Helper']) { expect(tester.widget(find.text(label)).style?.color, linkColor); } diff --git a/mobile/test/features/settings/theme_picker_page_test.dart b/mobile/test/features/settings/theme_picker_page_test.dart index bad4a8c4cf..4e90b17b1f 100644 --- a/mobile/test/features/settings/theme_picker_page_test.dart +++ b/mobile/test/features/settings/theme_picker_page_test.dart @@ -66,7 +66,9 @@ void main() { ); await tester.pumpAndSettle(); expect(find.text('Chat color'), findsOneWidget); - final green = chatColorChoices.indexWhere((color) => color.name == 'Green'); + final green = chatColorChoices.indexWhere((color) => color.name == 'Jade'); + await tester.ensureVisible(find.byKey(ValueKey('accent-option-$green'))); + await tester.pumpAndSettle(); await tester.tap(find.byKey(ValueKey('accent-option-$green'))); await tester.pumpAndSettle(); expect(notifier.preferenceWrites, 0); @@ -126,7 +128,7 @@ void main() { .color; final surface = surfaceColor(); final fills = []; - for (final name in ['Blue', 'Green', 'Neutral']) { + for (final name in ['Cobalt', 'Jade', 'Chartreuse', 'Gray']) { await tester.tap( find.byKey(const ValueKey('theme-preview-accent-action-button')), ); @@ -135,6 +137,8 @@ void main() { (color) => color.name == name, ); final option = find.byKey(ValueKey('accent-option-$index')); + await tester.ensureVisible(option); + await tester.pumpAndSettle(); await tester.tap(option); await tester.pumpAndSettle(); Navigator.of(tester.element(option)).pop(); @@ -150,7 +154,7 @@ void main() { expect(notifier.preferenceWrites, 0); fills.add(bubbleColor()); } - expect(fills.toSet(), hasLength(3)); + expect(fills.toSet(), hasLength(4)); expect(tester.takeException(), isNull); }); } diff --git a/mobile/test/shared/theme/chat_colors_test.dart b/mobile/test/shared/theme/chat_colors_test.dart index cb2b320b63..492d29618c 100644 --- a/mobile/test/shared/theme/chat_colors_test.dart +++ b/mobile/test/shared/theme/chat_colors_test.dart @@ -1,61 +1,122 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:buzz/shared/theme/theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - test('matches the six desktop choices and bubble fills in order', () { + test('matches all seven desktop accent fills and readable inks', () { expect(chatColorChoices.map((color) => color.name), [ - 'Neutral', - 'Blue', - 'Green', - 'Orange', - 'Pink', - 'Lilac', + 'Gray', + 'Chartreuse', + 'Flame', + 'Magenta', + 'Iris', + 'Cobalt', + 'Jade', ]); expect(chatColorChoices.map((color) => color.light.toARGB32()), [ - 0xFFE9E9EA, - 0xFF0077DB, - 0xFF008934, - 0xFFC95100, - 0xFFD33785, - 0xFFA94AD8, + 0xFF525252, + 0xFFD7D72E, + 0xFFD93A0C, + 0xFFCC1F7A, + 0xFF6C4EF0, + 0xFF2B4FE0, + 0xFF0B8467, ]); expect(chatColorChoices.map((color) => color.dark.toARGB32()), [ - 0xFF2E2E2E, - 0xFF0077DB, - 0xFF008934, - 0xFFC95100, - 0xFFD33785, - 0xFFA94AD8, + 0xFFC7C7C7, + 0xFFD7D72E, + 0xFFD93A0C, + 0xFFCC1F7A, + 0xFF6C4EF0, + 0xFF2B4FE0, + 0xFF0B8467, ]); + for (final brightness in Brightness.values) { + final scheme = brightness == Brightness.dark + ? darkColorScheme + : lightColorScheme; + for (var index = 0; index < chatColorChoices.length; index++) { + final ink = chatForegroundForScheme(scheme, index); + final a = ink.computeLuminance(); + final b = chatColorForScheme(scheme, index).computeLuminance(); + final contrast = a > b ? (a + .05) / (b + .05) : (b + .05) / (a + .05); + expect( + contrast, + greaterThanOrEqualTo(4.5), + reason: '${chatColorChoices[index].name} $brightness', + ); + // Only the light fills fall back to black: Chartreuse and dark Gray. + final black = index == 1 || (index == 0 && scheme == darkColorScheme); + expect(ink, black ? Colors.black : Colors.white); + } + } }); - test( - 'retired saved colors follow desktop aliases without losing preferences', - () { - for (final entry in { - '#06b6d4': 'Blue', - '#6366f1': 'Blue', - '#ef4444': 'Pink', - '#a855f7': 'Lilac', - }.entries) { - expect(chatColorChoices[chatColorIndex(entry.key)].name, entry.value); - final preference = CommunityThemePreference.fromJson({ + test('accents follow the cross-client v1 contract', () { + // Desktop's parser test reads the same fixture, so both clients agree on + // every value either can save. + final contract = + jsonDecode( + File( + '../test-fixtures/community-theme-accents.json', + ).readAsStringSync(), + ) + as Map; + CommunityThemePreference parse(String accent) => + CommunityThemePreference.fromJson({ 'version': 1, 'theme': 'buzz-dark', - 'accent': entry.key, + 'accent': accent, 'followSystem': false, }); - expect(preference.mode, ThemeMode.dark); - expect(preference.accent, entry.key); - expect( - mobileAppearanceTheme( - Brightness.dark, - preference.accent, - ).extension()!.outgoing, - chatColorChoices[chatColorIndex(entry.key)].dark, - ); - } - }, - ); + + final emitted = (contract['emitted'] as List).cast>(); + expect( + emitted.map((entry) => entry['family']), + chatColorChoices.map((color) => color.name), + ); + for (final (index, entry) in emitted.indexed) { + final accent = entry['accent'] as String; + expect(chatColorChoices[index].wireValue, accent); + // Family names from earlier preview builds republish as the v1 value. + expect(parse(chatColorChoices[index].name.toLowerCase()).accent, accent); + } + // Older clients reject an unknown accent with the whole preference, so + // anything published without a picker action stays in their set. + final baseReaders = ((contract['baseReaders'] as Map)['accepted'] as List) + .cast(); + for (final automatic in [ + defaultCommunityTheme.accent, + for (final index in [null, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8]) + legacyAccentWireValue(index), + ]) { + expect(baseReaders, contains(automatic)); + } + + final legacy = (contract['legacy'] as List).cast>(); + for (final entry in [...emitted, ...legacy]) { + final accent = entry['accent'] as String; + final preference = parse(accent); + expect(preference.mode, ThemeMode.dark); + expect(preference.accent, accent); + final family = chatColorChoices.firstWhere( + (color) => color.name == entry['family'], + ); + expect( + mobileAppearanceTheme( + Brightness.dark, + preference.accent, + ).extension()!.outgoing, + family.dark, + reason: accent, + ); + } + + for (final accent in (contract['rejected'] as List).cast()) { + expect(() => parse(accent), throwsFormatException, reason: accent); + } + }); } diff --git a/mobile/test/shared/theme/mobile_appearance_test.dart b/mobile/test/shared/theme/mobile_appearance_test.dart index e01b56c829..b220764474 100644 --- a/mobile/test/shared/theme/mobile_appearance_test.dart +++ b/mobile/test/shared/theme/mobile_appearance_test.dart @@ -10,10 +10,10 @@ void main() { expect(light.colorScheme.onSurface, const Color(0xFF0A0A0A)); expect(light.colorScheme.onSurfaceVariant, const Color(0xFF646464)); expect(light.colorScheme.outline, const Color(0xFFE8E8E8)); - expect(dark.colorScheme.surface, const Color(0xFF1C1C1C)); - expect(dark.colorScheme.onSurface, const Color(0xFFF5F5F5)); - expect(dark.colorScheme.onSurfaceVariant, const Color(0xFFC1C1C1)); - expect(dark.colorScheme.outline, const Color(0xFF333333)); + expect(dark.colorScheme.surface, const Color(0xFF000000)); + expect(dark.colorScheme.onSurface, const Color(0xFFFFFFFF)); + expect(dark.colorScheme.onSurfaceVariant, const Color(0xFFC7C7C7)); + expect(dark.colorScheme.outline, const Color(0xFF1A1A1A)); }); for (final brightness in Brightness.values) { diff --git a/test-fixtures/community-theme-accents.json b/test-fixtures/community-theme-accents.json new file mode 100644 index 0000000000..6b93d739e8 --- /dev/null +++ b/test-fixtures/community-theme-accents.json @@ -0,0 +1,83 @@ +{ + "emitted": [ + { + "family": "Gray", + "accent": "neutral", + "desktop": "Neutral" + }, + { + "family": "Chartreuse", + "accent": "#d7d72e", + "desktop": "Chartreuse" + }, + { + "family": "Flame", + "accent": "#f97316", + "desktop": "Orange" + }, + { + "family": "Magenta", + "accent": "#ec4899", + "desktop": "Pink" + }, + { + "family": "Iris", + "accent": "#c0a2f1", + "desktop": "Lilac" + }, + { + "family": "Cobalt", + "accent": "#3b82f6", + "desktop": "Blue" + }, + { + "family": "Jade", + "accent": "#22c55e", + "desktop": "Green" + } + ], + "legacy": [ + { + "accent": "#06b6d4", + "desktop": "Cyan", + "family": "Jade" + }, + { + "accent": "#ef4444", + "desktop": "Red", + "family": "Magenta" + }, + { + "accent": "#a855f7", + "desktop": "Purple", + "family": "Iris" + }, + { + "accent": "#6366f1", + "desktop": "Indigo", + "family": "Cobalt" + } + ], + "rejected": [ + "url(image)", + "#123456", + "Chartreuse", + "cyan", + "" + ], + "baseReaders": { + "note": "v1 accents accepted by Mobile and Desktop before Chartreuse; anything a client publishes without a picker action must stay in this set", + "accepted": [ + "neutral", + "#3b82f6", + "#06b6d4", + "#22c55e", + "#f97316", + "#ef4444", + "#ec4899", + "#c0a2f1", + "#a855f7", + "#6366f1" + ] + } +}