feat(app): polish compact header, composer, and settings menu (#7129)

Header islands lose their filled background and shadow; the round tap
highlight stays. The floating header's fade now carries the icon contrast
the island fill used to give, so it stays solid through the header and
runs a longer tail into the content. The fade lives inside the overlay
because Android clips children that overhang their parent, and keeps
three gradient stops because Android rendered a four-stop version far
more transparent than specified.

Single-line header titles read one step larger. The compact composer
gets a soft drop shadow. The compact settings root groups App and Host
into islands of tiles, driven by breakpoint style values so desktop
keeps its plain list.
This commit is contained in:
Mohamed Boudra
2026-10-16 18:05:11 +07:00
committed by GitHub
parent 6017706cc1
commit 8d9a7123ba
6 changed files with 167 additions and 92 deletions
@@ -50,11 +50,13 @@ async function openNamedWorkspace(page: Page, names: typeof labels.long) {
}
async function expectHeaderContentsFit(page: Page, info: TestInfo, long: boolean) {
const menu = page.getByTestId("menu-button").filter({ visible: true });
const title = page.getByTestId("workspace-header-title");
const project = page.getByTestId("workspace-header-subtitle");
const badge = page.getByTestId(`host-badge-${getServerId()}`).filter({ visible: true });
const trigger = page.getByRole("button", { name: /Switch tabs/ });
const measure = async () => ({
menu: await menu.boundingBox(),
title: await title.boundingBox(),
project: await project.boundingBox(),
badge: await badge.boundingBox(),
@@ -66,23 +68,21 @@ async function expectHeaderContentsFit(page: Page, info: TestInfo, long: boolean
await page.screenshot({ path: info.outputPath("header.png") });
await expect(async () => {
const bounds = await measure();
expect(bounds.menu).not.toBeNull();
expect(bounds.title).not.toBeNull();
expect(bounds.project).not.toBeNull();
expect(bounds.badge).not.toBeNull();
expect(bounds.icon).not.toBeNull();
expect(bounds.trigger).not.toBeNull();
// Approved geometry: 12px outside margin, 46px leading island, 8px title gap.
expect(bounds.title!.x).toBe(66);
// The labels sit between the menu button and the actions without overlapping either.
expect(bounds.title!.x).toBeGreaterThan(bounds.menu!.x + bounds.menu!.width);
expect(bounds.project!.x).toBe(bounds.title!.x);
expect(bounds.trigger!.width).toBe(32);
expect(bounds.trigger!.height).toBe(32);
// The action frame starts 7px inside its island; preserve the 8px gap before it.
const contentRight = bounds.trigger!.x - 15;
for (const box of [bounds.title!, bounds.project!, bounds.badge!, bounds.icon!]) {
expect(box.x + box.width).toBeLessThanOrEqual(contentRight + 1);
expect(box.x + box.width).toBeLessThan(bounds.trigger!.x);
}
expect(bounds.project!.width).toBeGreaterThan(20);
expect(bounds.icon!.width).toBe(12);
// Long labels truncate instead of squeezing the host icon.
expect(bounds.icon!.width).toBe(bounds.icon!.height);
expect(bounds.badge!.x).toBeGreaterThan(bounds.project!.x + bounds.project!.width);
expect(bounds.titleTruncated).toBe(long);
expect(bounds.projectTruncated).toBe(long);
@@ -1,11 +1,12 @@
import { useCallback, useId, useState, type ReactNode } from "react";
import { useCallback, useState, type ReactNode } from "react";
import { View, type LayoutChangeEvent } from "react-native";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { StyleSheet } from "react-native-unistyles";
import { EdgeFade } from "@/components/ui/edge-fade";
/** How far the fade runs past the header's bottom edge, so content dissolves instead of clipping. */
const FADE_OVERHANG = 24;
const FADE_OVERHANG = 48;
/** Share of the fade that stays opaque, so the header's title and icons keep contrast. */
const FADE_SOLID_FRACTION = 0.57;
interface FloatingHeaderLayoutProps {
header: ReactNode;
@@ -29,51 +30,22 @@ export function FloatingHeaderLayout({ header, floating, children }: FloatingHea
{floating ? null : header}
<View style={styles.content}>{children(floating ? headerHeight : 0)}</View>
{floating ? (
<View style={styles.overlay} pointerEvents="box-none" onLayout={handleHeaderLayout}>
<FloatingHeaderFade />
{header}
<View style={styles.overlay} pointerEvents="box-none">
<EdgeFade
edge="top"
solidFraction={FADE_SOLID_FRACTION}
backdrop="surfaceWorkspace"
style={styles.fade}
/>
<View pointerEvents="box-none" onLayout={handleHeaderLayout}>
{header}
</View>
</View>
) : null}
</View>
);
}
function FloatingHeaderFadeSvg({ gradientId, color }: { gradientId: string; color: string }) {
return (
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="0%" y2="100%">
{/* Vary opacity rather than interpolating toward `transparent`, which crosses black in
some engines and leaves a grey fringe. */}
<Stop offset="0%" stopColor={color} stopOpacity={1} />
<Stop offset="50%" stopColor={color} stopOpacity={0.97} />
<Stop offset="75%" stopColor={color} stopOpacity={0.8} />
<Stop offset="100%" stopColor={color} stopOpacity={0} />
</SvgLinearGradient>
</Defs>
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
</Svg>
);
}
const ThemedFloatingHeaderFadeSvg = withUnistyles(FloatingHeaderFadeSvg);
const workspaceSurfaceColorMapping = (theme: Theme) => ({
color: theme.colors.surfaceWorkspace,
});
function FloatingHeaderFade() {
// React-generated ids contain characters that are invalid inside SVG fragment references.
const gradientId = `floating-header-fade-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
return (
<View style={styles.fade} pointerEvents="none">
<ThemedFloatingHeaderFadeSvg
gradientId={gradientId}
uniProps={workspaceSurfaceColorMapping}
/>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
@@ -88,12 +60,14 @@ const styles = StyleSheet.create({
top: 0,
left: 0,
right: 0,
// The fade's tail lives inside the overlay so Android does not clip it.
paddingBottom: FADE_OVERHANG,
},
fade: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: -FADE_OVERHANG,
bottom: 0,
},
});
@@ -17,7 +17,6 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
const ThemedArrowLeft = withUnistyles(ArrowLeft);
const ISLAND_PADDING = 7;
const NO_SHORTCUT: ShortcutKey[] = [];
interface CompactHeaderProps {
@@ -37,8 +36,9 @@ interface CompactHeaderProps {
/**
* The phone header: the navigation button and the screen's actions sit in islands, with the title
* left-aligned between them. It has no background of its own, so it reads the same in flow and floating
* over content (see `FloatingHeaderLayout`). Its height matches the old flat header, so anything
* left-aligned between them. Neither the header nor its islands draw a background, so it reads the
* same in flow and floating over content (see `FloatingHeaderLayout`, whose fade gives the icons
* contrast). Its height matches the old flat header, so anything
* offset by the header height still lines up.
*/
export function CompactHeader({
@@ -90,7 +90,11 @@ function CompactHeaderTitle({
return (
<>
{title ? (
<Text style={styles.title} numberOfLines={1} testID={titleTestID}>
<Text
style={subtitle ? styles.title : styles.singleLineTitle}
numberOfLines={1}
testID={titleTestID}
>
{title}
</Text>
) : null}
@@ -146,10 +150,6 @@ const styles = StyleSheet.create((theme) => ({
island: {
flexDirection: "row",
alignItems: "center",
padding: ISLAND_PADDING,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface2,
...theme.shadow.sm,
},
title: {
fontSize: theme.fontSize.base,
@@ -157,6 +157,13 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
textAlign: "left",
},
// A title without a subtitle has the header to itself, so it reads one step larger.
singleLineTitle: {
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
textAlign: "left",
},
titleSkeleton: {
width: 140,
maxWidth: "100%",
@@ -0,0 +1,72 @@
import { useId } from "react";
import { View, type StyleProp, type ViewStyle } from "react-native";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
type EdgeFadeBackdrop = "surface0" | "surfaceWorkspace";
const backdropColorMappings: Record<EdgeFadeBackdrop, (theme: Theme) => { color: string }> = {
surface0: (theme) => ({ color: theme.colors.surface0 }),
surfaceWorkspace: (theme) => ({ color: theme.colors.surfaceWorkspace }),
};
interface EdgeFadeSvgProps {
gradientId: string;
color: string;
edge: "top" | "bottom";
solidFraction: number;
}
function EdgeFadeSvg({ gradientId, color, edge, solidFraction }: EdgeFadeSvgProps) {
const fromTop = edge === "top";
return (
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
<SvgLinearGradient
id={gradientId}
x1="0%"
y1={fromTop ? "0%" : "100%"}
x2="0%"
y2={fromTop ? "100%" : "0%"}
>
{/* Vary opacity rather than interpolating toward `transparent`, which crosses black in
some engines and leaves a grey fringe. Keep it to three stops: Android rendered a
four-stop version far more transparent than its stops specify. */}
<Stop offset={0} stopColor={color} stopOpacity={1} />
<Stop offset={solidFraction} stopColor={color} stopOpacity={1} />
<Stop offset={1} stopColor={color} stopOpacity={0} />
</SvgLinearGradient>
</Defs>
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
</Svg>
);
}
const ThemedEdgeFadeSvg = withUnistyles(EdgeFadeSvg);
interface EdgeFadeProps {
/** The solid edge; content dissolves moving away from it. */
edge: "top" | "bottom";
/** Share of the fade, measured from the solid edge, that stays fully opaque. */
solidFraction: number;
backdrop: EdgeFadeBackdrop;
/** Position and size. Keep the fade inside its parent: Android clips overhanging children. */
style: StyleProp<ViewStyle>;
}
/** Dissolves content into the surface behind chrome that floats over it. */
export function EdgeFade({ edge, solidFraction, backdrop, style }: EdgeFadeProps) {
// React-generated ids contain characters that are invalid inside SVG fragment references.
const gradientId = `edge-fade-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
return (
<View style={style} pointerEvents="none">
<ThemedEdgeFadeSvg
gradientId={gradientId}
edge={edge}
solidFraction={solidFraction}
uniProps={backdropColorMappings[backdrop]}
/>
</View>
);
}
+6 -1
View File
@@ -1862,11 +1862,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const inputWrapperCombinedStyle = useMemo(
() => [
styles.inputWrapper,
isCompact && !isFullscreen && styles.inputWrapperCompact,
readOnly && styles.inputWrapperReadOnly,
inputWrapperStyle,
{ opacity: surfacePresentation.input.opacity },
],
[inputWrapperStyle, readOnly, surfacePresentation.input.opacity],
[inputWrapperStyle, isCompact, isFullscreen, readOnly, surfacePresentation.input.opacity],
);
// `withUnistyles` maps this component's `style` into a `.hash > *` child
// rule, which ties on specificity with react-native-web's own
@@ -2223,6 +2224,10 @@ const styles = StyleSheet.create((theme: Theme) => ({
}
: {}),
},
// Lifts the composer off the chat it sits under on phones.
inputWrapperCompact: {
boxShadow: "0px 4px 16px rgba(0, 0, 0, 0.2)",
},
// Dotted says "this surface is the same box, but there is nothing to type
// into it" without swapping the border colour, which reads as an error state.
inputWrapperReadOnly: {
+47 -30
View File
@@ -120,6 +120,7 @@ import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-p
import ProjectsScreen from "@/screens/projects-screen";
import ProjectSettingsScreen from "@/screens/project-settings-screen";
import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { densityIconBox, densityIconSize } from "@/styles/density";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import {
type EnableBuiltInDaemonOption,
@@ -758,7 +759,7 @@ function SidebarSectionButton({
isSelected,
onSelect,
}: SidebarSectionButtonProps) {
const { theme } = useUnistyles();
const { theme, rt } = useUnistyles();
const handlePress = useCallback(() => {
onSelect(itemId);
}, [onSelect, itemId]);
@@ -775,7 +776,7 @@ function SidebarSectionButton({
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
>
<IconComponent
size={theme.iconSize.md}
size={densityIconSize(rt, "md")}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
strokeWidth={SIDEBAR_ICON_STROKE_WIDTH}
/>
@@ -801,7 +802,7 @@ function SidebarHostSectionButton({
isSelected,
onSelect,
}: SidebarHostSectionButtonProps) {
const { theme } = useUnistyles();
const { theme, rt } = useUnistyles();
const handlePress = useCallback(() => {
onSelect(itemId);
}, [onSelect, itemId]);
@@ -819,7 +820,7 @@ function SidebarHostSectionButton({
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
>
<IconComponent
size={theme.iconSize.md}
size={densityIconSize(rt, "md")}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
strokeWidth={SIDEBAR_ICON_STROKE_WIDTH}
/>
@@ -912,6 +913,16 @@ function HostPicker({
);
}
/** A labelled group of sidebar rows. On compact layouts the rows sit together in one island. */
function SidebarGroup({ label, children }: { label?: string; children: ReactNode }) {
return (
<View style={sidebarStyles.group}>
{label ? <Text style={sidebarStyles.groupLabel}>{label}</Text> : null}
<View style={sidebarStyles.island}>{children}</View>
</View>
);
}
interface SettingsSidebarProps {
view: SettingsView;
onSelectSection: (section: SettingsSectionSlug) => void;
@@ -933,7 +944,7 @@ function SettingsSidebar({
activeHostServerId,
layout,
}: SettingsSidebarProps) {
const { theme } = useUnistyles();
const { theme, rt } = useUnistyles();
const { t } = useTranslation();
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
@@ -960,8 +971,7 @@ function SettingsSidebar({
const sidebarBody = (
<>
<View style={sidebarStyles.list}>
<Text style={sidebarStyles.groupLabel}>{t("settings.groups.app")}</Text>
<SidebarGroup label={t("settings.groups.app")}>
{items.map((item) => (
<SidebarSectionButton
key={item.id}
@@ -972,10 +982,9 @@ function SettingsSidebar({
onSelect={onSelectSection}
/>
))}
</View>
</SidebarGroup>
{hasHosts ? (
<View style={sidebarStyles.list}>
<Text style={sidebarStyles.groupLabel}>{t("settings.groups.host")}</Text>
<SidebarGroup label={t("settings.groups.host")}>
<HostPicker
activeServerId={activeHostServerId}
sortedHosts={sortedHosts}
@@ -993,9 +1002,9 @@ function SettingsSidebar({
onSelect={onSelectHostSection}
/>
))}
</View>
</SidebarGroup>
) : (
<View style={sidebarStyles.list}>
<SidebarGroup>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("settings.addHost")}
@@ -1004,7 +1013,7 @@ function SettingsSidebar({
style={sidebarItemStyle}
>
<Plus
size={theme.iconSize.md}
size={densityIconSize(rt, "md")}
color={theme.colors.foregroundMuted}
strokeWidth={SIDEBAR_ICON_STROKE_WIDTH}
/>
@@ -1021,7 +1030,7 @@ function SettingsSidebar({
style={sidebarItemStyle}
>
<Server
size={theme.iconSize.md}
size={densityIconSize(rt, "md")}
color={theme.colors.foregroundMuted}
strokeWidth={SIDEBAR_ICON_STROKE_WIDTH}
/>
@@ -1030,7 +1039,7 @@ function SettingsSidebar({
</Text>
</Pressable>
) : null}
</View>
</SidebarGroup>
)}
</>
);
@@ -1572,26 +1581,33 @@ const sidebarStyles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
},
list: {
// Compact values draw each group as an island of tiles; desktop keeps a plain list.
group: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
gap: theme.spacing[0.5],
gap: { xs: theme.spacing[1], md: theme.spacing[0.5] },
},
groupLabel: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundExtraMuted,
paddingHorizontal: theme.spacing[2],
paddingHorizontal: { xs: theme.spacing[4], md: theme.spacing[2] },
paddingVertical: theme.spacing[1],
},
island: {
gap: { xs: 2, md: theme.spacing[0.5] },
borderRadius: { xs: theme.borderRadius["2xl"], md: 0 },
overflow: "hidden",
},
item: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 28,
gap: { xs: theme.spacing[3], md: theme.spacing[2] },
minHeight: { xs: 52, md: 28 },
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
paddingHorizontal: { xs: theme.spacing[4], md: theme.spacing[2] },
borderRadius: { xs: 0, md: theme.borderRadius.lg },
backgroundColor: { xs: theme.colors.surface1, md: "transparent" },
},
itemHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
@@ -1600,7 +1616,7 @@ const sidebarStyles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surfaceSidebarHover,
},
label: {
fontSize: theme.fontSize.base,
fontSize: { xs: theme.fontSize.lg, md: theme.fontSize.base },
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
flex: 1,
@@ -1608,11 +1624,12 @@ const sidebarStyles = StyleSheet.create((theme) => ({
pickerTrigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 28,
gap: { xs: theme.spacing[3], md: theme.spacing[2] },
minHeight: { xs: 52, md: 28 },
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
paddingHorizontal: { xs: theme.spacing[4], md: theme.spacing[2] },
borderRadius: { xs: 0, md: theme.borderRadius.lg },
backgroundColor: { xs: theme.colors.surface1, md: "transparent" },
},
pickerTriggerHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
@@ -1620,14 +1637,14 @@ const sidebarStyles = StyleSheet.create((theme) => ({
pickerTriggerLabel: {
flex: 1,
minWidth: 0,
fontSize: theme.fontSize.base,
fontSize: { xs: theme.fontSize.lg, md: theme.fontSize.base },
color: theme.colors.foreground,
fontWeight: theme.fontWeight.normal,
},
// Match the setting items' icon footprint so the host label aligns with them.
pickerTriggerDot: {
width: theme.iconSize.md,
height: theme.iconSize.md,
width: densityIconBox("md"),
height: densityIconBox("md"),
alignItems: "center",
justifyContent: "center",
},