From 8d9a7123ba38353029352a93a85cca7160af6f07 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 16 Oct 2026 18:05:11 +0700 Subject: [PATCH] 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. --- .../browser/compact-workspace-header.spec.ts | 16 ++-- .../src/components/floating-header/index.tsx | 64 +++++---------- .../src/components/headers/compact-header.tsx | 23 ++++-- packages/app/src/components/ui/edge-fade.tsx | 72 +++++++++++++++++ packages/app/src/composer/input/input.tsx | 7 +- packages/app/src/screens/settings-screen.tsx | 77 +++++++++++-------- 6 files changed, 167 insertions(+), 92 deletions(-) create mode 100644 packages/app/src/components/ui/edge-fade.tsx diff --git a/packages/app/e2e/browser/compact-workspace-header.spec.ts b/packages/app/e2e/browser/compact-workspace-header.spec.ts index 6033f147e8..be26dbb8d1 100644 --- a/packages/app/e2e/browser/compact-workspace-header.spec.ts +++ b/packages/app/e2e/browser/compact-workspace-header.spec.ts @@ -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); diff --git a/packages/app/src/components/floating-header/index.tsx b/packages/app/src/components/floating-header/index.tsx index b18a8731b0..6e4cd525f1 100644 --- a/packages/app/src/components/floating-header/index.tsx +++ b/packages/app/src/components/floating-header/index.tsx @@ -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} {children(floating ? headerHeight : 0)} {floating ? ( - - - {header} + + + + {header} + ) : null} ); } -function FloatingHeaderFadeSvg({ gradientId, color }: { gradientId: string; color: string }) { - return ( - - - - {/* Vary opacity rather than interpolating toward `transparent`, which crosses black in - some engines and leaves a grey fringe. */} - - - - - - - - - ); -} - -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 ( - - - - ); -} - 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, }, }); diff --git a/packages/app/src/components/headers/compact-header.tsx b/packages/app/src/components/headers/compact-header.tsx index aa66580fce..6ed2bddcbe 100644 --- a/packages/app/src/components/headers/compact-header.tsx +++ b/packages/app/src/components/headers/compact-header.tsx @@ -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 ? ( - + {title} ) : 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%", diff --git a/packages/app/src/components/ui/edge-fade.tsx b/packages/app/src/components/ui/edge-fade.tsx new file mode 100644 index 0000000000..de404aeed8 --- /dev/null +++ b/packages/app/src/components/ui/edge-fade.tsx @@ -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 { 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 ( + + + + {/* 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. */} + + + + + + + + ); +} + +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; +} + +/** 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 ( + + + + ); +} diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index c64d87aba1..c6c267a38f 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1862,11 +1862,12 @@ export const MessageInput = forwardRef( 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: { diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 925607a858..3950c0778e 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -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} > @@ -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} > @@ -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 ( + + {label ? {label} : null} + {children} + + ); +} + 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 = ( <> - - {t("settings.groups.app")} + {items.map((item) => ( ))} - + {hasHosts ? ( - - {t("settings.groups.host")} + ))} - + ) : ( - + @@ -1021,7 +1030,7 @@ function SettingsSidebar({ style={sidebarItemStyle} > @@ -1030,7 +1039,7 @@ function SettingsSidebar({ ) : null} - + )} ); @@ -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", },