fix(mobile): restore channel icons and align chat colors with desktop (#8478)

## 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 <klopez4212@gmail.com>
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 <noreply@anthropic.com>
This commit is contained in:
klopez4212
2026-10-20 13:48:12 -04:00
committed by GitHub
parent 0b53fc699d
commit 5fefec604f
26 changed files with 869 additions and 212 deletions

View File

@@ -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<string>(["#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);

View File

@@ -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);
}
}
});

View File

@@ -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 = {

View File

@@ -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",

View File

@@ -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),
),
),
),

View File

@@ -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<String>(
@@ -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<String>(
@@ -551,7 +547,10 @@ class _SectionLabel extends StatelessWidget {
),
),
const SizedBox(width: Grid.quarter),
_SectionChevron(expanded: expanded, color: color),
_SectionChevron(
expanded: expanded,
color: navigationSecondaryForeground(context),
),
],
);
}

View File

@@ -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],

View File

@@ -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),
),
),
],
),
),
],
),

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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);

View File

@@ -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) =>

View File

@@ -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,
);

View File

@@ -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,
);
}

View File

@@ -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),
),
],
);

View File

@@ -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 {

View File

@@ -238,6 +238,7 @@ Widget _buildTestable({
ProfileNotifier? profileNotifier,
String? signingPubkey,
bool darkTheme = false,
String? chatColor,
bool personalChat = false,
required List<NostrEvent> 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,

View File

@@ -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<DecoratedBox>(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')),

View File

@@ -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<Icon>(publicIcon).icon, BuzzIcons.hash);
expect(tester.widget<Icon>(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<Text>(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<Text>(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<Icon>(options).color, const Color(0xFFC7C7C7));
expect(
tester.widget<Icon>(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<Text>(find.text('general')).style?.fontWeight,
FontWeight.w400,
);
expect(
tester.widget<Text>(find.text('general')).style?.color,
const Color(0xFFC7C7C7),
);
});
testWidgets('bolds channels with unread thread activity without a badge', (

View File

@@ -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<ChatColors>()!.outgoing);
expect(bubbles[1].color, theme.colorScheme.secondaryContainer);
expect(bubbles[1].color, receivedMessageSurface(brightness));
final text = tester
.widgetList<RichText>(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<ChatColors>()!.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<GptMarkdown>(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 = <CodeTextSpan>[];
for (final rich in tester.widgetList<RichText>(
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 = <Color?>[];
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 <InlineSpan>[]) {
inspect(child, effective);
}
}
}
for (final rich in tester.widgetList<RichText>(
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<ChatColors>()!.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<Container>(
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<Text>(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);
}

View File

@@ -57,7 +57,7 @@ void bubbleContentTests() {
surface.color,
own
? theme.extension<ChatColors>()!.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<Text>(find.text(label)).style?.color, linkColor);
}

View File

@@ -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 = <Color>[];
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);
});
}

View File

@@ -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<String, dynamic>;
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<ChatColors>()!.outgoing,
chatColorChoices[chatColorIndex(entry.key)].dark,
);
}
},
);
final emitted = (contract['emitted'] as List).cast<Map<String, dynamic>>();
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<String>();
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<Map<String, dynamic>>();
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<ChatColors>()!.outgoing,
family.dark,
reason: accent,
);
}
for (final accent in (contract['rejected'] as List).cast<String>()) {
expect(() => parse(accent), throwsFormatException, reason: accent);
}
});
}

View File

@@ -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) {

View File

@@ -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"
]
}
}