fix(app): preserve host badge identity
Keep host badges visible under row pressure and apply the selected host color consistently to the icon, label, and tinted background.
This commit is contained in:
@@ -129,6 +129,7 @@ test.describe("Host appearance", () => {
|
|||||||
expect(titleBox).not.toBeNull();
|
expect(titleBox).not.toBeNull();
|
||||||
expect(rowBox).not.toBeNull();
|
expect(rowBox).not.toBeNull();
|
||||||
expect(kebabBox).not.toBeNull();
|
expect(kebabBox).not.toBeNull();
|
||||||
|
await expect(badge).toHaveCSS("flex-shrink", "0");
|
||||||
expect(titleBox!.width).toBeGreaterThan(0);
|
expect(titleBox!.width).toBeGreaterThan(0);
|
||||||
expect(kebabBox!.x + kebabBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width);
|
expect(kebabBox!.x + kebabBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -144,6 +144,32 @@ export async function selectAllHostsFilter(page: Page): Promise<void> {
|
|||||||
await page.getByTestId("sidebar-host-filter-all").click();
|
await page.getByTestId("sidebar-host-filter-all").click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Playwright reports resolved colors, so the expected value is derived from the same identity
|
||||||
|
// table the app reads. A hex literal in the test would be a second copy of the palette.
|
||||||
|
function toRgb(hex: string): string {
|
||||||
|
const { r, g, b } = parseHex(hex);
|
||||||
|
return `rgb(${r}, ${g}, ${b})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRgba(hex: string, alpha: number): string {
|
||||||
|
const { r, g, b } = parseHex(hex);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RgbChannels {
|
||||||
|
r: number;
|
||||||
|
g: number;
|
||||||
|
b: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHex(hex: string): RgbChannels {
|
||||||
|
return {
|
||||||
|
r: Number.parseInt(hex.slice(1, 3), 16),
|
||||||
|
g: Number.parseInt(hex.slice(3, 5), 16),
|
||||||
|
b: Number.parseInt(hex.slice(5, 7), 16),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function openHostAppearanceSettings(page: Page, serverId: string): Promise<void> {
|
export async function openHostAppearanceSettings(page: Page, serverId: string): Promise<void> {
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
await selectSettingsHost(page, serverId);
|
await selectSettingsHost(page, serverId);
|
||||||
@@ -222,7 +248,11 @@ export async function expectHostBadgeTinted(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const badge = hostBadge(page, target);
|
const badge = hostBadge(page, target);
|
||||||
await expect(badge).toBeVisible({ timeout: 15_000 });
|
await expect(badge).toBeVisible({ timeout: 15_000 });
|
||||||
await expect(badge.getByText(target.hostName)).toHaveCSS("color", "rgb(113, 113, 122)");
|
await expect(badge.getByText(target.hostName)).toHaveCSS(
|
||||||
|
"color",
|
||||||
|
toRgb(identityColor(target.color)),
|
||||||
|
);
|
||||||
|
await expect(badge).toHaveCSS("background-color", toRgba(identityColor(target.color), 0.1));
|
||||||
await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(target.color));
|
await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(target.color));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +264,11 @@ export async function expectHostAppearancePreview(
|
|||||||
await expect(preview).toBeVisible();
|
await expect(preview).toBeVisible();
|
||||||
const badge = preview.getByTestId(`sidebar-host-badge-${input.serverId}`);
|
const badge = preview.getByTestId(`sidebar-host-badge-${input.serverId}`);
|
||||||
await expect(badge).toHaveText(input.hostName);
|
await expect(badge).toHaveText(input.hostName);
|
||||||
|
await expect(badge.getByText(input.hostName)).toHaveCSS(
|
||||||
|
"color",
|
||||||
|
toRgb(identityColor(input.color)),
|
||||||
|
);
|
||||||
|
await expect(badge).toHaveCSS("background-color", toRgba(identityColor(input.color), 0.1));
|
||||||
await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(input.color));
|
await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(input.color));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,32 @@
|
|||||||
import { Text, View } from "react-native";
|
import { Text, View, type TextStyle, type ViewStyle } from "react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { Server } from "lucide-react-native";
|
import { Server } from "lucide-react-native";
|
||||||
import { HOST_COLORS, type HostColor } from "@/hosts/appearance";
|
import { HOST_COLORS, type HostColor } from "@/hosts/appearance";
|
||||||
import { identityColor } from "@/styles/identity-colors";
|
import { identityColor, identityTint } from "@/styles/identity-colors";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
|
|
||||||
const ThemedServer = withUnistyles(Server);
|
const ThemedServer = withUnistyles(Server);
|
||||||
const MAX_NAMED_BADGE_WIDTH = 96;
|
const MAX_NAMED_BADGE_WIDTH = 96;
|
||||||
|
|
||||||
|
interface HostBadgeColorOverride {
|
||||||
|
badge: ViewStyle | null;
|
||||||
|
text: TextStyle | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildColorOverrides(): Record<HostColor, HostBadgeColorOverride> {
|
||||||
|
const byColor = {} as Record<HostColor, HostBadgeColorOverride>;
|
||||||
|
for (const color of HOST_COLORS) {
|
||||||
|
byColor[color] =
|
||||||
|
color === "none"
|
||||||
|
? { badge: null, text: null }
|
||||||
|
: {
|
||||||
|
badge: { backgroundColor: identityTint(color) },
|
||||||
|
text: { color: identityColor(color) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return byColor;
|
||||||
|
}
|
||||||
|
|
||||||
// The identity table is theme-independent, so its icon mapping is fixed for the life of the
|
// The identity table is theme-independent, so its icon mapping is fixed for the life of the
|
||||||
// module. It must not be rebuilt per render: a fresh `uniProps` identity makes withUnistyles
|
// module. It must not be rebuilt per render: a fresh `uniProps` identity makes withUnistyles
|
||||||
// re-subscribe every pass.
|
// re-subscribe every pass.
|
||||||
@@ -22,6 +41,7 @@ function buildIconMappings(): Record<HostColor, (theme: Theme) => { color: strin
|
|||||||
return byColor;
|
return byColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COLOR_OVERRIDES = buildColorOverrides();
|
||||||
const ICON_MAPPINGS = buildIconMappings();
|
const ICON_MAPPINGS = buildIconMappings();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,14 +61,19 @@ export function HostBadge({
|
|||||||
color: HostColor;
|
color: HostColor;
|
||||||
showLabel: boolean;
|
showLabel: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const override = COLOR_OVERRIDES[color];
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[hostBadgeStyles.badge, showLabel ? null : hostBadgeStyles.badgeIconOnly]}
|
style={[
|
||||||
|
hostBadgeStyles.badge,
|
||||||
|
showLabel ? null : hostBadgeStyles.badgeIconOnly,
|
||||||
|
override.badge,
|
||||||
|
]}
|
||||||
testID={`sidebar-host-badge-${serverId}`}
|
testID={`sidebar-host-badge-${serverId}`}
|
||||||
>
|
>
|
||||||
<ThemedServer size={9} uniProps={ICON_MAPPINGS[color]} />
|
<ThemedServer size={9} uniProps={ICON_MAPPINGS[color]} />
|
||||||
{showLabel ? (
|
{showLabel ? (
|
||||||
<Text style={hostBadgeStyles.text} numberOfLines={1}>
|
<Text style={[hostBadgeStyles.text, override.text]} numberOfLines={1}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -71,7 +96,7 @@ const hostBadgeStyles = StyleSheet.create((theme) => ({
|
|||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
maxWidth: MAX_NAMED_BADGE_WIDTH,
|
maxWidth: MAX_NAMED_BADGE_WIDTH,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
flexShrink: 1,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
// Icon-only drops the label, so the asymmetric padding that made room for text leaves a
|
// Icon-only drops the label, so the asymmetric padding that made room for text leaves a
|
||||||
// lopsided pill. Symmetric padding recenters the icon.
|
// lopsided pill. Symmetric padding recenters the icon.
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ export function identityColor(name: IdentityColorName): string {
|
|||||||
return IDENTITY_COLORS[name];
|
return IDENTITY_COLORS[name];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The same hue at 10% alpha, for fills that sit behind text of the full-strength color. */
|
||||||
|
export function identityTint(name: IdentityColorName): string {
|
||||||
|
return `${IDENTITY_COLORS[name]}1a`;
|
||||||
|
}
|
||||||
|
|
||||||
function hashIdentityKey(key: string): number {
|
function hashIdentityKey(key: string): number {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (const character of key) {
|
for (const character of key) {
|
||||||
|
|||||||
Reference in New Issue
Block a user