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 (
-
- );
-}
-
-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 (
+
+ );
+}
+
+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",
},