From 4a5bc937d8cf9c8c04534cc628c891ead495eb85 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 22 Jul 2026 14:19:33 +0200 Subject: [PATCH 01/23] Refine light-mode sidebar surfaces - Improve light-mode contrast for rows, controls, and separators - Use neutral sidebar tokens and preserve dark-mode styling --- apps/web/src/components/SidebarV2.tsx | 56 +++++++++++++-------------- apps/web/src/index.css | 26 +++++++------ 2 files changed, 42 insertions(+), 40 deletions(-) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index a62e09a3b8e..cd676d197c1 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -324,9 +324,9 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { const rowClassName = cn( "group/v2-row relative w-full cursor-pointer select-none rounded-md text-left", props.isActive - ? "bg-foreground/[0.11] text-foreground dark:bg-white/[0.11]" + ? "bg-background text-foreground shadow-xs/5 ring-1 ring-inset ring-border dark:bg-white/[0.11] dark:shadow-none dark:ring-0" : isSelected - ? "bg-foreground/[0.07] text-foreground dark:bg-white/[0.07]" + ? "bg-muted text-foreground dark:bg-white/[0.07]" : "hover:bg-accent/65", ); @@ -355,7 +355,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { : status !== "ready" ? "font-semibold text-foreground/95" : shouldRecede - ? "font-normal text-muted-foreground/75" + ? "font-normal text-muted-foreground/80" : "font-medium text-foreground/90", ) : cn( @@ -364,7 +364,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ? "text-foreground" : isUnread ? "font-medium text-muted-foreground" - : "text-muted-foreground/60", + : "text-muted-foreground/70", ), )} > @@ -399,8 +399,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { > {props.showSettledGap ? (
- Settled - + Settled +
) : null}
- + {props.jumpLabel ?? compactSidebarTimeLabel( @@ -479,17 +479,17 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { tabIndex={0} data-testid="sidebar-v2-row-card" className={cn( - // Every card carries a faint tonal fill so threads read as - // discrete objects; active/selected/hover are brighter tones of - // the same treatment rather than a different shape. + // Light cards sit just above the zinc rail instead of tinting it + // darker. Active/selected rows then add only a restrained neutral + // fill. Dark mode keeps the original graphite treatments. "group/v2-row relative w-full cursor-pointer select-none overflow-hidden rounded-lg text-left transition-colors", props.isActive - ? "bg-foreground/[0.11] text-foreground dark:bg-white/[0.11]" + ? "bg-background text-foreground shadow-xs/5 ring-1 ring-inset ring-border dark:bg-white/[0.11] dark:shadow-none dark:ring-0" : isSelected - ? "bg-foreground/[0.07] text-foreground dark:bg-white/[0.07]" + ? "bg-muted text-foreground dark:bg-white/[0.07]" : shouldRecede - ? "bg-foreground/[0.025] hover:bg-accent/45 dark:bg-white/[0.025]" - : "bg-foreground/[0.035] hover:bg-accent/65 dark:bg-white/[0.035]", + ? "bg-background/50 hover:bg-accent/45 dark:bg-white/[0.025]" + : "bg-background hover:bg-accent/65 dark:bg-white/[0.035]", )} onClick={handleClick} onDoubleClick={handleDoubleClick} @@ -506,7 +506,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { {props.projectTitle ? ( )} - + {props.jumpLabel ? ( props.jumpLabel ) : topStatus ? ( @@ -556,7 +556,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
{title}
-
+
{thread.branch ? ( {thread.branch} @@ -805,7 +805,7 @@ export default function SidebarV2() { // filter context changes so a scope/search flip never inherits a deep // page state. const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); - const settledResetKey = `${projectScopeKey ?? "all"}`; + const settledResetKey = projectScopeKey ?? "all"; const lastSettledResetKeyRef = useRef(settledResetKey); if (lastSettledResetKeyRef.current !== settledResetKey) { lastSettledResetKeyRef.current = settledResetKey; @@ -1415,12 +1415,12 @@ export default function SidebarV2() { render={ } > - + Search {commandPaletteShortcutLabel ? ( @@ -1432,7 +1432,7 @@ export default function SidebarV2() { - + @@ -1472,7 +1472,7 @@ export default function SidebarV2() { "shrink-0 rounded-md border px-2.5 py-1 text-[11px] font-medium transition-colors", projectScopeKey === null ? "border-foreground/15 bg-accent text-foreground" - : "border-black/15 text-muted-foreground hover:border-black/40 hover:text-foreground dark:border-white/15 dark:hover:border-white/40", + : "border-border bg-background/60 text-muted-foreground hover:border-input hover:bg-background hover:text-foreground dark:border-white/15 dark:bg-transparent dark:hover:border-white/40 dark:hover:bg-transparent", )} > All @@ -1492,7 +1492,7 @@ export default function SidebarV2() { "flex shrink-0 items-center gap-1.5 rounded-md border py-1 pl-1.5 pr-2.5 text-[11px] font-medium transition-colors", isScoped ? "border-foreground/15 bg-accent text-foreground" - : "border-black/15 text-muted-foreground hover:border-black/40 hover:text-foreground dark:border-white/15 dark:hover:border-white/40", + : "border-border bg-background/60 text-muted-foreground hover:border-input hover:bg-background hover:text-foreground dark:border-white/15 dark:bg-transparent dark:hover:border-white/40 dark:hover:bg-transparent", )} > -
+
} > @@ -1586,7 +1586,7 @@ export default function SidebarV2() { @@ -166,7 +166,7 @@ function RightPanelEmptyState(props: { const disabledCard = ( + ) : ( + + )} - {!props.settlementSupported ? null : variantAction === "unsettle" ? ( - - ) : ( - - )} - -
+ + {detailsTooltip} + ); } @@ -469,132 +605,109 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { data-thread-item className="list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_96px]" > -
-
-
- + - {props.projectTitle ? ( - - {props.projectTitle} - - ) : ( - - )} - - - {props.jumpLabel ? ( - props.jumpLabel - ) : topStatus ? ( - +
+
+ + {props.projectTitle ? ( + + {props.projectTitle} + + ) : ( + + )} + + + {props.jumpLabel ? ( + props.jumpLabel + ) : topStatus ? ( + + {topStatus.icon === "working" ? ( + + ) : topStatus.icon === "done" ? ( + + ) : null} + {topStatus.label} + + ) : ( + threadTimeLabel(thread) + )} + + {props.settlementSupported ? ( + + ) : null} - {props.settlementSupported ? ( - +
+
{title}
+
+ {thread.branch ? ( + {thread.branch} + ) : ( + + )} + {prBadge} + {diff ? ( + + +{diff.insertions}{" "} + −{diff.deletions} + ) : null} - -
-
{title}
-
- {thread.branch ? ( - - {thread.branch} - - ) : ( - - )} - {prBadge} - {diff ? ( - - +{diff.insertions}{" "} - −{diff.deletions} - - ) : null} - - {driverKind ? ( - - } + + {isRemote ? ( + + + + ) : null} + {driverKind ? ( + - - {thread.modelSelection.model} - - ) : null} - {isRemote ? ( - - - } - > - - - - Running on {props.environmentLabel ?? "a remote environment"} - - - ) : null} - -
- {status === "failed" && thread.session?.lastError ? ( -
- {thread.session.lastError} + + ) : null} +
- ) : null} -
-
+
+ + {detailsTooltip} + ); }); @@ -708,9 +821,8 @@ export default function SidebarV2() { [], ); - // Project scope: chips above the list. Scoping filters the list AND - // becomes the new-thread target — one visible control doing both jobs the - // old per-project headers did. + // Project scope: one menu above the list. Scoping filters the list without + // making the header width depend on the number or length of project names. const [projectScopeKey, setProjectScopeKey] = useState(null); const scopedProject = useMemo( () => @@ -1319,243 +1431,245 @@ export default function SidebarV2() { const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal") ?? shortcutLabelForCommand(keybindings, "chat.new"); - const projectScrollerRef = useRef(null); - const [canScrollProjectsRight, setCanScrollProjectsRight] = useState(false); - const updateProjectScrollFade = useCallback(() => { - const scroller = projectScrollerRef.current; - if (!scroller) return; - setCanScrollProjectsRight( - scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1, - ); - }, []); - useEffect(() => { - const scroller = projectScrollerRef.current; - if (!scroller) return; - - updateProjectScrollFade(); - const resizeObserver = new ResizeObserver(updateProjectScrollFade); - resizeObserver.observe(scroller); - return () => resizeObserver.disconnect(); - }, [projects, updateProjectScrollFade]); - return ( <> - - +
+
} > - - Search + +
Search
{commandPaletteShortcutLabel ? ( - + {commandPaletteShortcutLabel} ) : null}
- - - - - - - +
+
+ + + } + > + + + + {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + + +
+
{projects.length > 0 ? ( - -
-
- {projects.length > 1 ? ( - - ) : null} - {projects.map((project) => { - const scopeKey = `${project.environmentId}:${project.id}`; - const isScoped = projectScopeKey === scopeKey; - return ( - - ); - })} -
-
- - + +
+ + + {scopedProject ? ( + + ) : ( + + )} + + {scopedProject?.title ?? "All projects"} + + + + + + setProjectScopeKey(value === "all" ? null : (value as string)) } > - - - Add project - -
+ + + All projects + + {projects.map((project) => { + const scopeKey = `${project.environmentId}:${project.id}`; + return ( + + + {project.title} + + ); + })} + + + + + + } + > + + + New project +
) : null} -
    - {orderedThreads.flatMap((thread, threadIndex) => { - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const isSettledRow = settledThreadKeys.has(threadKey); - // Settled is the ONLY thing that collapses a row: every - // not-settled thread is a full card. Density comes from users - // (or the auto rules) actually settling work, not from the - // sidebar second-guessing what still matters. - const isCard = !isSettledRow; - const previousThread = threadIndex > 0 ? orderedThreads[threadIndex - 1] : null; - const previousWasCard = - previousThread != null && - !settledThreadKeys.has( - scopedThreadKey(scopeThreadRef(previousThread.environmentId, previousThread.id)), + +
      + {orderedThreads.flatMap((thread, threadIndex) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const isSettledRow = settledThreadKeys.has(threadKey); + // Settled is the ONLY thing that collapses a row: every + // not-settled thread is a full card. Density comes from users + // (or the auto rules) actually settling work, not from the + // sidebar second-guessing what still matters. + const isCard = !isSettledRow; + const previousThread = threadIndex > 0 ? orderedThreads[threadIndex - 1] : null; + const previousWasCard = + previousThread != null && + !settledThreadKeys.has( + scopedThreadKey( + scopeThreadRef(previousThread.environmentId, previousThread.id), + ), + ); + const showSettledGap = !isCard && previousWasCard; + const row = ( + ); - const showSettledGap = !isCard && previousWasCard; - const row = ( - - ); - if (!showSettledGap) return [row]; - // The divider is its own keyed list item (not part of the first - // settled row): it keeps one stable DOM node at the boundary, - // so settling a thread slides it instead of teleporting it - // along with whichever row happens to be first in the tail — - // and row heights stay independent of neighbor classification. - return [ -
    • -
      - - Settled + if (!showSettledGap) return [row]; + // The divider is its own keyed list item (not part of the first + // settled row): it keeps one stable DOM node at the boundary, + // so settling a thread slides it instead of teleporting it + // along with whichever row happens to be first in the tail — + // and row heights stay independent of neighbor classification. + return [ +
    • +
      + Settled + +
      +
    • , + row, + ]; + })} + {hiddenSettledCount > 0 ? ( +
    • +
- , - row, - ]; - })} - {hiddenSettledCount > 0 ? ( -
  • - -
  • - ) : null} - + + + ) : null} + + {orderedThreads.length === 0 ? (
    {projects.length === 0 ? ( @@ -1564,7 +1678,7 @@ export default function SidebarV2() {
    -
    +
    {children}
    diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 80ab68d706b..c338aee2fdd 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -115,7 +115,7 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() { diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index 56498b2dbc6..7864fe44567 100644 --- a/apps/web/src/components/ui/command.tsx +++ b/apps/web/src/components/ui/command.tsx @@ -205,7 +205,7 @@ function CommandFooter({ className, ...props }: React.ComponentProps<"div">) { return (
    ) { function SidebarSeparator({ className, ...props }: React.ComponentProps) { return ( ) { const defaultProps = { className: cn( - "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", + "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-ring transition-transform hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", // Increases the hit area of the button on mobile. "after:-inset-2 after:absolute md:after:hidden", "group-data-[collapsible=icon]:hidden", @@ -783,7 +783,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { } const sidebarMenuButtonVariants = cva( - "peer/menu-button flex w-full cursor-pointer items-center gap-2 overflow-hidden rounded-lg p-2 text-left text-sm outline-hidden ring-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", + "peer/menu-button flex w-full cursor-pointer items-center gap-2 overflow-hidden rounded-lg p-2 text-left text-sm outline-hidden ring-ring transition-[width,height,padding] hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 active:bg-sidebar-row-active active:text-sidebar-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-row-selected data-[active=true]:font-medium data-[active=true]:text-sidebar-foreground data-[state=open]:hover:bg-sidebar-row-hover data-[state=open]:hover:text-sidebar-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", { defaultVariants: { size: "default", @@ -796,9 +796,9 @@ const sidebarMenuButtonVariants = cva( sm: "h-7 text-xs", }, variant: { - default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", + default: "hover:bg-sidebar-row-hover hover:text-sidebar-foreground", outline: - "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", + "bg-sidebar-control-surface ring-1 ring-sidebar-border hover:bg-sidebar-row-hover hover:text-sidebar-foreground", }, }, }, @@ -867,7 +867,7 @@ function SidebarMenuAction({ }) { const defaultProps = { className: cn( - "absolute top-1.5 right-1 flex aspect-square w-5 cursor-pointer items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", + "absolute top-1.5 right-1 flex aspect-square w-5 cursor-pointer items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-ring transition-transform hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-foreground [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", // Increases the hit area of the button on mobile. "after:-inset-2 after:absolute md:after:hidden", "peer-data-[size=sm]/menu-button:top-1", @@ -875,7 +875,7 @@ function SidebarMenuAction({ "peer-data-[size=lg]/menu-button:top-2.5", "group-data-[collapsible=icon]:hidden", showOnHover && - "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0", + "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-foreground md:opacity-0", className, ), "data-sidebar": "menu-action", @@ -894,7 +894,7 @@ function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">)
    span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", - "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", + "-translate-x-px flex h-7 min-w-0 cursor-pointer items-center gap-2 overflow-hidden rounded-lg px-2 text-sidebar-foreground outline-hidden ring-ring hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 active:bg-sidebar-row-active active:text-sidebar-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-muted-foreground", + "data-[active=true]:bg-sidebar-row-selected data-[active=true]:text-sidebar-foreground", size === "sm" && "text-xs", size === "md" && "text-sm", "group-data-[collapsible=icon]:hidden", diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 953b3376555..8649e07e416 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -144,6 +144,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --color-card: var(--card); --color-foreground: var(--foreground); --color-background: var(--background); + --color-surface-raised: var(--surface-raised); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-muted-foreground: var(--sidebar-muted-foreground); @@ -322,7 +323,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } .chat-composer-glass { - background: var(--card); + background: var(--surface-raised); } /* Mix from --background (not --card): the lower chrome is a full-width @@ -353,7 +354,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } .dark .chat-composer-glass { - background: var(--card); + background: var(--surface-raised); } .dark .chat-composer-lower-chrome { @@ -431,6 +432,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --radius: 0.625rem; --background: var(--color-zinc-25); --app-chrome-background: var(--background); + --surface-raised: color-mix(in srgb, var(--card) 20%, transparent); --foreground: var(--color-zinc-800); --card: var(--color-white); --card-foreground: var(--color-zinc-800); @@ -455,9 +457,8 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --success-foreground: var(--color-emerald-700); --warning: var(--color-amber-500); --warning-foreground: var(--color-amber-700); - /* Generic defaults preserve the existing sidebar primitive. Sidebar V2 - overrides these roles on its own root so its stronger surface hierarchy - cannot leak into settings or the legacy sidebar. */ + /* Fallbacks for standalone sidebar primitives. App sidebars override these + roles on their own root so the surface hierarchy stays out of page content. */ --sidebar: var(--card); --sidebar-foreground: var(--foreground); --sidebar-muted-foreground: var(--muted-foreground); @@ -471,6 +472,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil color-scheme: dark; --background: var(--color-zinc-950); --app-chrome-background: var(--background); + --surface-raised: color-mix(in srgb, var(--card) 45%, transparent); --foreground: var(--color-zinc-100); --card: var(--color-zinc-900); --card-foreground: var(--color-zinc-100); @@ -498,7 +500,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } -[data-sidebar-version="v2"] { +[data-app-sidebar] { --sidebar: var(--color-zinc-50); --sidebar-foreground: var(--color-zinc-800); --sidebar-muted-foreground: var(--color-zinc-500); @@ -510,7 +512,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --sidebar-stage-fade: var(--sidebar); } -.dark [data-sidebar-version="v2"] { +.dark [data-app-sidebar] { --sidebar: var(--color-zinc-950); --sidebar-foreground: var(--color-zinc-100); --sidebar-muted-foreground: var(--color-zinc-400); From 89830a09822c49af2c1478ee94bfba7bf63a90e1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 22 Jul 2026 23:45:59 +0200 Subject: [PATCH 23/23] Fit swipe actions in compact thread rows - Add compact action sizing for slim mobile thread-list rows - Preserve full-row press targets while reducing action visuals --- .../features/home/thread-swipe-actions.tsx | 36 ++++++++++++------- .../features/threads/thread-list-v2-items.tsx | 1 + 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 666169f3039..186c606ae8f 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -36,6 +36,8 @@ import { AppText as Text } from "../../components/AppText"; const ACTION_ITEM_WIDTH = 58; const ACTION_CIRCLE_SIZE = 36; const ACTION_ICON_SIZE = 15; +const COMPACT_ACTION_CIRCLE_SIZE = 28; +const COMPACT_ACTION_ICON_SIZE = 13; export const THREAD_SWIPE_ACTIONS_WIDTH = ACTION_ITEM_WIDTH * 2; export const THREAD_SWIPE_SPRING = { @@ -163,6 +165,9 @@ export function useSwipeableScrollGate(options?: { export function ThreadSwipeable(props: { readonly backgroundColor: ColorValue; readonly children: (close: () => void) => ReactNode; + /** Uses action visuals that fit inside compact 44pt rows. The press target + * still spans the row's full height and width. */ + readonly compactActions?: boolean; readonly containerStyle?: StyleProp; /** Disables NEW swipe activations (e.g. while the list scrolls). */ readonly enabled?: boolean; @@ -258,6 +263,7 @@ export function ThreadSwipeable(props: { renderRightActions={(_progress, translation, methods) => ( ["name"]; @@ -293,6 +300,8 @@ function SwipeActionButton(props: { readonly stretchesOnFullSwipe: boolean; readonly translation: SharedValue; }) { + const circleSize = props.compact ? COMPACT_ACTION_CIRCLE_SIZE : ACTION_CIRCLE_SIZE; + const iconSize = props.compact ? COMPACT_ACTION_ICON_SIZE : ACTION_ICON_SIZE; const actionStyle = useAnimatedStyle(() => { const reveal = Math.max(-props.translation.value, 0); const entryProgress = interpolate(reveal, props.entryRange, [0, 1], Extrapolation.CLAMP); @@ -324,7 +333,7 @@ function SwipeActionButton(props: { return { transform: [{ translateX: -stretch }], - width: ACTION_CIRCLE_SIZE + stretch, + width: circleSize + stretch, }; }); const iconStyle = useAnimatedStyle(() => { @@ -386,13 +395,13 @@ function SwipeActionButton(props: { width: "100%", })} > - + - + - + {props.label} @@ -434,6 +443,7 @@ function SwipeActionButton(props: { export function ThreadSwipeActions(props: { readonly backgroundColor: ColorValue; + readonly compact: boolean; readonly fullSwipeAction?: "delete" | "primary"; readonly fullSwipeThreshold: number; readonly onDelete: () => void; @@ -466,6 +476,7 @@ export function ThreadSwipeActions(props: { : null}