diff --git a/apps/desktop/resources/dmg/dmg-background-latest.svg b/apps/desktop/resources/dmg/dmg-background-latest.svg index 23907be8470e..2c300aa52423 100644 --- a/apps/desktop/resources/dmg/dmg-background-latest.svg +++ b/apps/desktop/resources/dmg/dmg-background-latest.svg @@ -1,30 +1,26 @@ - - - - - - - + + + - - - + + + - - - + - - T3 Code - FOR MAC - Drag T3 Code into Applications. - - Once copied, open it from Applications. + + + + + + Drag T3 Code into Applications. + + Once copied, open it from Applications. diff --git a/apps/desktop/resources/dmg/dmg-background-nightly.svg b/apps/desktop/resources/dmg/dmg-background-nightly.svg index c4b6cc113a6a..6ae74b3b51ef 100644 --- a/apps/desktop/resources/dmg/dmg-background-nightly.svg +++ b/apps/desktop/resources/dmg/dmg-background-nightly.svg @@ -22,7 +22,10 @@ - T3 Code + + + + NIGHTLY Drag T3 Code into Applications. diff --git a/apps/mobile/generated-uniwind-default-theme-variables.json b/apps/mobile/generated-uniwind-default-theme-variables.json index 1953880fa946..3f1e143feb73 100644 --- a/apps/mobile/generated-uniwind-default-theme-variables.json +++ b/apps/mobile/generated-uniwind-default-theme-variables.json @@ -6,6 +6,13 @@ "--color-card": "#ffffff", "--color-card-alt": "#f5f5f5", "--color-card-translucent": "rgba(255, 255, 255, 0.8)", + "--color-thread-canvas": "#f5f5f5", + "--color-thread-selected": "#ffffff", + "--color-thread-selected-foreground": "#262626", + "--color-thread-selected-foreground-muted": "#737373", + "--color-composer-panel": "rgba(245, 245, 245, 0.88)", + "--color-composer-surface": "rgba(255, 255, 255, 0.94)", + "--color-composer-border": "rgba(0, 0, 0, 0.1)", "--color-foreground": "#262626", "--color-foreground-secondary": "#525252", "--color-foreground-muted": "#737373", @@ -76,6 +83,13 @@ "--color-card": "#171717", "--color-card-alt": "#1c1c1c", "--color-card-translucent": "rgba(17, 17, 17, 0.8)", + "--color-thread-canvas": "#1c1c1c", + "--color-thread-selected": "#171717", + "--color-thread-selected-foreground": "#f5f5f5", + "--color-thread-selected-foreground-muted": "#8e8e93", + "--color-composer-panel": "rgba(28, 28, 28, 0.92)", + "--color-composer-surface": "rgba(23, 23, 23, 0.9)", + "--color-composer-border": "rgba(255, 255, 255, 0.08)", "--color-foreground": "#f5f5f5", "--color-foreground-secondary": "#a3a3a3", "--color-foreground-muted": "#8e8e93", diff --git a/apps/mobile/generated-uniwind-themes.css b/apps/mobile/generated-uniwind-themes.css index e9352e1b38b9..d63fe16d578b 100644 --- a/apps/mobile/generated-uniwind-themes.css +++ b/apps/mobile/generated-uniwind-themes.css @@ -128,6 +128,13 @@ --color-card: #fdfafd; --color-card-alt: #faf3fb; --color-card-translucent: rgba(253, 250, 253, 0.8); + --color-thread-canvas: #faf3fb; + --color-thread-selected: #fdfafd; + --color-thread-selected-foreground: #501854; + --color-thread-selected-foreground-muted: #ac1668; + --color-composer-panel: rgba(250, 243, 251, 0.88); + --color-composer-surface: rgba(253, 250, 253, 0.94); + --color-composer-border: rgba(238, 225, 237, 0.54); --color-foreground: #501854; --color-foreground-secondary: #ac1668; --color-foreground-muted: #8d1255; @@ -256,6 +263,13 @@ --color-card: #2c2631; --color-card-alt: #29232d; --color-card-translucent: rgba(44, 38, 49, 0.8); + --color-thread-canvas: #29232d; + --color-thread-selected: #2c2631; + --color-thread-selected-foreground: #f9f8fb; + --color-thread-selected-foreground-muted: #e7d0dd; + --color-composer-panel: rgba(41, 35, 45, 0.92); + --color-composer-surface: rgba(44, 38, 49, 0.9); + --color-composer-border: rgba(39, 36, 44, 0.46); --color-foreground: #f9f8fb; --color-foreground-secondary: #e7d0dd; --color-foreground-muted: #e7d0dd; @@ -384,6 +398,13 @@ --color-card: #ecefed; --color-card-alt: #f3f7f4; --color-card-translucent: rgba(236, 239, 237, 0.8); + --color-thread-canvas: #f3f7f4; + --color-thread-selected: #ecefed; + --color-thread-selected-foreground: #241523; + --color-thread-selected-foreground-muted: #746c73; + --color-composer-panel: rgba(243, 247, 244, 0.88); + --color-composer-surface: rgba(236, 239, 237, 0.94); + --color-composer-border: rgba(203, 213, 209, 0.54); --color-foreground: #241523; --color-foreground-secondary: #746c73; --color-foreground-muted: #6e696f; @@ -512,6 +533,13 @@ --color-card: #36413c; --color-card-alt: #1b2821; --color-card-translucent: rgba(54, 65, 60, 0.8); + --color-thread-canvas: #1b2821; + --color-thread-selected: #36413c; + --color-thread-selected-foreground: #fffaff; + --color-thread-selected-foreground-muted: #919595; + --color-composer-panel: rgba(27, 40, 33, 0.92); + --color-composer-surface: rgba(54, 65, 60, 0.9); + --color-composer-border: rgba(65, 95, 79, 0.46); --color-foreground: #fffaff; --color-foreground-secondary: #919595; --color-foreground-muted: #9da5a2; @@ -640,6 +668,13 @@ --color-card: #edeff1; --color-card-alt: #f5f7f8; --color-card-translucent: rgba(237, 239, 241, 0.8); + --color-thread-canvas: #f5f7f8; + --color-thread-selected: #edeff1; + --color-thread-selected-foreground: #241523; + --color-thread-selected-foreground-muted: #746c75; + --color-composer-panel: rgba(245, 247, 248, 0.88); + --color-composer-surface: rgba(237, 239, 241, 0.94); + --color-composer-border: rgba(205, 212, 220, 0.54); --color-foreground: #241523; --color-foreground-secondary: #746c75; --color-foreground-muted: #6f6873; @@ -768,6 +803,13 @@ --color-card: #333b45; --color-card-alt: #17212b; --color-card-translucent: rgba(51, 59, 69, 0.8); + --color-thread-canvas: #17212b; + --color-thread-selected: #333b45; + --color-thread-selected-foreground: #fffaff; + --color-thread-selected-foreground-muted: #8d8f97; + --color-composer-panel: rgba(23, 33, 43, 0.92); + --color-composer-surface: rgba(51, 59, 69, 0.9); + --color-composer-border: rgba(64, 85, 103, 0.46); --color-foreground: #fffaff; --color-foreground-secondary: #8d8f97; --color-foreground-muted: #969ca6; @@ -896,6 +938,13 @@ --color-card: #f1efee; --color-card-alt: #f9f7f5; --color-card-translucent: rgba(241, 239, 238, 0.8); + --color-thread-canvas: #f9f7f5; + --color-thread-selected: #f1efee; + --color-thread-selected-foreground: #241523; + --color-thread-selected-foreground-muted: #766c74; + --color-composer-panel: rgba(249, 247, 245, 0.88); + --color-composer-surface: rgba(241, 239, 238, 0.94); + --color-composer-border: rgba(221, 210, 206, 0.54); --color-foreground: #241523; --color-foreground-secondary: #766c74; --color-foreground-muted: #74686f; @@ -1024,6 +1073,13 @@ --color-card: #433835; --color-card-alt: #291e1a; --color-card-translucent: rgba(67, 56, 53, 0.8); + --color-thread-canvas: #291e1a; + --color-thread-selected: #433835; + --color-thread-selected-foreground: #fffaff; + --color-thread-selected-foreground-muted: #968e8f; + --color-composer-panel: rgba(41, 30, 26, 0.92); + --color-composer-surface: rgba(67, 56, 53, 0.9); + --color-composer-border: rgba(102, 76, 63, 0.46); --color-foreground: #fffaff; --color-foreground-secondary: #968e8f; --color-foreground-muted: #a59996; @@ -1152,6 +1208,13 @@ --color-card: #f0eff2; --color-card-alt: #f8f7f9; --color-card-translucent: rgba(240, 239, 242, 0.8); + --color-thread-canvas: #f8f7f9; + --color-thread-selected: #f0eff2; + --color-thread-selected-foreground: #241523; + --color-thread-selected-foreground-muted: #766c76; + --color-composer-panel: rgba(248, 247, 249, 0.88); + --color-composer-surface: rgba(240, 239, 242, 0.94); + --color-composer-border: rgba(214, 209, 222, 0.54); --color-foreground: #241523; --color-foreground-secondary: #766c76; --color-foreground-muted: #726874; @@ -1280,6 +1343,13 @@ --color-card: #383443; --color-card-alt: #1d1929; --color-card-translucent: rgba(56, 52, 67, 0.8); + --color-thread-canvas: #1d1929; + --color-thread-selected: #383443; + --color-thread-selected-foreground: #fffaff; + --color-thread-selected-foreground-muted: #8e8a95; + --color-composer-panel: rgba(29, 25, 41, 0.92); + --color-composer-surface: rgba(56, 52, 67, 0.9); + --color-composer-border: rgba(77, 67, 102, 0.46); --color-foreground: #fffaff; --color-foreground-secondary: #8e8a95; --color-foreground-muted: #9690a1; diff --git a/apps/mobile/global.css b/apps/mobile/global.css index e153107b1811..6ec28f6e3744 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -19,6 +19,13 @@ --color-card: #ffffff; --color-card-alt: #f5f5f5; --color-card-translucent: rgba(255, 255, 255, 0.8); + --color-thread-canvas: #f5f5f5; + --color-thread-selected: #ffffff; + --color-thread-selected-foreground: #262626; + --color-thread-selected-foreground-muted: #737373; + --color-composer-panel: rgba(245, 245, 245, 0.88); + --color-composer-surface: rgba(255, 255, 255, 0.94); + --color-composer-border: rgba(0, 0, 0, 0.1); /* Text */ --color-foreground: #262626; @@ -123,6 +130,13 @@ --color-card: #171717; --color-card-alt: #1c1c1c; --color-card-translucent: rgba(17, 17, 17, 0.8); + --color-thread-canvas: #1c1c1c; + --color-thread-selected: #171717; + --color-thread-selected-foreground: #f5f5f5; + --color-thread-selected-foreground-muted: #8e8e93; + --color-composer-panel: rgba(28, 28, 28, 0.92); + --color-composer-surface: rgba(23, 23, 23, 0.9); + --color-composer-border: rgba(255, 255, 255, 0.08); /* Text */ --color-foreground: #f5f5f5; diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index 3010b5240997..48423ef48ff3 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -7,11 +7,13 @@ import android.graphics.Canvas import android.graphics.Paint import android.graphics.RectF import android.graphics.Typeface +import android.os.Build import android.text.Editable import android.text.InputType import android.text.Spanned import android.text.TextWatcher import android.text.style.ReplacementSpan +import android.util.TypedValue import android.view.Gravity import android.view.ViewGroup import android.view.inputmethod.InputMethodManager @@ -27,6 +29,11 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( appContext ) { private val editor = SelectionAwareEditText(context) + private val defaultHighlightColor = editor.highlightColor + private val defaultSelectionColor = context.resolveThemeColor( + android.R.attr.colorAccent, + editor.currentTextColor, + ) private val onComposerChange by EventDispatcher() private val onComposerSelectionChange by EventDispatcher() private val onComposerFocus by EventDispatcher() @@ -152,6 +159,11 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( val theme = JSONObject(themeJson) editor.setTextColor(parseColor(theme.optString("text"), Color.BLACK)) editor.setHintTextColor(parseColor(theme.optString("placeholder"), Color.GRAY)) + if (theme.isNull("selection")) { + resetSelectionTheme() + } else { + applySelectionTheme(parseColor(theme.optString("selection"), editor.currentTextColor)) + } chipTheme = ComposerChipTheme( chipBackground = parseColor(theme.optString("chipBackground"), chipTheme.chipBackground), chipBorder = parseColor(theme.optString("chipBorder"), chipTheme.chipBorder), @@ -277,6 +289,21 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( editor.setLineSpacing(max(0, desiredLineHeightPx - fontHeight).toFloat(), 1f) } + private fun applySelectionTheme(color: Int) { + editor.highlightColor = color.withAlpha(0x52) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return + + editor.textCursorDrawable = editor.textCursorDrawable?.mutate()?.apply { setTint(color) } + editor.textSelectHandle?.mutate()?.setTint(color) + editor.textSelectHandleLeft?.mutate()?.setTint(color) + editor.textSelectHandleRight?.mutate()?.setTint(color) + } + + private fun resetSelectionTheme() { + applySelectionTheme(defaultSelectionColor) + editor.highlightColor = defaultHighlightColor + } + private fun currentSelectionPayload(): Map = mapOf( "start" to editor.selectionStart.coerceAtLeast(0), @@ -335,12 +362,24 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( private fun parseColor(value: String, fallback: Int): Int = try { - Color.parseColor(value) + val androidColor = if (value.length == 9 && value.startsWith("#")) { + "#${value.takeLast(2)}${value.substring(1, 7)}" + } else { + value + } + Color.parseColor(androidColor) } catch (_: Exception) { fallback } } +private fun Int.withAlpha(alpha: Int): Int = (this and 0x00FFFFFF) or (alpha shl 24) + +private fun Context.resolveThemeColor(attribute: Int, fallback: Int): Int { + val value = TypedValue() + return if (theme.resolveAttribute(attribute, value, true)) value.data else fallback +} + private data class ComposerToken( val type: String, val source: String, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index fbe2785e07bf..9a2925ccd9cf 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -51,6 +51,7 @@ "@expo/metro-runtime": "~57.0.14", "@expo/ui": "~57.0.14", "@legendapp/list": "catalog:", + "@material/material-color-utilities": "0.3.0", "@noble/curves": "catalog:", "@pierre/diffs": "catalog:", "@react-native-ai/apple": "0.12.0", diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx index 46bc2c7c0912..f397c18a0a8f 100644 --- a/apps/mobile/src/components/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -48,6 +48,7 @@ export function AndroidScreenHeader(props: { readonly trailing?: ReactNode; readonly onBack?: () => void; readonly embedded?: boolean; + readonly hideBottomBorder?: boolean; }) { const insets = useSafeAreaInsets(); @@ -56,6 +57,7 @@ export function AndroidScreenHeader(props: { className="border-b border-header-border bg-header px-3 pb-2.5" style={{ paddingTop: props.embedded ? 8 : Math.max(insets.top, 12), + borderBottomWidth: props.hideBottomBorder ? 0 : undefined, }} > diff --git a/apps/mobile/src/components/ThemedSwitch.tsx b/apps/mobile/src/components/ThemedSwitch.tsx index 5b4603fd1201..08cef56f3ec0 100644 --- a/apps/mobile/src/components/ThemedSwitch.tsx +++ b/apps/mobile/src/components/ThemedSwitch.tsx @@ -1,6 +1,52 @@ -import { Platform, Switch, type SwitchProps } from "react-native"; +import { Platform, Pressable, Switch, View, type SwitchProps } from "react-native"; + +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { SymbolView } from "./AppSymbol"; export function ThemedSwitch(props: SwitchProps) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); + if (materialYouStyleLayoutActive) { + return ( + props.onValueChange?.(!props.value)} + style={props.style} + testID={props.testID} + className={props.disabled ? "opacity-40" : "active:opacity-70"} + > + + + + + + + ); + } + return ( {/* Static header config (glass preset and title) lives in Stack.tsx. The live sheet color stays dynamic here so the FlatList can remain the direct scene child for native scroll-edge sampling. */} 0 ? projectName : undefined, @@ -466,6 +470,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { title="Files" subtitle={projectName} onBack={handleReturnToThread} + hideBottomBorder={materialYouStyleLayoutActive} actions={[ { accessibilityLabel: "Refresh files", @@ -474,7 +479,13 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { }, ]} /> - + ); + + return materialYouStyleLayoutActive ? ( + + {content} + + ) : ( + content + ); } export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index cbeadf59f6f6..f67c2bff0f68 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -1,3 +1,4 @@ +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; import Constants from "expo-constants"; @@ -66,6 +67,7 @@ function checkedMenuState(checked: boolean) { } function AndroidHomeHeader(props: HomeHeaderProps) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const insets = useSafeAreaInsets(); const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); // Thread List v2 lays the list out in fixed creation order, so the @@ -199,7 +201,11 @@ function AndroidHomeHeader(props: HomeHeaderProps) { <> - + >(() => new Map()); @@ -1081,26 +1084,31 @@ export function HomeScreen(props: HomeScreenProps) { if (!hasAnyThreads) { return ( - - - - {emptyState.loading ? ( - - - - ) : null} + + + + + {emptyState.loading ? ( + + + + ) : null} + ); @@ -1145,100 +1153,116 @@ export function HomeScreen(props: HomeScreenProps) { if (threadListV2Enabled) { return ( - + + + + 0 ? ( + ({ opacity: pressed ? 0.6 : 1 })} + > + + Show more ({threadListV2Layout.hiddenSettledCount} settled hidden) + + + ) : null + } + ListEmptyComponent={v2ListEmpty} + style={{ flex: 1 }} + automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} + contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} + showsVerticalScrollIndicator={false} + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="handled" + {...scrollGateHandlers} + scrollEventThrottle={16} + contentContainerStyle={{ + paddingBottom: + Platform.OS === "ios" + ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance + : Math.max(insets.bottom, 16) + 88, + }} + /> + + + + ); + } + + return ( + + + {/* Sticky headers are deliberately not wired up: LegendList's JS sticky + implementation mispositions pinned headers at mount under iOS + automatic content insets (headers render one nav-inset too low until + the first scroll event) and blanks non-pinned headers after + collapse/expand data changes. The flattened layout still exposes + `stickyHeaderIndices` if this gets revisited. */} - 0 ? ( - ({ opacity: pressed ? 0.6 : 1 })} - > - - Show more ({threadListV2Layout.hiddenSettledCount} settled hidden) - - - ) : null - } - ListEmptyComponent={v2ListEmpty} + - ); - } - - return ( - - {/* Sticky headers are deliberately not wired up: LegendList's JS sticky - implementation mispositions pinned headers at mount under iOS - automatic content insets (headers render one nav-inset too low until - the first scroll event) and blanks non-pinned headers after - collapse/expand data changes. The flattened layout still exposes - `stickyHeaderIndices` if this gets revisited. */} - - - ); } diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 34bc450a4ed9..4d9968cca097 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -47,6 +47,7 @@ import { } from "../keyboard/hardwareKeyboardCommands"; import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; import { HomeListOptionsProvider } from "../home/home-list-options"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; @@ -219,6 +220,7 @@ function AdaptiveWorkspaceLayoutContent( }, ) { const projectGroupingMode = props.projectGroupingMode; + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const { width, height } = useWindowDimensions(); const pathname = props.pathname; const navigation = useNavigation(); @@ -567,7 +569,14 @@ function AdaptiveWorkspaceLayoutContent( ) : null} - + void }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); return ( - + ) { } function appTheme(themeId: MobileThemeId, appearance: MobileThemeAppearance) { - return themeId === DEFAULT_MOBILE_THEME_ID + return themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you" ? readDefaultMobileThemeVariables(appearance) : getMobileThemeVariables(themeId, appearance); } diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index f161e654788b..e6622e3269e9 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -3,17 +3,19 @@ import { startTransition, use, useCallback, + useEffect, + useState, useLayoutEffect, useMemo, useRef, type ReactNode, } from "react"; -import { Appearance, useColorScheme } from "react-native"; +import { AppState, Appearance, Platform, useColorScheme } from "react-native"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; -import { ScopedTheme, Uniwind } from "uniwind"; +import { ScopedTheme, ScopedVariables, Uniwind } from "uniwind"; import { resolveAppearance, @@ -22,6 +24,10 @@ import { } from "../../../lib/appearancePreferences"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; import type { Preferences } from "../../../persistence/mobile-preferences"; +import { isSystemColorsAvailable, readSystemColorPalettes } from "../../../lib/materialYouPalette"; +import { materialYouPaletteToMobileThemeVariables } from "../../../lib/materialYouTheme"; +import { getMobileThemeRuntimeVariables } from "../../../lib/mobileThemeVariables"; +import type { MobileThemeVariables } from "../../../lib/mobileTheme"; import { createMobileThemePairPatch, createMobileThemeSelectionPatch, @@ -45,6 +51,16 @@ interface AppearancePreferencesContextValue { readonly themeIds: MobileThemeIds; readonly themeMode: MobileThemeMode; readonly themeAppearance: MobileThemeAppearance; + readonly materialYouStyleLayoutEnabled: boolean; + readonly materialYouStyleLayoutActive: boolean; + readonly setMaterialYouStyleLayoutEnabled: (value: boolean) => void; + readonly systemColorsAvailable: boolean; + readonly systemColorsActive: boolean; + readonly themeVariables: MobileThemeVariables; + readonly themeVariablesByAppearance: Readonly< + Record + >; + readonly systemColorPalettes: ReturnType; readonly isReady: boolean; readonly setThemeIdForAppearance: ( appearance: MobileThemeAppearance, @@ -81,6 +97,41 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN [resolvedThemeIds.dark, resolvedThemeIds.light], ); const themeId = themeIds[themeAppearance]; + const materialYouStyleLayoutEnabled = storedPreferences?.materialYouStyleLayoutEnabled ?? false; + const materialYouStyleLayoutActive = Platform.OS === "android" && materialYouStyleLayoutEnabled; + const systemColorsActive = themeId === "material-you" && isSystemColorsAvailable; + const [systemColorPalettes, setSystemColorPalettes] = useState(readSystemColorPalettes); + useEffect(() => { + if (!isSystemColorsAvailable) return; + const refresh = () => { + const next = readSystemColorPalettes(); + setSystemColorPalettes((previous) => + JSON.stringify(previous) === JSON.stringify(next) ? previous : next, + ); + }; + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") refresh(); + }); + const focusSubscription = AppState.addEventListener("focus", refresh); + return () => { + subscription.remove(); + focusSubscription.remove(); + }; + }, []); + const themeVariablesByAppearance = useMemo(() => { + const resolve = (appearance: MobileThemeAppearance) => { + const base = getMobileThemeRuntimeVariables(themeIds[appearance], appearance); + return themeIds[appearance] === "material-you" && systemColorPalettes + ? materialYouPaletteToMobileThemeVariables( + systemColorPalettes[appearance], + appearance, + base, + ) + : base; + }; + return { light: resolve("light"), dark: resolve("dark") }; + }, [themeIds, systemColorPalettes]); + const themeVariables = themeVariablesByAppearance[themeAppearance]; const activeThemeName = getMobileUniwindThemeName(themeId, themeAppearance); const { baseFontSize, codeFontSize, codeWordBreak, terminalFontSize } = preferences; const appearance = useMemo( @@ -196,6 +247,13 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN [runtimeState, syncThemeRuntime, updateThemePreferences], ); + const setMaterialYouStyleLayoutEnabled = useCallback( + (value: boolean) => { + updatePreferences({ materialYouStyleLayoutEnabled: value }); + }, + [updatePreferences], + ); + const setBaseFontSize = useCallback( (value: number) => { const current = appliedRuntimeStateRef.current ?? runtimeState; @@ -233,6 +291,14 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN themeIds, themeMode, themeAppearance, + systemColorsAvailable: isSystemColorsAvailable, + systemColorsActive, + materialYouStyleLayoutEnabled, + materialYouStyleLayoutActive, + setMaterialYouStyleLayoutEnabled, + themeVariables, + themeVariablesByAppearance, + systemColorPalettes, isReady, setThemeIdForAppearance, setThemeIdForBothAppearances, @@ -248,6 +314,13 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN themeIds, themeMode, themeAppearance, + systemColorsActive, + materialYouStyleLayoutEnabled, + materialYouStyleLayoutActive, + setMaterialYouStyleLayoutEnabled, + themeVariables, + themeVariablesByAppearance, + systemColorPalettes, isReady, setThemeIdForAppearance, setThemeIdForBothAppearances, @@ -261,7 +334,9 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN return ( - {props.children} + + {props.children} + ); } diff --git a/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx index 2257115828b6..a677cc5707d2 100644 --- a/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx +++ b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx @@ -1,7 +1,7 @@ import { memo, useId } from "react"; -import { Pressable, View } from "react-native"; +import { Platform, Pressable, View } from "react-native"; import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg"; -import { ScopedTheme } from "uniwind"; +import { ScopedTheme, ScopedVariables } from "uniwind"; import { mixThemePreviewBase, THEME_PREVIEW_RENDER_SPECS } from "@t3tools/shared/themePreview"; @@ -19,6 +19,9 @@ import { getMobileUniwindThemeName } from "../../../../lib/mobileThemeRuntime"; import { cn } from "../../../../lib/cn"; import { useAppearancePreferences } from "../AppearancePreferencesProvider"; +import { SettingsSection } from "../../components/SettingsSection"; +import { SettingsSwitchRow } from "../../components/SettingsSwitchRow"; + const APPEARANCE_MODES: ReadonlyArray<{ readonly id: MobileThemeMode; readonly label: string; @@ -38,7 +41,12 @@ const PreviewOrb = memo(function PreviewOrb(props: { const idPrefix = useId().replaceAll(":", ""); const accentGradientId = `${idPrefix}-accent-glow`; const actionGradientId = `${idPrefix}-action-glow`; - const colors = getMobileThemePreviewColors(props.themeId, props.appearance); + const { systemColorPalettes } = useAppearancePreferences(); + const palette = systemColorPalettes?.[props.appearance]; + const colors = + props.themeId === "material-you" && palette + ? { canvas: palette.surface, accent: palette.primary, messageAction: palette.tertiary } + : getMobileThemePreviewColors(props.themeId, props.appearance); const spec = THEME_PREVIEW_RENDER_SPECS[props.appearance]; const accentRadius = Math.hypot( Math.max(spec.accent.center[0], 1 - spec.accent.center[0]), @@ -204,15 +212,20 @@ function PreviewPane(props: { readonly compact?: boolean }) { } function ModePreview(props: { readonly mode: MobileThemeMode; readonly themeIds: MobileThemeIds }) { + const { themeVariablesByAppearance } = useAppearancePreferences(); if (props.mode === "system") { return ( - + + + - + + + @@ -224,7 +237,9 @@ function ModePreview(props: { readonly mode: MobileThemeMode; readonly themeIds: - + + + @@ -278,10 +293,25 @@ export function ThemeAppearanceSection() { setThemeMode, themeIds, themeMode, + materialYouStyleLayoutEnabled, + setMaterialYouStyleLayoutEnabled, + systemColorsAvailable, } = useAppearancePreferences(); return ( + {Platform.OS === "android" ? ( + + + + ) : null} Color scheme @@ -302,7 +332,9 @@ export function ThemeAppearanceSection() { Themes - {MOBILE_THEME_OPTIONS.map((theme) => ( + {MOBILE_THEME_OPTIONS.filter( + (theme) => theme.id !== "material-you" || systemColorsAvailable, + ).map((theme) => ( candidate.id === themeId) ?? BUILT_IN_THEMES[0]; const palette = getThemeColorsForAppearance(theme, scheme) ?? theme.colors; diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 684b69c843a5..bdc26628dd1c 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1,3 +1,4 @@ +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentId, @@ -170,6 +171,7 @@ export function ComposerSurface(props: { /** Morphs between the compact and expanded composer layouts. */ readonly animateLayout?: boolean; }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const targetBorderRadius = typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0; const animatedBorderRadius = useSharedValue(targetBorderRadius); @@ -191,7 +193,11 @@ export function ComposerSurface(props: { // clip leaves the glass and content at their final height on the first frame. return ( {/* The backdrop gradient lives on a plain View: Reanimated's Animated.View silently drops experimental_backgroundImage on Android, which left this strip fully transparent and the feed text legible through the composer. */} ); case "v2-settled-shelf": @@ -966,7 +972,7 @@ function ThreadNavigationSidebarPane( disabled={!shelfPreferencesLoaded} expanded={item.expanded} onToggle={toggleSettledShelf} - pane="sidebar" + pane={materialYouStyleLayoutActive ? "screen" : "sidebar"} /> ); case "v2-show-more": @@ -986,7 +992,7 @@ function ThreadNavigationSidebarPane( case "header": return ( - + - + + {materialYouStyleLayoutActive && props.searchQuery.length > 0 ? ( + props.onSearchQueryChange("")} + > + + + ) : null} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 55f122d6389a..0ff42a077b04 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -76,6 +76,7 @@ import { useSelectedThreadWorktree } from "../../state/use-selected-thread-workt import { useThreadComposerState } from "../../state/use-thread-composer-state"; import { threadEnvironment } from "../../state/threads"; import { projectThreadContentPresentation } from "./threadContentPresentation"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAdaptiveWorkspaceLayout, useAdaptiveWorkspacePaneRole, @@ -193,6 +194,8 @@ function ThreadRouteContent( readonly selectedThreadDetailState: ReturnType; }, ) { + const { materialYouStyleLayoutActive, themeVariables } = useAppearancePreferences(); + const headerColor = themeVariables["--color-header"]; const { fileInspector, layout, @@ -839,7 +842,19 @@ function ThreadRouteContent( - + (layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems) : undefined, unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined, + contentStyle: + Platform.OS === "android" && materialYouStyleLayoutActive + ? { backgroundColor: headerColor } + : undefined, }} /> @@ -940,6 +959,7 @@ function ThreadRouteContent( subtitle={headerSubtitle} onBack={layout.usesSplitView ? undefined : () => navigation.goBack()} actions={androidHeaderActions} + hideBottomBorder={materialYouStyleLayoutActive} /> ) : null} diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 24cdb0cc4546..2d4276606ec3 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -52,6 +52,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { useNewTaskFlow } from "./new-task-flow-provider"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { createProviderCatalogRefreshRunner, providerCatalogRefreshError, @@ -104,6 +105,8 @@ function ModelRow(props: { readonly isFirst: boolean; readonly isLast: boolean; }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); + const selectedMaterialRow = materialYouStyleLayoutActive && props.selected; return ( ["simultaneousWithExternalGesture"]; }) { const { width: windowWidth } = useWindowDimensions(); - const { themeAppearance: colorScheme } = useAppearancePreferences(); + const { themeAppearance: colorScheme, materialYouStyleLayoutActive } = useAppearancePreferences(); const compact = props.variant === "compact"; const selected = props.selected === true; + const visuallySelected = selected && (!compact || materialYouStyleLayoutActive); // Recycling-safe: resets when the list container is reused for another // thread, so a hover highlight can't leak across rows. const [hovered, setHovered] = useRecyclingState(false); @@ -475,6 +476,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const drawerColor = theme["--color-drawer"]; const pressedBackgroundColor = theme["--color-subtle"]; const selectedBackgroundColor = theme["--color-user-bubble"]; + const materialSelectedBackgroundColor = theme["--color-thread-selected"]; + const materialSelectedForegroundColor = theme["--color-thread-selected-foreground"]; const selectedForegroundColor = theme["--color-user-bubble-foreground"]; const { @@ -502,15 +505,25 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ); const backgroundColor = compact ? screenColor : drawerColor; - const effectivePressedBackground = selected - ? themeColorWithAlpha(String(selectedForegroundColor), 0.16) + const effectiveSelectedBackground = materialYouStyleLayoutActive + ? materialSelectedBackgroundColor + : selectedBackgroundColor; + const effectiveSelectedForeground = materialYouStyleLayoutActive + ? materialSelectedForegroundColor + : selectedForegroundColor; + const effectivePressedBackground = visuallySelected + ? themeColorWithAlpha(String(effectiveSelectedForeground), 0.16) : pressedBackgroundColor; const effectiveStatus = - selected && status + visuallySelected && status ? { ...status, - pillClassName: "bg-user-bubble-foreground/20", - textClassName: "text-user-bubble-foreground", + pillClassName: materialYouStyleLayoutActive + ? "bg-thread-selected-foreground/20" + : "bg-user-bubble-foreground/20", + textClassName: materialYouStyleLayoutActive + ? "text-thread-selected-foreground" + : "text-user-bubble-foreground", } : status; @@ -581,7 +594,9 @@ export const ThreadListRow = memo(function ThreadListRow(props: { compact ? "accent-icon-subtle" : selected - ? "accent-user-bubble-foreground-muted" + ? materialYouStyleLayoutActive + ? "accent-thread-selected-foreground-muted" + : "accent-user-bubble-foreground-muted" : "accent-foreground-muted" } /> @@ -589,9 +604,12 @@ export const ThreadListRow = memo(function ThreadListRow(props: { @@ -604,12 +622,18 @@ export const ThreadListRow = memo(function ThreadListRow(props: { {pr.label} @@ -626,6 +650,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: { accessibilityLabel={threadAccessibilityLabel} accessibilityRole="button" className="bg-screen active:opacity-70" + style={ + materialYouStyleLayoutActive + ? { + backgroundColor: visuallySelected ? effectiveSelectedBackground : backgroundColor, + borderRadius: SIDEBAR_ROW_RADIUS, + } + : undefined + } onPress={() => { close(); onSelectThread(thread); @@ -634,13 +666,36 @@ export const ThreadListRow = memo(function ThreadListRow(props: { - + {thread.title} - {props.hasQueuedMessages ? : null} + {props.hasQueuedMessages ? ( + + ) : null} {statusPill} - {timestamp} + + {timestamp} + ) : null} {subtitleRow} @@ -673,8 +729,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { onSelectThread(thread); }} style={({ pressed }) => ({ - backgroundColor: selected - ? selectedBackgroundColor + backgroundColor: visuallySelected + ? effectiveSelectedBackground : pressed || hovered ? effectivePressedBackground : backgroundColor, @@ -691,19 +747,29 @@ export const ThreadListRow = memo(function ThreadListRow(props: { {thread.title} - {props.hasQueuedMessages ? : null} + {props.hasQueuedMessages ? ( + + ) : null} {statusPill} @@ -715,7 +781,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ) : null} {subtitleRow} @@ -728,7 +794,9 @@ export const ThreadListRow = memo(function ThreadListRow(props: { threadKey={`${thread.environmentId}:${thread.id}`} backgroundColor={backgroundColor} containerStyle={ - compact ? undefined : { borderRadius: SIDEBAR_ROW_RADIUS, overflow: "hidden" } + compact && !materialYouStyleLayoutActive + ? undefined + : { borderRadius: SIDEBAR_ROW_RADIUS, overflow: "hidden" } } enableTrackpadSwipe fullSwipeWidth={props.fullSwipeWidth ?? windowWidth - 32} diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 73c6084cff61..518804365879 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -1,3 +1,4 @@ +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import type { EnvironmentProject, EnvironmentThreadShell, @@ -429,11 +430,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const pr = useThreadPr(thread); + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const theme = useUniwindTheme(); const screenColor = theme["--color-screen"]; const drawerColor = theme["--color-drawer"]; const pressedBackgroundColor = theme["--color-subtle"]; - const selectedBackgroundColor = theme["--color-user-bubble"]; + const selectedBackgroundColor = + theme[materialYouStyleLayoutActive ? "--color-thread-selected" : "--color-user-bubble"]; const sidebarPane = props.pane === "sidebar"; const selected = props.selected === true; // The provider badge's border blends into the row's own surface, which @@ -720,7 +723,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -739,7 +746,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { className={cn( "text-xs tabular-nums", selected - ? "text-user-bubble-foreground" + ? materialYouStyleLayoutActive + ? "text-thread-selected-foreground" + : "text-user-bubble-foreground" : (statusLabel?.className ?? "text-foreground-tertiary"), )} > @@ -749,7 +758,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -769,7 +782,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -786,7 +803,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -794,7 +815,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -806,7 +831,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { {props.environmentLabel} @@ -818,7 +847,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { kind={props.environmentMachine} size={11} tintColorClassName={ - selected ? "accent-user-bubble-foreground-muted" : "accent-foreground-tertiary" + selected + ? materialYouStyleLayoutActive + ? "accent-thread-selected-foreground-muted" + : "accent-user-bubble-foreground-muted" + : "accent-foreground-tertiary" } /> ) : null} @@ -829,7 +862,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { {pr ? ( #{pr.label} @@ -863,16 +903,17 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onSelectThread(thread); }} style={ - sidebarPane + sidebarPane || materialYouStyleLayoutActive ? ({ pressed }) => ({ backgroundColor: selected ? selectedBackgroundColor : pressed ? pressedBackgroundColor - : drawerColor, + : sidebarPane + ? drawerColor + : screenColor, borderRadius: SIDEBAR_V2_ROW_RADIUS, - paddingHorizontal: 12, - paddingVertical: 10, + ...(sidebarPane ? { paddingHorizontal: 12, paddingVertical: 10 } : null), }) : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) } @@ -884,7 +925,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { labels and text hierarchy carry state, an inset hairline separates rows. The opaque screen background stays so swipe actions reveal behind the row. */ - + {cardContent} {props.showTrailingDivider !== false ? ( @@ -900,19 +941,21 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { } accessibilityRole="button" accessibilityState={{ selected }} - className={sidebarPane ? undefined : "bg-screen"} + className={sidebarPane || materialYouStyleLayoutActive ? undefined : "bg-screen"} onPress={() => { close(); onSelectThread(thread); }} style={ - sidebarPane + sidebarPane || materialYouStyleLayoutActive ? ({ pressed }) => ({ backgroundColor: selected ? selectedBackgroundColor : pressed ? pressedBackgroundColor - : drawerColor, + : sidebarPane + ? drawerColor + : screenColor, borderRadius: SIDEBAR_V2_ROW_RADIUS, }) : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) @@ -940,7 +983,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -959,7 +1006,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { className={cn( "text-sm tabular-nums", selected - ? "text-user-bubble-foreground-muted" + ? materialYouStyleLayoutActive + ? "text-thread-selected-foreground-muted" + : "text-user-bubble-foreground-muted" : snoozedRow ? "text-foreground-secondary" : "text-foreground-tertiary", diff --git a/apps/mobile/src/features/threads/thread-search-match.tsx b/apps/mobile/src/features/threads/thread-search-match.tsx index 9c478f3c4504..bc10bd9fe922 100644 --- a/apps/mobile/src/features/threads/thread-search-match.tsx +++ b/apps/mobile/src/features/threads/thread-search-match.tsx @@ -2,6 +2,7 @@ import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state import { AppText as Text } from "../../components/AppText"; import { cn } from "../../lib/cn"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; function foldAsciiCase(value: string): string { return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); @@ -50,12 +51,17 @@ export function ThreadSearchMatchExcerpt(props: { readonly compact?: boolean; }) { const isUser = props.match.source === "user"; + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const parts = splitHighlightParts(props.match.snippet, props.query); return ( @@ -63,7 +69,9 @@ export function ThreadSearchMatchExcerpt(props: { className={cn( props.compact ? "text-sm font-t3-medium" : "text-xs font-t3-medium", props.selected - ? "text-user-bubble-foreground" + ? materialYouStyleLayoutActive + ? "text-thread-selected-foreground" + : "text-user-bubble-foreground" : isUser ? "text-foreground-secondary" : "text-adaptive-emerald-600-400", @@ -77,7 +85,9 @@ export function ThreadSearchMatchExcerpt(props: { props.compact ? "text-sm" : "text-xs", part.highlighted && "font-t3-bold", props.selected - ? "text-user-bubble-foreground" + ? materialYouStyleLayoutActive + ? "text-thread-selected-foreground" + : "text-user-bubble-foreground" : part.highlighted ? "text-foreground" : "text-foreground-muted", diff --git a/apps/mobile/src/lib/materialYouPalette.android.ts b/apps/mobile/src/lib/materialYouPalette.android.ts new file mode 100644 index 000000000000..c88dbc3747f7 --- /dev/null +++ b/apps/mobile/src/lib/materialYouPalette.android.ts @@ -0,0 +1,14 @@ +import { getMaterialColors, isDynamicColorAvailable } from "@expo/ui/jetpack-compose"; + +import type { MaterialYouPalettes } from "./materialYouPalette"; + +export const isSystemColorsAvailable = isDynamicColorAvailable; + +export function readSystemColorPalettes(): MaterialYouPalettes | null { + if (!isDynamicColorAvailable) return null; + + return { + light: getMaterialColors({ scheme: "light" }), + dark: getMaterialColors({ scheme: "dark" }), + }; +} diff --git a/apps/mobile/src/lib/materialYouPalette.ts b/apps/mobile/src/lib/materialYouPalette.ts new file mode 100644 index 000000000000..e1e52c8fa794 --- /dev/null +++ b/apps/mobile/src/lib/materialYouPalette.ts @@ -0,0 +1,36 @@ +export interface MaterialYouPalette { + readonly primary: string; + readonly onPrimary: string; + readonly primaryContainer: string; + readonly onPrimaryContainer: string; + readonly inversePrimary: string; + readonly secondaryContainer: string; + readonly onSecondaryContainer: string; + readonly tertiary: string; + readonly tertiaryContainer: string; + readonly onTertiaryContainer: string; + readonly surface: string; + readonly onSurface: string; + readonly onSurfaceVariant: string; + readonly surfaceContainer: string; + readonly surfaceContainerHigh: string; + readonly surfaceContainerHighest: string; + readonly surfaceContainerLow: string; + readonly surfaceContainerLowest: string; + readonly errorContainer: string; + readonly onErrorContainer: string; + readonly outline: string; + readonly outlineVariant: string; + readonly scrim: string; +} + +export interface MaterialYouPalettes { + readonly light: MaterialYouPalette; + readonly dark: MaterialYouPalette; +} + +export const isSystemColorsAvailable = false; + +export function readSystemColorPalettes(): MaterialYouPalettes | null { + return null; +} diff --git a/apps/mobile/src/lib/materialYouTheme.test.ts b/apps/mobile/src/lib/materialYouTheme.test.ts new file mode 100644 index 000000000000..af52c2635e36 --- /dev/null +++ b/apps/mobile/src/lib/materialYouTheme.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; +import type { MaterialYouPalette } from "./materialYouPalette"; +import { materialYouPaletteToMobileThemeVariables } from "./materialYouTheme"; + +const palette: MaterialYouPalette = { + primary: "#6750A4FF", + onPrimary: "#FFFFFFFF", + primaryContainer: "#EADDFFFF", + onPrimaryContainer: "#21005DFF", + inversePrimary: "#D0BCFFFF", + secondaryContainer: "#E8DEF8FF", + onSecondaryContainer: "#1D192BFF", + tertiary: "#7D5260FF", + tertiaryContainer: "#FFD8E4FF", + onTertiaryContainer: "#31111DFF", + surface: "#FFFBFEFF", + onSurface: "#1C1B1FFF", + onSurfaceVariant: "#49454FFF", + surfaceContainer: "#F3EDF7FF", + surfaceContainerHigh: "#ECE6F0FF", + surfaceContainerHighest: "#E6E0E9FF", + surfaceContainerLow: "#F7F2FAFF", + surfaceContainerLowest: "#FFFFFFFF", + errorContainer: "#F9DEDCFF", + onErrorContainer: "#410E0BFF", + outline: "#79747EFF", + outlineVariant: "#CAC4D0FF", + scrim: "#000000FF", +}; + +describe("Material You system colors", () => { + it("overrides the selected theme without mutating its base variables", () => { + const base = getMobileThemeRuntimeVariables("t3-code", "dark"); + const snapshot = { ...base }; + + const variables = materialYouPaletteToMobileThemeVariables(palette, "dark", base); + + expect(base).toEqual(snapshot); + expect(variables["--color-screen"]).toBe(palette.surface); + expect(variables["--color-thread-canvas"]).toBe(palette.surfaceContainerLow); + expect(variables["--color-thread-selected"]).toBe(palette.surfaceContainer); + expect(variables["--color-header"]).toBe(palette.surfaceContainerHigh); + expect(variables["--color-primary"]).toBe(palette.primary); + expect(variables["--color-placeholder"]).toBe("#1C1B1F9E"); + }); + + it("uses the Messages-style RCS tones for sent messages", () => { + const base = getMobileThemeRuntimeVariables("t3-code", "dark"); + const dark = materialYouPaletteToMobileThemeVariables( + { ...palette, inversePrimary: "#A31D8DFF" }, + "dark", + base, + ); + const light = materialYouPaletteToMobileThemeVariables( + { ...palette, inversePrimary: "#A31D8DFF" }, + "light", + getMobileThemeRuntimeVariables("t3-code", "light"), + ); + + expect(dark["--color-user-bubble"]).toBe("#850073FF"); + expect(dark["--color-user-bubble-foreground"]).toBe("#FFD7EFFF"); + expect(light["--color-user-bubble"]).toBe(palette.primary); + expect(light["--color-user-bubble-foreground"]).toBe(palette.onPrimary); + }); +}); diff --git a/apps/mobile/src/lib/materialYouTheme.ts b/apps/mobile/src/lib/materialYouTheme.ts new file mode 100644 index 000000000000..ce16b23c89ec --- /dev/null +++ b/apps/mobile/src/lib/materialYouTheme.ts @@ -0,0 +1,108 @@ +import { Hct, argbFromHex, hexFromArgb } from "@material/material-color-utilities"; + +import type { MobileThemeAppearance, MobileThemeVariables } from "./mobileTheme"; +import type { MaterialYouPalette } from "./materialYouPalette"; + +function withAlpha(color: string, alpha: number): string { + const match = /^#([\da-f]{6})(?:[\da-f]{2})?$/iu.exec(color); + if (!match?.[1]) return color; + const alphaByte = Math.round(Math.min(1, Math.max(0, alpha)) * 255) + .toString(16) + .padStart(2, "0") + .toUpperCase(); + return `#${match[1]}${alphaByte}`; +} + +function materialTone(color: string, tone: number): string { + const match = /^#([\da-f]{6})(?:[\da-f]{2})?$/iu.exec(color); + if (!match?.[1]) return color; + + const source = Hct.fromInt(argbFromHex(match[1])); + return `${hexFromArgb(Hct.from(source.hue, source.chroma, tone).toInt()).toUpperCase()}FF`; +} + +export function materialYouPaletteToMobileThemeVariables( + palette: MaterialYouPalette, + appearance: MobileThemeAppearance, + base: MobileThemeVariables, +): MobileThemeVariables { + const dark = appearance === "dark"; + const userBubble = dark ? materialTone(palette.inversePrimary, 30) : palette.primary; + const userBubbleForeground = dark ? materialTone(palette.inversePrimary, 90) : palette.onPrimary; + + return { + ...base, + "--color-screen": palette.surface, + "--color-sheet": withAlpha(palette.surfaceContainerLow, 0.98), + "--color-sheet-solid": palette.surfaceContainerLow, + "--color-card": palette.surfaceContainer, + "--color-card-alt": palette.surfaceContainerHigh, + "--color-card-translucent": withAlpha(palette.surfaceContainer, 0.8), + "--color-thread-canvas": palette.surfaceContainerLow, + "--color-thread-selected": palette.surfaceContainer, + "--color-thread-selected-foreground": palette.onSurface, + "--color-thread-selected-foreground-muted": palette.onSurfaceVariant, + "--color-composer-panel": withAlpha(palette.surfaceContainerLow, dark ? 0.92 : 0.88), + "--color-composer-surface": withAlpha(palette.surfaceContainerHigh, dark ? 0.9 : 0.94), + "--color-composer-border": withAlpha(palette.outlineVariant, dark ? 0.34 : 0.46), + "--color-foreground": palette.onSurface, + "--color-foreground-secondary": palette.onSurfaceVariant, + "--color-foreground-muted": palette.onSurfaceVariant, + "--color-foreground-tertiary": withAlpha(palette.onSurfaceVariant, 0.58), + "--color-border": withAlpha(palette.outlineVariant, dark ? 0.5 : 0.65), + "--color-border-subtle": withAlpha(palette.outlineVariant, dark ? 0.34 : 0.46), + "--color-separator": withAlpha(palette.outlineVariant, dark ? 0.24 : 0.34), + "--color-subtle": withAlpha(palette.onSurface, 0.05), + "--color-subtle-strong": withAlpha(palette.onSurface, 0.1), + "--color-inline-skill-background": withAlpha(palette.tertiaryContainer, 0.52), + "--color-inline-skill-border": withAlpha(palette.tertiary, 0.38), + "--color-inline-skill-foreground": palette.onTertiaryContainer, + "--color-primary": palette.primary, + "--color-primary-foreground": palette.onPrimary, + "--color-primary-shadow": withAlpha(palette.scrim, dark ? 0.22 : 0.18), + "--color-secondary": palette.secondaryContainer, + "--color-secondary-foreground": palette.onSecondaryContainer, + "--color-secondary-border": withAlpha(palette.outlineVariant, dark ? 0.42 : 0.56), + "--color-switch-active-track": palette.primary, + "--color-switch-active-thumb": palette.onPrimary, + "--color-switch-inactive-track": palette.surfaceContainerHighest, + "--color-switch-inactive-thumb": palette.outline, + "--color-danger": palette.errorContainer, + "--color-danger-border": withAlpha(palette.onErrorContainer, dark ? 0.42 : 0.5), + "--color-danger-foreground": palette.onErrorContainer, + "--color-input": palette.surfaceContainerLowest, + "--color-input-border": withAlpha(palette.outline, dark ? 0.42 : 0.5), + "--color-sidebar-search": palette.surfaceContainerLowest, + "--color-placeholder": withAlpha(palette.onSurface, 0.62), + "--color-icon": palette.onSurface, + "--color-icon-muted": palette.onSurfaceVariant, + "--color-icon-subtle": withAlpha(palette.onSurfaceVariant, 0.68), + "--color-header": palette.surfaceContainerHigh, + "--color-header-border": withAlpha(palette.outlineVariant, dark ? 0.36 : 0.48), + "--color-glass-surface": withAlpha(palette.surfaceContainerHigh, dark ? 0.78 : 0.72), + "--color-glass-tint": withAlpha(palette.primaryContainer, dark ? 0.24 : 0.18), + "--color-status-bar": palette.surfaceContainerLow, + "--color-md-body": materialTone(palette.onSurfaceVariant, dark ? 90 : 10), + "--color-md-strong": materialTone(palette.onSurfaceVariant, dark ? 90 : 10), + "--color-md-link": palette.primary, + "--color-md-blockquote-border": withAlpha(palette.primary, dark ? 0.34 : 0.26), + "--color-md-blockquote-bg": withAlpha(palette.primaryContainer, dark ? 0.14 : 0.2), + "--color-md-code-bg": withAlpha(palette.onSurface, dark ? 0.08 : 0.06), + "--color-md-code-text": palette.onSurface, + "--color-md-user-code-bg": withAlpha(userBubbleForeground, dark ? 0.18 : 0.22), + "--color-md-user-code-text": userBubbleForeground, + "--color-md-user-fence-bg": withAlpha(userBubbleForeground, dark ? 0.14 : 0.18), + "--color-md-user-fence-text": userBubbleForeground, + "--color-md-hr": withAlpha(palette.outlineVariant, dark ? 0.48 : 0.6), + "--color-user-bubble": userBubble, + "--color-user-bubble-foreground": userBubbleForeground, + "--color-user-bubble-foreground-muted": withAlpha(userBubbleForeground, 0.78), + "--color-user-bubble-skill-foreground": userBubbleForeground, + "--color-backdrop": withAlpha(palette.scrim, dark ? 0.48 : 0.22), + "--color-drawer": palette.surface, + "--color-drawer-shadow": withAlpha(palette.scrim, dark ? 0.32 : 0.12), + "--color-dot-separator": withAlpha(palette.onSurface, 0.2), + "--color-wordmark": palette.onSurface, + "--color-chevron": withAlpha(palette.onSurface, 0.2), + }; +} diff --git a/apps/mobile/src/lib/mobileTheme.test.ts b/apps/mobile/src/lib/mobileTheme.test.ts index 652de9296f1f..def50874c210 100644 --- a/apps/mobile/src/lib/mobileTheme.test.ts +++ b/apps/mobile/src/lib/mobileTheme.test.ts @@ -153,7 +153,7 @@ describe("mobile themes", () => { it("maps semantic palette roles onto every mobile color variable", () => { const variables = createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"); - expect(Object.keys(variables)).toHaveLength(68); + expect(Object.keys(variables)).toHaveLength(75); expect(variables["--color-sheet-solid"]).toBe( themeColorToNativeColor(BUILT_IN_THEMES[0].colors.chrome), ); diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index 10ef1b58edec..b2986906296f 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -14,8 +14,8 @@ import { } from "@t3tools/shared/themePreview"; export const DEFAULT_MOBILE_THEME_ID = MOBILE_DEFAULT_THEME_ID; -export const MOBILE_THEME_IDS = SHARED_MOBILE_THEME_IDS; -export type MobileThemeId = SharedMobileThemeId; +export const MOBILE_THEME_IDS = [...SHARED_MOBILE_THEME_IDS, "material-you"] as const; +export type MobileThemeId = SharedMobileThemeId | "material-you"; export type MobileThemeAppearance = ThemeAppearance; export type MobileThemeMode = MobileThemeAppearance | "system"; export type MobileThemeIds = Readonly>; @@ -25,6 +25,7 @@ export const MOBILE_THEME_OPTIONS: ReadonlyArray<{ readonly label: string; }> = [ { id: DEFAULT_MOBILE_THEME_ID, label: "T3 Code" }, + { id: "material-you", label: "Material You" }, ...BUILT_IN_THEMES.map((theme) => ({ id: theme.id as MobileThemeId, label: theme.label })), ]; @@ -217,6 +218,16 @@ export function createMobileThemeVariables( "--color-card": c.surfaceRaised, "--color-card-alt": c.surface, "--color-card-translucent": withAlpha(c.surfaceRaised, 0.8), + "--color-thread-canvas": c.surface, + "--color-thread-selected": c.surfaceRaised, + "--color-thread-selected-foreground": c.text, + "--color-thread-selected-foreground-muted": c.textMuted, + "--color-composer-panel": themeColorWithAlpha(c.surface, appearance === "dark" ? 0.92 : 0.88), + "--color-composer-surface": themeColorWithAlpha( + c.surfaceRaised, + appearance === "dark" ? 0.9 : 0.94, + ), + "--color-composer-border": themeColorWithAlpha(c.border, appearance === "dark" ? 0.46 : 0.54), "--color-foreground": c.text, "--color-foreground-secondary": c.textMuted, "--color-foreground-muted": c.mutedForeground, @@ -306,7 +317,8 @@ export function getMobileThemePreviewColors( themeId: MobileThemeId, appearance: MobileThemeAppearance, ): ThemePreviewColors { - if (themeId === DEFAULT_MOBILE_THEME_ID) return STANDARD_THEME_PREVIEW_COLORS[appearance]; + if (themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you") + return STANDARD_THEME_PREVIEW_COLORS[appearance]; const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0]; const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors; return { diff --git a/apps/mobile/src/lib/mobileThemeRuntime.ts b/apps/mobile/src/lib/mobileThemeRuntime.ts index 0c30de6bd46b..a777b2929fcf 100644 --- a/apps/mobile/src/lib/mobileThemeRuntime.ts +++ b/apps/mobile/src/lib/mobileThemeRuntime.ts @@ -42,7 +42,9 @@ export function getMobileUniwindThemeName( themeId: MobileThemeId, appearance: MobileThemeAppearance, ): MobileUniwindThemeName { - return themeId === DEFAULT_MOBILE_THEME_ID ? appearance : `${themeId}-${appearance}`; + return themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you" + ? appearance + : `${themeId}-${appearance}`; } /** diff --git a/apps/mobile/src/lib/mobileThemeVariables.ts b/apps/mobile/src/lib/mobileThemeVariables.ts index 79a479b4fe61..30c38e6bf425 100644 --- a/apps/mobile/src/lib/mobileThemeVariables.ts +++ b/apps/mobile/src/lib/mobileThemeVariables.ts @@ -21,7 +21,7 @@ export function getMobileThemeRuntimeVariables( themeId: MobileThemeId, appearance: MobileThemeAppearance, ): MobileThemeVariables { - return themeId === DEFAULT_MOBILE_THEME_ID + return themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you" ? defaults[appearance] : getMobileThemeVariables(themeId, appearance); } diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 15e385067dcd..856e253923f9 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -196,6 +196,31 @@ describe("mobile connection storage", () => { }); }); + it("persists Material You independently for each appearance", async () => { + const themes = { lightThemeId: "material-you", darkThemeId: "ocean" } as const; + await savePreferencesPatch(themes); + await expect(loadPreferences()).resolves.toEqual(themes); + await savePreferencesPatch({ lightThemeId: "t3-chat" }); + await expect(loadPreferences()).resolves.toEqual({ ...themes, lightThemeId: "t3-chat" }); + }); + + it("persists the Material You layout independently of the selected theme", async () => { + await savePreferencesPatch({ + lightThemeId: "material-you", + materialYouStyleLayoutEnabled: true, + }); + await savePreferencesPatch({ lightThemeId: "t3-chat" }); + await expect(loadPreferences()).resolves.toEqual({ + lightThemeId: "t3-chat", + materialYouStyleLayoutEnabled: true, + }); + await savePreferencesPatch({ materialYouStyleLayoutEnabled: false }); + await expect(loadPreferences()).resolves.toEqual({ + lightThemeId: "t3-chat", + materialYouStyleLayoutEnabled: false, + }); + }); + it("drops the removed theme transition preference", async () => { mocks.setPreferencesJson(JSON.stringify({ themeTransition: "circle-bottom-left" }), 10); diff --git a/apps/mobile/src/lib/useUniwindTheme.ts b/apps/mobile/src/lib/useUniwindTheme.ts index 06c50c859ae9..b76dfca96b35 100644 --- a/apps/mobile/src/lib/useUniwindTheme.ts +++ b/apps/mobile/src/lib/useUniwindTheme.ts @@ -1,8 +1,5 @@ -import { useMemo } from "react"; - import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import type { MobileThemeVariables } from "./mobileTheme"; -import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; /** * Complete JS palette for native and third-party APIs that cannot consume a @@ -13,9 +10,5 @@ import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; * ScopedTheme instead of subscribing every consumer to CSS-variable updates. */ export function useUniwindTheme(): MobileThemeVariables { - const { themeAppearance, themeId } = useAppearancePreferences(); - return useMemo( - () => getMobileThemeRuntimeVariables(themeId, themeAppearance), - [themeAppearance, themeId], - ); + return useAppearancePreferences().themeVariables; } diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 1a488d34f084..129e1af2a92b 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -18,6 +18,7 @@ import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; import { MOBILE_TYPOGRAPHY } from "../lib/typography"; import { useNativePaste } from "../lib/useNativePaste"; import { useFontFamily } from "../lib/useFontFamily"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { useUniwindTheme } from "../lib/useUniwindTheme"; import { acknowledgeComposerNativeEvent, @@ -211,7 +212,9 @@ export function ComposerEditor({ }, [], ); + const { systemColorsActive } = useAppearancePreferences(); const themeJson = JSON.stringify({ + selection: systemColorsActive ? theme["--color-primary"] : null, text: theme["--color-foreground"], placeholder: theme["--color-placeholder"], chipBackground: theme["--color-subtle"], diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index ce2c3e67d533..8209f6103bcc 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -21,6 +21,7 @@ export interface Preferences { readonly lightThemeId?: MobileThemeId; readonly darkThemeId?: MobileThemeId; readonly themeMode?: MobileThemeMode; + readonly materialYouStyleLayoutEnabled?: boolean; readonly baseFontSize?: number; readonly terminalFontSize?: number | null; readonly markdownFontSize?: number; @@ -90,6 +91,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { lightThemeId?: MobileThemeId; darkThemeId?: MobileThemeId; themeMode?: MobileThemeMode; + materialYouStyleLayoutEnabled?: boolean; baseFontSize?: number; terminalFontSize?: number | null; markdownFontSize?: number; @@ -133,6 +135,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { ) { preferences.themeMode = parsed.themeMode; } + if (typeof parsed.materialYouStyleLayoutEnabled === "boolean") { + preferences.materialYouStyleLayoutEnabled = parsed.materialYouStyleLayoutEnabled; + } if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize; if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) { preferences.terminalFontSize = parsed.terminalFontSize; diff --git a/apps/server/src/terminal/NodePtyAdapter.test.ts b/apps/server/src/terminal/NodePtyAdapter.test.ts index 7cf6a167ecfa..066cf01f261a 100644 --- a/apps/server/src/terminal/NodePtyAdapter.test.ts +++ b/apps/server/src/terminal/NodePtyAdapter.test.ts @@ -21,15 +21,50 @@ const spawn = vi.fn(() => ({ vi.mock("node-pty", () => ({ spawn })); -const testLayer = NodePtyAdapter.layer.pipe( - Layer.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.succeed(HostProcessPlatform, "win32"), - Layer.succeed(HostProcessArchitecture, "x64"), +const makeTestLayer = (platform: NodeJS.Platform = "win32") => + NodePtyAdapter.layer.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.succeed(HostProcessPlatform, platform), + Layer.succeed(HostProcessArchitecture, "x64"), + ), ), - ), -); + ); + +const testLayer = makeTestLayer(); + +for (const platform of ["win32", "linux", "darwin"] as const) { + it.effect(`terminates through node-pty using ${platform} semantics`, () => + Effect.gen(function* () { + const adapter = yield* PtyAdapter.PtyAdapter; + const process = yield* adapter.spawn({ + shell: "test-shell", + cwd: ".", + cols: 80, + rows: 24, + env: {}, + }); + const nativeProcess = spawn.mock.results.at(-1)!.value; + nativeProcess.kill.mockImplementation((signal?: string) => { + if (platform === "win32" && signal) { + throw new Error("Signals not supported on windows."); + } + }); + + process.kill("SIGTERM"); + process.kill("SIGKILL"); + process.kill(); + + assert.deepEqual( + nativeProcess.kill.mock.calls, + platform === "win32" + ? [[undefined], [undefined], [undefined]] + : [["SIGTERM"], ["SIGKILL"], [undefined]], + ); + }).pipe(Effect.provide(makeTestLayer(platform))), + ); +} it.effect("spawns through the public adapter with the provided host references", () => Effect.gen(function* () { diff --git a/apps/server/src/terminal/NodePtyAdapter.ts b/apps/server/src/terminal/NodePtyAdapter.ts index 41f177e47586..8f238ac60c34 100644 --- a/apps/server/src/terminal/NodePtyAdapter.ts +++ b/apps/server/src/terminal/NodePtyAdapter.ts @@ -69,9 +69,11 @@ const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () { class NodePtyProcess implements PtyAdapter.PtyProcess { private readonly process: import("node-pty").IPty; + private readonly platform: NodeJS.Platform; - constructor(process: import("node-pty").IPty) { + constructor(process: import("node-pty").IPty, platform: NodeJS.Platform) { this.process = process; + this.platform = platform; } get pid(): number { @@ -87,7 +89,8 @@ class NodePtyProcess implements PtyAdapter.PtyProcess { } kill(signal?: string): void { - this.process.kill(signal); + // node-pty terminates the Windows process tree without a POSIX signal. + this.process.kill(this.platform === "win32" ? undefined : signal); } onData(callback: (data: string) => void): () => void { @@ -164,7 +167,7 @@ export const make = Effect.fn("NodePtyAdapter.make")(function* ( cause, }), }); - return new NodePtyProcess(ptyProcess); + return new NodePtyProcess(ptyProcess, platform); }), }); }); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index d9ddf9225bdf..91c7cc855596 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -57,7 +57,7 @@ import { } from "~/terminal/ghostty/surface"; import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core"; import { useOpenInPreferredEditor } from "../editorPreferences"; -import { isTerminalLinkActivation, isTerminalUrl, resolvePathLinkTarget } from "../terminal-links"; +import { isTerminalUrl, resolvePathLinkTarget } from "../terminal-links"; import { isDiffToggleShortcut, isTerminalClearShortcut, @@ -782,7 +782,6 @@ export function TerminalViewport({ } function handleLinkActivate(text: string, event: MouseEvent): void { - if (!isTerminalLinkActivation(event)) return; const latestTerminal = terminalRef.current; if (!latestTerminal) return; if (isTerminalUrl(text)) { @@ -803,6 +802,7 @@ export function TerminalViewport({ threadRef, openPreview, fallbackToBrowser, + forceBrowser: event.metaKey || event.ctrlKey, }).catch((error: unknown) => { toastManager.add( stackedThreadToast({ diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 98f9caa2300a..b8de82ff2794 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -15,6 +15,7 @@ import { } from "~/sidebarProjectGrouping"; import { useProjects, useThreadShells } from "~/state/entities"; import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; +import { ProjectFavicon } from "../ProjectFavicon"; import { sortLogicalProjectsForSidebar } from "../Sidebar.logic"; import { Menu, @@ -114,11 +115,14 @@ export function DraftHeroHeadline({ render={ } > - {activeProjectDisplayName ?? "Choose a project"} + {activeProjectGroup ? ( + + ) : null} + {activeProjectDisplayName ?? "Choose a project"} {activeProjectDisplayName ? ( @@ -164,7 +168,13 @@ export function DraftHeroHeadline({ > {projectPickerEntries.map(({ group }) => { return ( - + + }> {group.displayName} diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts index 2ce81cb06af2..96629b5c9b02 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts @@ -88,6 +88,7 @@ describe("openTerminalLinkInPreview", () => { threadRef, openPreview, fallbackToBrowser, + forceBrowser: false, }), ).rejects.toBe(failure); expect(fallbackToBrowser).not.toHaveBeenCalled(); @@ -105,6 +106,7 @@ describe("openTerminalLinkInPreview", () => { threadRef, openPreview, fallbackToBrowser, + forceBrowser: false, }); expect(fallbackToBrowser).toHaveBeenCalledOnce(); @@ -120,6 +122,7 @@ describe("openTerminalLinkInPreview", () => { threadRef, openPreview, fallbackToBrowser, + forceBrowser: false, }); expect(openPreview).toHaveBeenCalledOnce(); @@ -141,6 +144,7 @@ describe("openTerminalLinkInPreview", () => { threadRef, openPreview, fallbackToBrowser: vi.fn(), + forceBrowser: false, }); await vi.waitFor(() => expect(browserDefaultsMocks.resolve).toHaveBeenCalledOnce()); @@ -170,6 +174,7 @@ describe("openTerminalLinkInPreview", () => { threadRef, openPreview: async () => AsyncResult.failure(cause), fallbackToBrowser, + forceBrowser: false, }); expect(fallbackToBrowser).toHaveBeenCalledOnce(); @@ -194,9 +199,26 @@ describe("openTerminalLinkInPreview", () => { threadRef, openPreview: async () => AsyncResult.failure(Cause.interrupt()), fallbackToBrowser, + forceBrowser: false, }); expect(reportError).not.toHaveBeenCalled(); expect(fallbackToBrowser).not.toHaveBeenCalled(); }); + + it("opens in the system browser when Ctrl or Command is held", async () => { + const fallbackToBrowser = vi.fn(); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + + await openTerminalLinkInPreview({ + url: "https://example.com/docs", + threadRef, + openPreview, + fallbackToBrowser, + forceBrowser: true, + }); + + expect(fallbackToBrowser).toHaveBeenCalledOnce(); + expect(openPreview).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts index 88fc693ddaed..23ef35a87829 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -34,19 +34,19 @@ interface OpenTerminalLinkInPreviewInput { readonly threadRef: ScopedThreadRef; readonly openPreview: OpenPreviewMutation; readonly fallbackToBrowser: () => void; + /** Cmd/Ctrl-click bypasses the preference and opens in the system browser. */ + readonly forceBrowser: boolean; } /** - * Opens a terminal hyperlink where the "Open links in" setting says. Terminal - * links are activated with the platform modifier already held, so unlike chat - * links the modifier cannot double as the system-browser override; the setting - * alone decides, and the system browser is the fallback whenever the in-app - * one cannot take the URL. + * Opens a terminal hyperlink where the "Open links in" setting says, unless a + * Cmd/Ctrl-click explicitly requests the system browser. */ export async function openTerminalLinkInPreview( input: OpenTerminalLinkInPreviewInput, ): Promise { const supportsPreview = + !input.forceBrowser && isWebUrl(input.url) && isPreviewSupportedInRuntime() && input.threadRef.threadId.length > 0 && diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 1cae305324fa..a7a3c7553540 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -516,7 +516,7 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean }) return ( { resize() { for (const callback of resizeCallbacks) callback(); }, - pointer(type: string, clientX: number, buttons: number) { + pointer(type: string, clientX: number, buttons: number, shiftKey = false) { canvas.dispatchEvent( Object.assign(new Event(type, { cancelable: true }), { clientX, @@ -176,6 +175,7 @@ describe("GhosttyTerminalSurface visibility", () => { pointerId: 1, button: 0, buttons, + shiftKey, }), ); }, @@ -280,6 +280,84 @@ describe("GhosttyTerminalSurface visibility", () => { expect(harness.renderedSnapshot.rowData[0]?.cells.some((cell) => cell.selected)).toBe(false); }); + it("starts a selection when dragging from a link", async () => { + const harness = createHarness(); + const onLinkActivate = vi.fn(); + const surface = await harness.create({ onLinkActivate }); + surface.write("https://example.com"); + harness.flushFrame(); + + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointermove", 37, 1); + harness.pointer("pointerup", 37, 0); + + expect(onLinkActivate).not.toHaveBeenCalled(); + expect(surface.getSelection()).toBe("https"); + }); + + it("keeps a link click active through slight pointer movement", async () => { + const harness = createHarness(); + const onLinkActivate = vi.fn(); + const surface = await harness.create({ onLinkActivate }); + surface.write("https://example.com"); + harness.flushFrame(); + + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointermove", 6, 1); + harness.pointer("pointerup", 6, 0); + + expect(onLinkActivate).toHaveBeenCalledOnce(); + }); + + it("uses repeated link clicks for word and line selection", async () => { + const harness = createHarness(); + const onLinkActivate = vi.fn(); + const surface = await harness.create({ onLinkActivate }); + surface.write("https://example.com tail"); + harness.flushFrame(); + + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointerup", 5, 0); + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointerup", 5, 0); + expect(onLinkActivate).toHaveBeenCalledOnce(); + expect(surface.getSelection()).not.toBe(""); + + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointerup", 5, 0); + expect(onLinkActivate).toHaveBeenCalledOnce(); + expect(surface.getSelection()).toBe("https://example.com tail"); + }); + + it("uses Shift drags over links for selection", async () => { + const harness = createHarness(); + const onLinkActivate = vi.fn(); + const surface = await harness.create({ onLinkActivate }); + surface.write("https://example.com"); + harness.flushFrame(); + + harness.pointer("pointerdown", 5, 1, true); + harness.pointer("pointermove", 37, 1, true); + harness.pointer("pointerup", 37, 0, true); + expect(onLinkActivate).not.toHaveBeenCalled(); + expect(surface.getSelection()).toBe("https"); + }); + + it("does not activate a link replaced before pointer release", async () => { + const harness = createHarness(); + const onLinkActivate = vi.fn(); + const surface = await harness.create({ onLinkActivate }); + surface.write("https://first.example"); + harness.flushFrame(); + + harness.pointer("pointerdown", 5, 1); + surface.write("\x1b[2J\x1b[Hhttps://second.example"); + harness.flushFrame(); + harness.pointer("pointerup", 5, 0); + + expect(onLinkActivate).not.toHaveBeenCalled(); + }); + it("stops zero-size mounts and repaints when the same size returns", async () => { const harness = createHarness(); const surface = await harness.create(); @@ -890,19 +968,6 @@ describe("terminalWheelArrowData", () => { }); }); -describe("isTerminalLinkPointerGesture", () => { - it("uses Command on macOS and Control elsewhere", () => { - expect(isTerminalLinkPointerGesture({ ctrlKey: false, metaKey: true }, "MacIntel")).toBe(true); - expect(isTerminalLinkPointerGesture({ ctrlKey: true, metaKey: false }, "MacIntel")).toBe(false); - expect(isTerminalLinkPointerGesture({ ctrlKey: true, metaKey: false }, "Linux x86_64")).toBe( - true, - ); - expect(isTerminalLinkPointerGesture({ ctrlKey: false, metaKey: true }, "Linux x86_64")).toBe( - false, - ); - }); -}); - describe("advanceTerminalSelectionClickSequence", () => { it("recognizes stationary double and triple pointer presses without PointerEvent.detail", () => { const first = advanceTerminalSelectionClickSequence(null, { diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index ca1cd5a18eb2..6a069902918a 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -249,6 +249,19 @@ export interface TerminalLinkWithRange { readonly range: GhosttyCellRange; } +function isSameTerminalLink( + left: TerminalLinkWithRange, + right: TerminalLinkWithRange | null, +): boolean { + return ( + right?.text === left.text && + right.range.start.x === left.range.start.x && + right.range.start.y === left.range.start.y && + right.range.end.x === left.range.end.x && + right.range.end.y === left.range.end.y + ); +} + function terminalColumnAtOffset(row: GhosttySnapshot["rowData"][number], offset: number): number { for (let column = 0; column < row.cells.length; column += 1) { const nextOffset = terminalColumnOffset(row, column + 1); @@ -467,15 +480,6 @@ export function terminalWheelArrowData(rows: number, applicationCursorKeys: bool return sequence.repeat(Math.abs(rows)); } -export function isTerminalLinkPointerGesture( - event: Pick, - platform = navigator.platform, -): boolean { - return isMacPlatform(platform) - ? event.metaKey && !event.ctrlKey - : event.ctrlKey && !event.metaKey; -} - export function ghosttyMouseButton(button: number): number | null { switch (button) { case 0: @@ -500,6 +504,8 @@ export interface TerminalSelectionClickSequence { readonly y: number; } +const TERMINAL_LINK_DRAG_THRESHOLD_PX = 4; + export function advanceTerminalSelectionClickSequence( previous: TerminalSelectionClickSequence | null, event: Pick, @@ -507,7 +513,8 @@ export function advanceTerminalSelectionClickSequence( const repeats = previous !== null && event.timeStamp - previous.time <= SELECTION_MULTI_CLICK_INTERVAL_MS && - Math.hypot(event.clientX - previous.x, event.clientY - previous.y) <= 4; + Math.hypot(event.clientX - previous.x, event.clientY - previous.y) <= + TERMINAL_LINK_DRAG_THRESHOLD_PX; return { count: repeats ? (previous.count >= 3 ? 1 : previous.count + 1) : 1, time: event.timeStamp, @@ -592,9 +599,14 @@ export class GhosttyTerminalSurface { private mouseReportingPointerId: number | null = null; private mouseReportingButton: number | null = null; private linkActivationPointerId: number | null = null; + private linkActivationLink: TerminalLinkWithRange | null = null; + private linkActivationOrigin: { + x: number; + y: number; + clickCount: number; + } | null = null; private hoveredLink: TerminalLinkWithRange | null = null; private hoverPointer: { x: number; y: number } | null = null; - private linkModifierActive = false; private selectionClickSequence: TerminalSelectionClickSequence | null = null; private selectionMoved = false; private composing = false; @@ -1018,7 +1030,6 @@ export class GhosttyTerminalSurface { } private readonly onKeyDown = (event: KeyboardEvent) => { - this.updateLinkModifier(event); // Presses handled outside the terminal must also swallow their release: // beforeKey runs side effects (keybindings, navigation sends), so it cannot // be consulted again on keyup, and Kitty report-event-types sessions would @@ -1121,7 +1132,6 @@ export class GhosttyTerminalSurface { }; private readonly onKeyUp = (event: KeyboardEvent) => { - this.updateLinkModifier(event); if (this.suppressedKeyCodes.delete(event.code)) return; if (isTerminalCompositionKey(event, this.composing)) { return; @@ -1143,7 +1153,6 @@ export class GhosttyTerminalSurface { private readonly onBlur = () => { this.focused = false; - this.linkModifierActive = false; this.refreshHoveredLink(); // Suppressions survive blur deliberately: a shortcut that moves focus (for // example terminal-toggle) must still swallow its own keyup if focus comes @@ -1264,21 +1273,39 @@ export class GhosttyTerminalSurface { return; } if (event.button !== 0) return; - if (isTerminalLinkPointerGesture(event)) { + const clickCount = this.recordSelectionClick(event); + const link = this.linkAt(event.clientX, event.clientY); + if (link && !event.shiftKey && clickCount === 1) { event.preventDefault(); event.stopPropagation(); this.linkActivationPointerId = event.pointerId; + this.linkActivationLink = link; + this.linkActivationOrigin = { + x: event.clientX, + y: event.clientY, + clickCount, + }; this.canvas.setPointerCapture(event.pointerId); return; } this.clearHoveredLink(); - const cell = this.cellAt(event.clientX, event.clientY); - this.selectionMoved = false; + this.beginSelection(event, clickCount); + this.canvas.setPointerCapture(event.pointerId); + }; + + private recordSelectionClick( + event: Pick, + ): number { this.selectionClickSequence = advanceTerminalSelectionClickSequence( this.selectionClickSequence, event, ); - const clickCount = this.selectionClickSequence.count; + return this.selectionClickSequence.count; + } + + private beginSelection(event: { clientX: number; clientY: number }, clickCount: number): void { + const cell = this.cellAt(event.clientX, event.clientY); + this.selectionMoved = false; this.selectionMode = clickCount >= 3 ? "line" : clickCount === 2 ? "word" : "cell"; const range = this.selectionMode === "line" @@ -1306,12 +1333,31 @@ export class GhosttyTerminalSurface { } } this.forceFullRender = true; - this.canvas.setPointerCapture(event.pointerId); this.requestRender(); - }; + } private readonly onPointerMove = (event: PointerEvent) => { - if (this.linkActivationPointerId === event.pointerId) return; + if (this.linkActivationPointerId === event.pointerId) { + const origin = this.linkActivationOrigin; + if ( + origin === null || + Math.hypot(event.clientX - origin.x, event.clientY - origin.y) <= + TERMINAL_LINK_DRAG_THRESHOLD_PX + ) { + return; + } + this.linkActivationPointerId = null; + this.linkActivationLink = null; + this.linkActivationOrigin = null; + this.clearHoveredLink(); + this.beginSelection( + { + clientX: origin.x, + clientY: origin.y, + }, + origin.clickCount, + ); + } // Hover motion is only reportable in any-event tracking (DEC 1003); normal and // button-event tracking never report motion without a captured pressed button. const anyEventTracking = this.synchronizeMouseTrackingState(); @@ -1321,7 +1367,6 @@ export class GhosttyTerminalSurface { ) { event.preventDefault(); this.hoverPointer = { x: event.clientX, y: event.clientY }; - this.linkModifierActive = isTerminalLinkPointerGesture(event); // A drag whose press was already sent to the terminal application cannot // turn into link activation midway through, so link feedback would lie. this.setHoveredLink(null); @@ -1396,14 +1441,6 @@ export class GhosttyTerminalSurface { private updateHoverCursor(event: PointerEvent): void { this.hoverPointer = { x: event.clientX, y: event.clientY }; - this.linkModifierActive = isTerminalLinkPointerGesture(event); - this.refreshHoveredLink(); - } - - private updateLinkModifier(event: Pick): void { - const active = isTerminalLinkPointerGesture(event); - if (active === this.linkModifierActive) return; - this.linkModifierActive = active; this.refreshHoveredLink(); } @@ -1420,7 +1457,7 @@ export class GhosttyTerminalSurface { private refreshHoveredLink(): void { const pointer = this.hoverPointer; - const link = pointer && this.linkModifierActive ? this.linkAt(pointer.x, pointer.y) : null; + const link = pointer ? this.linkAt(pointer.x, pointer.y) : null; this.setHoveredLink(link); } @@ -1444,13 +1481,17 @@ export class GhosttyTerminalSurface { if (this.linkActivationPointerId === event.pointerId) { event.preventDefault(); event.stopPropagation(); + const link = this.linkActivationLink; this.linkActivationPointerId = null; + this.linkActivationLink = null; + this.linkActivationOrigin = null; if (this.canvas.hasPointerCapture(event.pointerId)) { this.canvas.releasePointerCapture(event.pointerId); } if (event.type !== "pointercancel") { - const link = this.linkAt(event.clientX, event.clientY); - if (link) this.options.onLinkActivate(link.text, event); + if (link && isSameTerminalLink(link, this.linkAt(event.clientX, event.clientY))) { + this.options.onLinkActivate(link.text, event); + } } return; } @@ -1467,7 +1508,6 @@ export class GhosttyTerminalSurface { this.clearHoveredLink(); } else { this.hoverPointer = { x: event.clientX, y: event.clientY }; - this.linkModifierActive = isTerminalLinkPointerGesture(event); this.refreshHoveredLink(); } return; diff --git a/docs/user/appearance.md b/docs/user/appearance.md index 0300b8fd9946..f682902586f1 100644 --- a/docs/user/appearance.md +++ b/docs/user/appearance.md @@ -7,6 +7,12 @@ within each theme. Appearance preferences are saved separately on each device or Mobile has its own themes and text, code, and terminal preferences. It does not follow environment themes or defaults. +On Android 12 or newer, choose the **Material You** theme in Appearance to use colors from +your wallpaper. Selecting another theme replaces those colors. Like other themes, Material You +can be selected separately for light and dark appearances. +Android also offers **Material You Layout**, which changes shapes and spacing independently +of the selected theme. + ## Motion The main sidebar, right panel, and terminal drawer open and close immediately by default. Move the diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b449b48cc4ff..96a35631c361 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -247,6 +247,9 @@ importers: '@legendapp/list': specifier: 'catalog:' version: 3.3.5(patch_hash=a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@material/material-color-utilities': + specifier: 0.3.0 + version: 0.3.0 '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -3412,6 +3415,9 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} + '@material/material-color-utilities@0.3.0': + resolution: {integrity: sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g==} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -14208,6 +14214,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@material/material-color-utilities@0.3.0': {} + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.27)