From c8eca3857cad8aaf55f54d5a876dac8ea5e61ce7 Mon Sep 17 00:00:00 2001 From: Eddy Naboulet <93473191+eddy-naboulet@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:52:46 +0200 Subject: [PATCH 01/14] fix(web): restore dark theme palette (#6663) Co-authored-by: maria (cherry picked from commit e58cbb9e75e97448f88d0fe2eccc8a45cd980394) --- apps/web/src/index.css | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index ed3d5732a..8f7359515 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1226,8 +1226,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil compatibility overrides so both navigation implementations receive the same palette. Success, info, provider, and channel identity colors remain independent; error, warning, and update roles are themeable below. */ -html[data-theme-id], -html.dark[data-theme-id] { + +/* The non-empty marker adds enough specificity to outrank the generated root + dark variant without reintroducing raw `.dark` selectors. */ +html[data-theme-id]:not([data-theme-id=""]) { --background: var(--app-theme-canvas); --app-chrome-background: var(--app-theme-chrome); --toolbar-background: var(--app-theme-toolbar); From 72cd53a411771849f57b0f45c14e38985ef0e5cc Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:59:13 +0200 Subject: [PATCH 02/14] refactor(web): simplify advanced theme controls (#7107) (cherry picked from commit 2f486ab80c748b4d8e3d3b17e49b5a327cb93335) --- .../components/settings/ThemeEditorPanel.tsx | 281 +++++++++++++----- apps/web/src/themePalette.ts | 192 ++++++++++++ docs/user/keybindings.md | 4 +- 3 files changed, 409 insertions(+), 68 deletions(-) diff --git a/apps/web/src/components/settings/ThemeEditorPanel.tsx b/apps/web/src/components/settings/ThemeEditorPanel.tsx index 56cb0ff63..15a5e57bd 100644 --- a/apps/web/src/components/settings/ThemeEditorPanel.tsx +++ b/apps/web/src/components/settings/ThemeEditorPanel.tsx @@ -20,8 +20,10 @@ import { parseThemeFile, removeCustomTheme, themeIdFromName, + updateThemeColorFamily, updateCustomTheme, type ThemeAppearance, + type ThemeColors, type ThemeColorRole, type ThemeDefinition, } from "../../themePalette"; @@ -42,62 +44,189 @@ import { type ThemeElementInspection, } from "./themeInspector"; -const THEME_EDITOR_PRIMARY_ROLES: ReadonlyArray = [ - "canvas", - "chrome", - "sidebar", - "surface", - "text", - "textMuted", - "placeholder", - "secondaryLabel", - "iconMuted", - "accent", - "messageSurface", - "messageAction", -]; - const THEME_EDITOR_SIMPLE_ROLES: ReadonlyArray = ["canvas", "accent"]; -const THEME_EDITOR_STATUS_ROLES: ReadonlyArray = [ - "error", - "errorForeground", - "errorSurface", - "warning", - "warningForeground", - "warningSurface", - "update", - "updateForeground", - "updateSurface", -]; - -const THEME_EDITOR_ADVANCED_ROLES = THEME_COLOR_ROLES.filter( - (role) => !THEME_EDITOR_PRIMARY_ROLES.includes(role) && !THEME_EDITOR_STATUS_ROLES.includes(role), -); +type ThemeEditorColorFamily = Readonly<{ + id: string; + label: string; + role: ThemeColorRole; + roles: ReadonlyArray; +}>; const THEME_EDITOR_ROLE_GROUPS: ReadonlyArray<{ id: string; title: string; - roles: ReadonlyArray; + families: ReadonlyArray; }> = [ { - id: "main", - title: "Main colors", - roles: THEME_EDITOR_PRIMARY_ROLES, + id: "foundation", + title: "Foundation", + families: [ + { + id: "background", + label: "Background", + role: "canvas", + roles: ["canvas", "chrome", "toolbar"], + }, + { id: "surface", label: "Surface", role: "surface", roles: ["surface"] }, + { + id: "raised-surface", + label: "Raised surface", + role: "surfaceRaised", + roles: ["surfaceRaised"], + }, + { + id: "overlay", + label: "Overlay", + role: "surfaceOverlay", + roles: ["surfaceOverlay"], + }, + { + id: "text", + label: "Text", + role: "text", + roles: ["text", "toolbarForeground", "toolbarControlForeground"], + }, + { + id: "muted-text", + label: "Muted text", + role: "mutedForeground", + roles: [ + "textMuted", + "mutedForeground", + "placeholder", + "secondaryLabel", + "iconMuted", + "sidebarMutedForeground", + ], + }, + { + id: "border", + label: "Border", + role: "border", + roles: ["border", "toolbarBorder", "sidebarBorder"], + }, + { id: "input", label: "Input", role: "input", roles: ["input"] }, + ], }, { - id: "status", - title: "Status colors", - roles: THEME_EDITOR_STATUS_ROLES, + id: "brand-content", + title: "Brand & content", + families: [ + { + id: "subtle-surface", + label: "Subtle surface", + role: "secondary", + roles: ["secondary", "secondaryForeground", "muted", "toolbarControl"], + }, + { + id: "highlight-surface", + label: "Highlight surface", + role: "accentSurface", + roles: ["accentSurface", "accentSurfaceForeground", "toolbarControlHover"], + }, + { + id: "accent", + label: "Accent", + role: "accent", + roles: [ + "accent", + "accentForeground", + "focus", + "update", + "updateForeground", + "updateSurface", + "terminalCursor", + ], + }, + { + id: "action", + label: "Action", + role: "messageAction", + roles: ["messageAction", "messageActionForeground", "messageActionHover"], + }, + { + id: "message-surface", + label: "Message surface", + role: "messageSurface", + roles: ["messageSurface", "messageForeground"], + }, + { + id: "code-surface", + label: "Code surface", + role: "codeBackground", + roles: ["codeBackground", "codeForeground"], + }, + ], }, { - id: "additional", - title: "Other colors", - roles: THEME_EDITOR_ADVANCED_ROLES, + id: "context", + title: "Context", + families: [ + { + id: "sidebar-background", + label: "Sidebar background", + role: "sidebar", + roles: ["sidebar", "sidebarForeground"], + }, + { + id: "sidebar-controls", + label: "Sidebar controls", + role: "sidebarControlSurface", + roles: ["sidebarControlSurface"], + }, + { + id: "sidebar-selection", + label: "Sidebar selection", + role: "sidebarRowSelected", + roles: ["sidebarRowHover", "sidebarRowActive", "sidebarRowSelected"], + }, + { + id: "terminal-background", + label: "Terminal background", + role: "terminalBackground", + roles: [ + "terminalBackground", + "terminalForeground", + "terminalSelection", + "terminalScrollbar", + "terminalScrollbarHover", + ], + }, + ], + }, + { + id: "status", + title: "Status", + families: [ + { + id: "error", + label: "Error", + role: "error", + roles: ["error", "errorForeground", "errorSurface"], + }, + { + id: "warning", + label: "Warning", + role: "warning", + roles: ["warning", "warningForeground", "warningSurface"], + }, + ], }, ]; -type ThemeEditorColors = Record; +const THEME_EDITOR_COLOR_FAMILIES = THEME_EDITOR_ROLE_GROUPS.flatMap((group) => group.families); +const THEME_EDITOR_COLOR_FAMILY_BY_ROLE = new Map( + THEME_EDITOR_COLOR_FAMILIES.flatMap((family) => + family.roles.map((role) => [role, family] as const), + ), +); + +function getThemeEditorColorFamily(role: ThemeColorRole): ThemeEditorColorFamily | null { + return THEME_EDITOR_COLOR_FAMILY_BY_ROLE.get(role) ?? null; +} + +type ThemeEditorColors = ThemeColors; type ThemeEditorColorsByAppearance = Record; // A draft with no source theme starts as the standard Pylon look — the @@ -348,9 +477,11 @@ export function ThemeEditorPanel({ return { ...current, - [activeAppearance]: shouldManageColors - ? getManagedEditorColors(activeAppearance, nextColors) - : nextColors, + [activeAppearance]: isAdvanced + ? updateThemeColorFamily(activeAppearance, current[activeAppearance], role, value) + : shouldManageColors + ? getManagedEditorColors(activeAppearance, nextColors) + : nextColors, }; }); if (!isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(role) && isThemeEditorColor(value)) { @@ -364,8 +495,9 @@ export function ThemeEditorPanel({ ); const selectThemeRole = useCallback((role: ThemeColorRole, reveal = false) => { - setSelectedRole(role); - if (!THEME_EDITOR_SIMPLE_ROLES.includes(role)) { + const visibleRole = getThemeEditorColorFamily(role)?.role ?? role; + setSelectedRole(visibleRole); + if (!THEME_EDITOR_SIMPLE_ROLES.includes(visibleRole)) { setIsAdvanced(true); setRoleQuery(""); } @@ -373,7 +505,7 @@ export function ThemeEditorPanel({ requestAnimationFrame(() => { panelRef.current - ?.querySelector(`[data-theme-color-role="${role}"]`) + ?.querySelector(`[data-theme-color-role="${visibleRole}"]`) ?.scrollIntoView({ behavior: "smooth", block: "nearest" }); }); }, []); @@ -389,13 +521,15 @@ export function ThemeEditorPanel({ }, []); const selectedHighlightRoles = selectedRole - ? !isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(selectedRole) - ? THEME_COLOR_ROLES.filter( - (role) => - colorsByAppearance[activeAppearance][role].trim().toLowerCase() === - colorsByAppearance[activeAppearance][selectedRole].trim().toLowerCase(), - ) - : [selectedRole] + ? isAdvanced + ? (getThemeEditorColorFamily(selectedRole)?.roles ?? [selectedRole]) + : THEME_EDITOR_SIMPLE_ROLES.includes(selectedRole) + ? THEME_COLOR_ROLES.filter( + (role) => + colorsByAppearance[activeAppearance][role].trim().toLowerCase() === + colorsByAppearance[activeAppearance][selectedRole].trim().toLowerCase(), + ) + : [selectedRole] : []; const selectedHighlightRolesKey = selectedHighlightRoles.join(","); @@ -490,7 +624,10 @@ export function ThemeEditorPanel({ }; const showInspection = (inspection: ThemeElementInspection) => { hoverInspection = inspection; - showThemeInspectorHover(inspection, getThemeRoleLabel(inspection.role)); + showThemeInspectorHover( + inspection, + getThemeEditorColorFamily(inspection.role)?.label ?? getThemeRoleLabel(inspection.role), + ); }; const handlePointerOver = (event: PointerEvent) => { const target = event.target; @@ -553,7 +690,11 @@ export function ThemeEditorPanel({ hoverFrame ??= requestAnimationFrame(() => { hoverFrame = null; if (hoverInspection) { - showThemeInspectorHover(hoverInspection, getThemeRoleLabel(hoverInspection.role)); + showThemeInspectorHover( + hoverInspection, + getThemeEditorColorFamily(hoverInspection.role)?.label ?? + getThemeRoleLabel(hoverInspection.role), + ); } }); }; @@ -854,19 +995,20 @@ export function ThemeEditorPanel({ ); const renderRoleFields = ( - roles: ReadonlyArray, + families: ReadonlyArray, gridClassName = "grid gap-2 sm:grid-cols-2", ) => (
- {roles.map((role) => ( + {families.map((family) => ( ))}
@@ -876,16 +1018,21 @@ export function ThemeEditorPanel({ const query = roleQuery.trim().toLowerCase(); const groups = THEME_EDITOR_ROLE_GROUPS.map((group) => ({ ...group, - roles: group.roles.filter( - (role) => !query || getThemeRoleLabel(role).toLowerCase().includes(query), + families: group.families.filter( + (family) => + !query || + [family.label, ...family.roles.map((role) => getThemeRoleLabel(role))] + .join(" ") + .toLowerCase() + .includes(query), ), - })).filter((group) => group.roles.length > 0); + })).filter((group) => group.families.length > 0); return isAdvanced ? (
{groups.map((group) => (

{group.title}

- {renderRoleFields(group.roles, "grid gap-1")} + {renderRoleFields(group.families, "grid gap-1")}
))} {groups.length === 0 ?

No matches.

: null} @@ -1018,7 +1165,7 @@ export function ThemeEditorPanel({ {isInspecting ? "Select an element · Esc to cancel" : selectedRole - ? `${getThemeRoleLabel(selectedRole)} · ${usageCount ?? 0} ${usageCount === 1 ? "use" : "uses"}` + ? `${isAdvanced ? (getThemeEditorColorFamily(selectedRole)?.label ?? getThemeRoleLabel(selectedRole)) : getThemeRoleLabel(selectedRole)} · ${usageCount ?? 0} ${usageCount === 1 ? "use" : "uses"}` : "Select a color below"}

)} diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index bca54cef9..24145dd15 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -1468,6 +1468,198 @@ function themeActionColors( }; } +/** + * Update one Advanced-editor color family without normalizing the rest of an + * imported or hand-tuned palette. The editor exposes a representative role + * for each family; paired foregrounds and nearby states are derived only when + * that representative is changed. + */ +export function updateThemeColorFamily( + appearance: ThemeAppearance, + colors: ThemeColors, + role: ThemeColorRole, + value: string, +): ThemeColors { + const parsedSelected = parseThemeColor(value); + if (!parsedSelected) return { ...colors, [role]: value }; + const normalized = formatOklchThemeColor(parsedSelected.color, parsedSelected.alpha); + + const canvas = parseThemeRgbColor( + colors.canvas, + appearance === "dark" ? { r: 24, g: 15, b: 27 } : { r: 250, g: 245, b: 250 }, + ); + const selected = themeOklchToRgb(parsedSelected.color); + const selectedOn = (background: ThemeRgbColor) => + mixThemeRgbColors(background, selected, parsedSelected.alpha); + const selectedOnCanvas = selectedOn(canvas); + const accent = parseThemeRgbColor(colors.accent, { r: 168, g: 67, b: 112 }); + const canvasIsDark = themeRelativeLuminance(canvas) < 0.179; + const terminalIsDark = themeRelativeLuminance(selectedOnCanvas) < 0.179; + const colorOf = (color: ThemeRgbColor) => themeRgbToThemeColor(color); + const foregroundOn = (background: ThemeRgbColor) => colorOf(readableThemeForeground(background)); + const selectedToneOn = (background: ThemeRgbColor) => + themeOklchToThemeColor( + solveOklchLightness( + parsedSelected.color, + background, + 4.6, + themeRelativeLuminance(background) < 0.179 ? "lighter" : "darker", + ), + ); + const statusColors = () => { + const surface = mixThemeRgbColors(canvas, selectedOnCanvas, canvasIsDark ? 0.16 : 0.08); + return { + foreground: selectedToneOn(surface), + surface: colorOf(surface), + }; + }; + + switch (role) { + case "canvas": + return { ...colors, canvas: normalized, chrome: normalized, toolbar: normalized }; + case "surface": + case "surfaceRaised": + case "surfaceOverlay": + case "input": + case "sidebarControlSurface": + return { ...colors, [role]: normalized }; + case "text": + return { + ...colors, + text: normalized, + toolbarForeground: normalized, + toolbarControlForeground: normalized, + }; + case "mutedForeground": + return { + ...colors, + textMuted: normalized, + mutedForeground: normalized, + placeholder: normalized, + secondaryLabel: normalized, + iconMuted: normalized, + sidebarMutedForeground: normalized, + }; + case "border": + return { + ...colors, + border: normalized, + toolbarBorder: normalized, + sidebarBorder: normalized, + }; + case "secondary": + return { + ...colors, + secondary: normalized, + secondaryForeground: foregroundOn(selectedOnCanvas), + muted: normalized, + toolbarControl: normalized, + }; + case "accentSurface": + return { + ...colors, + accentSurface: normalized, + accentSurfaceForeground: foregroundOn(selectedOnCanvas), + toolbarControlHover: normalized, + }; + case "accent": { + const updateSurface = mixThemeRgbColors(canvas, selectedOnCanvas, canvasIsDark ? 0.32 : 0.16); + return { + ...colors, + accent: normalized, + accentForeground: foregroundOn(selectedOnCanvas), + focus: normalized, + update: normalized, + updateForeground: selectedToneOn(updateSurface), + updateSurface: colorOf(updateSurface), + terminalCursor: normalized, + }; + } + case "messageAction": { + const actionForeground = readableThemeForeground(selectedOnCanvas); + const towardOpposite = + actionForeground === THEME_LIGHT_FOREGROUND || actionForeground === THEME_WHITE_FOREGROUND + ? THEME_BLACK_FOREGROUND + : THEME_WHITE_FOREGROUND; + const actionHover = mixThemeRgbColors(selected, towardOpposite, 0.12); + return { + ...colors, + messageAction: normalized, + messageActionForeground: colorOf(actionForeground), + messageActionHover: formatOklchThemeColor( + themeRgbToOklch(actionHover), + parsedSelected.alpha, + ), + }; + } + case "messageSurface": + return { + ...colors, + messageSurface: normalized, + messageForeground: foregroundOn(selectedOnCanvas), + }; + case "codeBackground": + return { + ...colors, + codeBackground: normalized, + codeForeground: foregroundOn(selectedOnCanvas), + }; + case "sidebar": + return { + ...colors, + sidebar: normalized, + sidebarForeground: foregroundOn(selectedOnCanvas), + }; + case "sidebarRowSelected": { + const sidebar = parseThemeRgbColor(colors.sidebar, canvas); + const selectedOnSidebar = selectedOn(sidebar); + return { + ...colors, + sidebarRowHover: colorOf(mixThemeRgbColors(sidebar, selectedOnSidebar, 0.5)), + sidebarRowActive: colorOf(mixThemeRgbColors(sidebar, selectedOnSidebar, 0.8)), + sidebarRowSelected: normalized, + }; + } + case "terminalBackground": { + const terminalForeground = readableThemeForeground(selectedOnCanvas); + return { + ...colors, + terminalBackground: normalized, + terminalForeground: colorOf(terminalForeground), + terminalSelection: colorOf( + mixThemeRgbColors(selectedOnCanvas, accent, terminalIsDark ? 0.35 : 0.18), + ), + terminalScrollbar: colorOf( + mixThemeRgbColors(selectedOnCanvas, terminalForeground, terminalIsDark ? 0.42 : 0.22), + ), + terminalScrollbarHover: colorOf( + mixThemeRgbColors(selectedOnCanvas, terminalForeground, terminalIsDark ? 0.55 : 0.32), + ), + }; + } + case "error": { + const status = statusColors(); + return { + ...colors, + error: normalized, + errorForeground: status.foreground, + errorSurface: status.surface, + }; + } + case "warning": { + const status = statusColors(); + return { + ...colors, + warning: normalized, + warningForeground: status.foreground, + warningSurface: status.surface, + }; + } + default: + return { ...colors, [role]: normalized }; + } +} + export const GROVE_THEME: ThemeDefinition = { id: GROVE_THEME_ID, label: GROVE_THEME_LABEL, diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 67859e2d7..82b79bba2 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -43,8 +43,10 @@ Repeating either shortcut closes that search, and switching shortcuts replaces t `themeEditor.toggle` opens or closes the floating theme editor and defaults to `mod+alt+shift+t`. Select a color label to spotlight the elements that use it; select the label again to clear the spotlight. The swatch and hex field keep that color selected while you edit it. +Advanced mode groups related app tokens into a smaller set of color families. Changing a family +updates its paired text and interaction states while leaving every unrelated imported color intact. Use **Inspect** to pick an element in the app and reveal its color token. Inspect disarms after one -successful pick; its hover glow and badge preview the element and token that click will select. +successful pick; its hover glow and badge preview the element and color family that click will select. **Cancel** or `Escape` exits Inspect and clears its selection and spotlight. `rightPanel.toggleMaximized` maximizes or restores the open right panel. It has no default shortcut, From bd922a271be3e688f24355899e36a06bf1ac1ecf Mon Sep 17 00:00:00 2001 From: Pavlo Trinko Date: Sun, 16 Aug 2026 01:35:45 +0200 Subject: [PATCH 03/14] fix(web): keep highlighted command menu items clear of the scroll fade (#7132) Co-authored-by: Claude Fable 5 (cherry picked from commit d484735c64ed98a0737b594818996660f72c1616) --- apps/web/src/components/chat/ComposerCommandMenu.tsx | 2 +- apps/web/src/components/ui/scroll-area.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 4f32211c1..9bf6e1e21 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -144,7 +144,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { className="dropdown-glass relative w-full overflow-hidden rounded-[20px] shadow-[0_16px_40px_-18px_rgb(0_0_0/55%)] **:data-[slot=scroll-area-scrollbar]:data-[orientation=vertical]:my-4 dark:shadow-[0_18px_44px_-18px_rgb(0_0_0/80%)]" > {props.items.length > 0 ? ( - + {groups.map((group, groupIndex) => (
{groupIndex > 0 ? : null} diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx index 78f59f7a1..bfc10825b 100644 --- a/apps/web/src/components/ui/scroll-area.tsx +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -45,7 +45,7 @@ function ScrollArea({ "h-full max-h-[inherit] overflow-auto overscroll-contain rounded-[inherit] outline-none transition-shadows focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-has-overflow-x:overscroll-x-contain", chainVerticalScroll && "overscroll-y-auto", scrollFade && - "mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]", + "scroll-p-[var(--fade-size)] mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]", scrollbarGutter && "scrollbar-gutter-stable", hideScrollbars && "[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", From c3ad0b60dc486d0852a61bf676282c3763289850 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 05:08:33 +0200 Subject: [PATCH 04/14] test: remove redundant and stale tests (#6267) (cherry picked from commit 27732293373fbb081a966b437ae022afe77db16b) --- .../backend/tailscaleEndpointProvider.test.ts | 8 - apps/desktop/src/preview/PickPreload.test.ts | 86 ----- .../features/terminal/terminalMenu.test.ts | 11 - .../orchestration/commandInvariants.test.ts | 21 -- .../provider/Layers/ProviderRegistry.test.ts | 66 ---- apps/web/src/historyBootstrap.test.ts | 139 -------- .../src/lib/terminalUiStateCleanup.test.ts | 65 ---- .../web/src/orchestrationEventEffects.test.ts | 135 -------- apps/web/src/orchestrationRecovery.test.ts | 306 ------------------ infra/relay/scripts/deploy.test.ts | 20 -- scripts/mobile-showcase.test.ts | 18 -- 11 files changed, 875 deletions(-) delete mode 100644 apps/desktop/src/preview/PickPreload.test.ts delete mode 100644 apps/web/src/historyBootstrap.test.ts delete mode 100644 apps/web/src/lib/terminalUiStateCleanup.test.ts delete mode 100644 apps/web/src/orchestrationEventEffects.test.ts delete mode 100644 apps/web/src/orchestrationRecovery.test.ts diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts index 28bf211f0..e8216ea99 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts @@ -5,7 +5,6 @@ import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { - isTailscaleIpv4Address, parseTailscaleMagicDnsName, resolveTailscaleAdvertisedEndpoints, } from "./tailscaleEndpointProvider.ts"; @@ -22,13 +21,6 @@ const unusedTailscaleExternalServicesLayer = Layer.mergeAll( ); describe("tailscale endpoint provider", () => { - it("detects Tailnet IPv4 addresses", () => { - assert.equal(isTailscaleIpv4Address("100.64.0.1"), true); - assert.equal(isTailscaleIpv4Address("100.127.255.254"), true); - assert.equal(isTailscaleIpv4Address("100.128.0.1"), false); - assert.equal(isTailscaleIpv4Address("192.168.1.44"), false); - }); - it.effect("parses MagicDNS names from tailscale status", () => Effect.gen(function* () { const dnsName = yield* parseTailscaleMagicDnsName( diff --git a/apps/desktop/src/preview/PickPreload.test.ts b/apps/desktop/src/preview/PickPreload.test.ts deleted file mode 100644 index 5696fe508..000000000 --- a/apps/desktop/src/preview/PickPreload.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { computeLabelPosition } from "./PickLabelPosition.ts"; - -const VIEWPORT = { viewportWidth: 1280, viewportHeight: 800 }; - -describe("computeLabelPosition", () => { - it("anchors to the element's top-left when there's room above and to the right", () => { - const { x, y } = computeLabelPosition({ - ...VIEWPORT, - targetLeft: 200, - targetTop: 200, - targetBottom: 240, - labelWidth: 120, - labelHeight: 18, - }); - expect(x).toBe(200); - // 200 (top) - 18 (height) - 4 (gap) - expect(y).toBe(200 - 18 - 4); - }); - - it("clamps left edge so the label stays inside the viewport", () => { - const { x } = computeLabelPosition({ - ...VIEWPORT, - targetLeft: -50, - targetTop: 200, - targetBottom: 240, - labelWidth: 120, - labelHeight: 18, - }); - expect(x).toBe(4); - }); - - it("clamps right edge when the label would overflow the viewport (the bug we shipped)", () => { - const { x } = computeLabelPosition({ - ...VIEWPORT, - targetLeft: 1240, - targetTop: 200, - targetBottom: 240, - labelWidth: 200, - labelHeight: 18, - }); - // viewportWidth (1280) - labelWidth (200) - margin (4) = 1076 - expect(x).toBe(1076); - }); - - it("flips the label below the element when there's no room above", () => { - const { y } = computeLabelPosition({ - ...VIEWPORT, - targetLeft: 200, - targetTop: 4, - targetBottom: 44, - labelWidth: 120, - labelHeight: 18, - }); - // labelY = 4 - 18 - 4 = -18 → flip → 44 + 4 = 48 - expect(y).toBe(48); - }); - - it("pins to the bottom margin when the element fills the viewport (no room above OR below)", () => { - const { y } = computeLabelPosition({ - ...VIEWPORT, - targetLeft: 200, - targetTop: 0, - targetBottom: 800, - labelWidth: 120, - labelHeight: 18, - }); - // Above overflows top → flip below = 800 + 4 = 804 → also overflows - // bottom → pin to viewportHeight - labelHeight - margin = 778. - expect(y).toBe(800 - 18 - 4); - }); - - it("never returns a negative coordinate", () => { - const { x, y } = computeLabelPosition({ - ...VIEWPORT, - targetLeft: -1000, - targetTop: -1000, - targetBottom: -900, - labelWidth: 5000, - labelHeight: 5000, - }); - expect(x).toBeGreaterThanOrEqual(0); - expect(y).toBeGreaterThanOrEqual(0); - }); -}); diff --git a/apps/mobile/src/features/terminal/terminalMenu.test.ts b/apps/mobile/src/features/terminal/terminalMenu.test.ts index 1f176263c..966312270 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.test.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.test.ts @@ -8,7 +8,6 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { buildTerminalMenuSessions, nextOpenTerminalId, - nextTerminalId, previousLiveTerminalId, resolveProjectScriptTerminalId, type TerminalMenuSession, @@ -125,16 +124,6 @@ describe("buildTerminalMenuSessions", () => { }); }); -describe("nextTerminalId", () => { - it("uses the primary id when no terminals are listed yet", () => { - expect(nextTerminalId([])).toBe(DEFAULT_TERMINAL_ID); - }); - - it("allocates term-2 when only the primary shell exists", () => { - expect(nextTerminalId([DEFAULT_TERMINAL_ID])).toBe("term-2"); - }); -}); - describe("nextOpenTerminalId", () => { it("matches nextTerminalId when not on a terminal route", () => { expect(nextOpenTerminalId({ listedTerminalIds: [] })).toBe(DEFAULT_TERMINAL_ID); diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 9531cd5c3..52aac1f0c 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -14,7 +14,6 @@ import * as Effect from "effect/Effect"; import { findThreadById, listThreadsByProjectId, - requireNonNegativeInteger, requireThread, requireThreadAbsent, } from "./commandInvariants.ts"; @@ -200,24 +199,4 @@ describe("commandInvariants", () => { ), ).rejects.toThrow("already exists"); }); - - it("requires non-negative integers", async () => { - await Effect.runPromise( - requireNonNegativeInteger({ - commandType: "thread.checkpoint.revert", - field: "turnCount", - value: 0, - }), - ); - - await expect( - Effect.runPromise( - requireNonNegativeInteger({ - commandType: "thread.checkpoint.revert", - field: "turnCount", - value: -1, - }), - ), - ).rejects.toThrow("greater than or equal to 0"); - }); }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 47de625c4..951cfb642 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -41,9 +41,7 @@ import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistr import { haveProvidersChanged, mergeProviderSnapshot, - mergeProviderSnapshots, ProviderRegistryLive, - selectProvidersByKind, } from "./ProviderRegistry.ts"; import * as ServerConfig from "../../config.ts"; import * as ServerSettingsModule from "../../serverSettings.ts"; @@ -1019,70 +1017,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); - it("persists merged provider snapshots for the providers that were refreshed", () => { - const previousProviders = [ - { - instanceId: ProviderInstanceId.make("cursor"), - driver: ProviderDriverKind.make("cursor"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "2026.04.09-f2b0fcd", - models: [ - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("reasoning", "Reasoning", [ - { id: "high", label: "High", isDefault: true }, - ]), - booleanDescriptor("fastMode", "Fast Mode"), - booleanDescriptor("thinking", "Thinking"), - ], - }), - }, - ], - slashCommands: [], - skills: [], - }, - { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - }, - ] as const satisfies ReadonlyArray; - const refreshedCursor = { - ...previousProviders[0], - checkedAt: "2026-04-14T00:01:00.000Z", - models: [], - } satisfies ServerProvider; - - const mergedProviders = mergeProviderSnapshots(previousProviders, [refreshedCursor]); - const persistedProviders = selectProvidersByKind( - mergedProviders, - new Set([ProviderDriverKind.make("cursor")]), - ); - - assert.deepStrictEqual(persistedProviders, [ - { - ...refreshedCursor, - models: [...previousProviders[0].models], - }, - ]); - }); - it.effect("persists the merged snapshot when a live update has empty models", () => Effect.gen(function* () { const cursorDriver = ProviderDriverKind.make("cursor"); diff --git a/apps/web/src/historyBootstrap.test.ts b/apps/web/src/historyBootstrap.test.ts deleted file mode 100644 index b4be13716..000000000 --- a/apps/web/src/historyBootstrap.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { MessageId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { buildBootstrapInput } from "./historyBootstrap"; - -const messageId = (value: string) => MessageId.make(value); - -describe("buildBootstrapInput", () => { - it("includes full transcript when under budget", () => { - const result = buildBootstrapInput( - [ - { - id: messageId("u-1"), - role: "user", - text: "hello", - createdAt: "2026-02-09T00:00:00.000Z", - turnId: null, - updatedAt: "2026-02-09T00:00:00.000Z", - streaming: false, - }, - { - id: messageId("a-1"), - role: "assistant", - text: "world", - createdAt: "2026-02-09T00:00:01.000Z", - turnId: null, - updatedAt: "2026-02-09T00:00:01.000Z", - streaming: false, - }, - ], - "what's next?", - 1_500, - ); - - expect(result.includedCount).toBe(2); - expect(result.omittedCount).toBe(0); - expect(result.truncated).toBe(false); - expect(result.text).toContain("USER:\nhello"); - expect(result.text).toContain("ASSISTANT:\nworld"); - expect(result.text).toContain("Latest user request (answer this now):"); - expect(result.text).toContain("what's next?"); - }); - - it("truncates older transcript messages when over budget", () => { - const result = buildBootstrapInput( - [ - { - id: messageId("u-1"), - role: "user", - text: "first question with details", - createdAt: "2026-02-09T00:00:00.000Z", - turnId: null, - updatedAt: "2026-02-09T00:00:00.000Z", - streaming: false, - }, - { - id: messageId("a-1"), - role: "assistant", - text: "first answer with details", - createdAt: "2026-02-09T00:00:01.000Z", - turnId: null, - updatedAt: "2026-02-09T00:00:01.000Z", - streaming: false, - }, - { - id: messageId("u-2"), - role: "user", - text: "second question with details", - createdAt: "2026-02-09T00:00:02.000Z", - turnId: null, - updatedAt: "2026-02-09T00:00:02.000Z", - streaming: false, - }, - ], - "final request", - 320, - ); - - expect(result.truncated).toBe(true); - expect(result.omittedCount).toBeGreaterThan(0); - expect(result.includedCount).toBeLessThan(3); - expect(result.text).toContain("omitted to stay within input limits"); - expect(result.text.length).toBeLessThanOrEqual(320); - }); - - it("preserves the latest prompt when prompt-only fallback is required", () => { - const latestPrompt = "Please keep this exact latest prompt."; - const result = buildBootstrapInput( - [ - { - id: messageId("u-1"), - role: "user", - text: "old context", - createdAt: "2026-02-09T00:00:00.000Z", - turnId: null, - updatedAt: "2026-02-09T00:00:00.000Z", - streaming: false, - }, - ], - latestPrompt, - latestPrompt.length + 3, - ); - - expect(result.text).toBe(latestPrompt); - expect(result.includedCount).toBe(0); - expect(result.omittedCount).toBe(1); - expect(result.truncated).toBe(true); - }); - - it("captures user image attachment context in transcript blocks", () => { - const result = buildBootstrapInput( - [ - { - id: messageId("u-image"), - role: "user", - text: "", - attachments: [ - { - type: "image", - id: "img-1", - name: "screenshot.png", - mimeType: "image/png", - sizeBytes: 2_048, - }, - ], - createdAt: "2026-02-09T00:00:00.000Z", - turnId: null, - updatedAt: "2026-02-09T00:00:00.000Z", - streaming: false, - }, - ], - "What does this error mean?", - 1_500, - ); - - expect(result.text).toContain("Attached image"); - expect(result.text).toContain("screenshot.png"); - }); -}); diff --git a/apps/web/src/lib/terminalUiStateCleanup.test.ts b/apps/web/src/lib/terminalUiStateCleanup.test.ts deleted file mode 100644 index a7fa1c1d3..000000000 --- a/apps/web/src/lib/terminalUiStateCleanup.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { ThreadId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { collectActiveTerminalUiThreadKeys } from "./terminalUiStateCleanup"; - -const threadId = (id: string): ThreadId => ThreadId.make(id); -const threadKey = (environmentId: string, id: string): string => - scopedThreadKey(scopeThreadRef(environmentId as never, threadId(id))); - -describe("collectActiveTerminalUiThreadKeys", () => { - it("retains non-deleted server threads", () => { - const activeThreadKeys = collectActiveTerminalUiThreadKeys({ - snapshotThreads: [ - { key: threadKey("env-a", "server-1"), deletedAt: null, archivedAt: null }, - { key: threadKey("env-b", "server-2"), deletedAt: null, archivedAt: null }, - ], - draftThreadKeys: [], - }); - - expect(activeThreadKeys).toEqual( - new Set([threadKey("env-a", "server-1"), threadKey("env-b", "server-2")]), - ); - }); - - it("ignores deleted and archived server threads and keeps local draft threads", () => { - const activeThreadKeys = collectActiveTerminalUiThreadKeys({ - snapshotThreads: [ - { key: threadKey("env-a", "server-active"), deletedAt: null, archivedAt: null }, - { - key: threadKey("env-a", "server-deleted"), - deletedAt: "2026-03-05T08:00:00.000Z", - archivedAt: null, - }, - { - key: threadKey("env-a", "server-archived"), - deletedAt: null, - archivedAt: "2026-03-05T09:00:00.000Z", - }, - ], - draftThreadKeys: [threadKey("env-a", "local-draft")], - }); - - expect(activeThreadKeys).toEqual( - new Set([threadKey("env-a", "server-active"), threadKey("env-a", "local-draft")]), - ); - }); - - it("does not keep draft-linked terminal UI state for archived server threads", () => { - const archivedThreadId = threadKey("env-a", "server-archived"); - - const activeThreadKeys = collectActiveTerminalUiThreadKeys({ - snapshotThreads: [ - { - key: archivedThreadId, - deletedAt: null, - archivedAt: "2026-03-05T09:00:00.000Z", - }, - ], - draftThreadKeys: [archivedThreadId, threadKey("env-a", "local-draft")], - }); - - expect(activeThreadKeys).toEqual(new Set([threadKey("env-a", "local-draft")])); - }); -}); diff --git a/apps/web/src/orchestrationEventEffects.test.ts b/apps/web/src/orchestrationEventEffects.test.ts deleted file mode 100644 index 4269304de..000000000 --- a/apps/web/src/orchestrationEventEffects.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { - CheckpointRef, - EventId, - MessageId, - ProjectId, - ProviderInstanceId, - ThreadId, - TurnId, - type OrchestrationEvent, -} from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { deriveOrchestrationBatchEffects } from "./orchestrationEventEffects"; - -function makeEvent( - type: T, - payload: Extract["payload"], - overrides: Partial> = {}, -): Extract { - const sequence = overrides.sequence ?? 1; - return { - sequence, - eventId: EventId.make(`event-${sequence}`), - aggregateKind: "thread", - aggregateId: - "threadId" in payload - ? payload.threadId - : "projectId" in payload - ? payload.projectId - : ProjectId.make("project-1"), - occurredAt: "2026-02-27T00:00:00.000Z", - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type, - payload, - ...overrides, - } as Extract; -} - -describe("deriveOrchestrationBatchEffects", () => { - it("targets draft promotion and terminal cleanup from thread lifecycle events", () => { - const createdThreadId = ThreadId.make("thread-created"); - const deletedThreadId = ThreadId.make("thread-deleted"); - const archivedThreadId = ThreadId.make("thread-archived"); - - const effects = deriveOrchestrationBatchEffects([ - makeEvent("thread.created", { - threadId: createdThreadId, - projectId: ProjectId.make("project-1"), - title: "Created thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - createdAt: "2026-02-27T00:00:00.000Z", - updatedAt: "2026-02-27T00:00:00.000Z", - }), - makeEvent("thread.deleted", { - threadId: deletedThreadId, - deletedAt: "2026-02-27T00:00:01.000Z", - }), - makeEvent("thread.archived", { - threadId: archivedThreadId, - archivedAt: "2026-02-27T00:00:02.000Z", - updatedAt: "2026-02-27T00:00:02.000Z", - }), - ]); - - expect(effects.promoteDraftThreadIds).toEqual([createdThreadId]); - expect(effects.clearDeletedThreadIds).toEqual([deletedThreadId]); - expect(effects.removeTerminalUiStateThreadIds).toEqual([deletedThreadId, archivedThreadId]); - expect(effects.needsProviderInvalidation).toBe(false); - }); - - it("keeps only the final lifecycle outcome for a thread within one batch", () => { - const threadId = ThreadId.make("thread-1"); - - const effects = deriveOrchestrationBatchEffects([ - makeEvent("thread.deleted", { - threadId, - deletedAt: "2026-02-27T00:00:01.000Z", - }), - makeEvent("thread.created", { - threadId, - projectId: ProjectId.make("project-1"), - title: "Recreated thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - createdAt: "2026-02-27T00:00:02.000Z", - updatedAt: "2026-02-27T00:00:02.000Z", - }), - makeEvent("thread.turn-diff-completed", { - threadId, - turnId: TurnId.make("turn-1"), - checkpointTurnCount: 1, - checkpointRef: CheckpointRef.make("checkpoint-1"), - status: "ready", - files: [], - assistantMessageId: MessageId.make("assistant-1"), - completedAt: "2026-02-27T00:00:03.000Z", - }), - ]); - - expect(effects.promoteDraftThreadIds).toEqual([threadId]); - expect(effects.clearDeletedThreadIds).toEqual([]); - expect(effects.removeTerminalUiStateThreadIds).toEqual([]); - expect(effects.needsProviderInvalidation).toBe(true); - }); - - it("does not retain archive cleanup when a thread is unarchived later in the same batch", () => { - const threadId = ThreadId.make("thread-1"); - - const effects = deriveOrchestrationBatchEffects([ - makeEvent("thread.archived", { - threadId, - archivedAt: "2026-02-27T00:00:01.000Z", - updatedAt: "2026-02-27T00:00:01.000Z", - }), - makeEvent("thread.unarchived", { - threadId, - updatedAt: "2026-02-27T00:00:02.000Z", - }), - ]); - - expect(effects.promoteDraftThreadIds).toEqual([]); - expect(effects.clearDeletedThreadIds).toEqual([]); - expect(effects.removeTerminalUiStateThreadIds).toEqual([]); - }); -}); diff --git a/apps/web/src/orchestrationRecovery.test.ts b/apps/web/src/orchestrationRecovery.test.ts deleted file mode 100644 index 21b78b611..000000000 --- a/apps/web/src/orchestrationRecovery.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - createOrchestrationRecoveryCoordinator, - deriveReplayRetryDecision, -} from "./orchestrationRecovery"; - -describe("createOrchestrationRecoveryCoordinator", () => { - it("defers live events until bootstrap completes and then requests replay", () => { - const coordinator = createOrchestrationRecoveryCoordinator(); - - expect(coordinator.beginSnapshotRecovery("bootstrap")).toBe(true); - expect(coordinator.classifyDomainEvent(4)).toBe("defer"); - - expect(coordinator.completeSnapshotRecovery(2)).toBe(true); - expect(coordinator.getState()).toMatchObject({ - latestSequence: 2, - highestObservedSequence: 4, - bootstrapped: true, - pendingReplay: false, - inFlight: null, - }); - }); - - it("classifies sequence gaps as recovery-only replay work", () => { - const coordinator = createOrchestrationRecoveryCoordinator(); - - coordinator.beginSnapshotRecovery("bootstrap"); - coordinator.completeSnapshotRecovery(3); - - expect(coordinator.classifyDomainEvent(5)).toBe("recover"); - expect(coordinator.beginReplayRecovery("sequence-gap")).toBe(true); - expect(coordinator.getState().inFlight).toEqual({ - kind: "replay", - reason: "sequence-gap", - }); - }); - - it("tracks live event batches without entering recovery", () => { - const coordinator = createOrchestrationRecoveryCoordinator(); - - coordinator.beginSnapshotRecovery("bootstrap"); - coordinator.completeSnapshotRecovery(3); - - expect(coordinator.classifyDomainEvent(4)).toBe("apply"); - expect(coordinator.markEventBatchApplied([{ sequence: 4 }])).toEqual([{ sequence: 4 }]); - expect(coordinator.getState()).toMatchObject({ - latestSequence: 4, - highestObservedSequence: 4, - bootstrapped: true, - inFlight: null, - }); - }); - - it("requests another replay when deferred events arrive during replay recovery", () => { - const coordinator = createOrchestrationRecoveryCoordinator(); - - coordinator.beginSnapshotRecovery("bootstrap"); - coordinator.completeSnapshotRecovery(3); - coordinator.classifyDomainEvent(5); - coordinator.beginReplayRecovery("sequence-gap"); - coordinator.classifyDomainEvent(7); - coordinator.markEventBatchApplied([{ sequence: 4 }, { sequence: 5 }, { sequence: 6 }]); - - expect(coordinator.completeReplayRecovery()).toEqual({ - replayMadeProgress: true, - shouldReplay: true, - }); - }); - - it("retries replay when no progress was made but higher live sequences were observed", () => { - const coordinator = createOrchestrationRecoveryCoordinator(); - - coordinator.beginSnapshotRecovery("bootstrap"); - coordinator.completeSnapshotRecovery(3); - coordinator.classifyDomainEvent(5); - coordinator.beginReplayRecovery("sequence-gap"); - - expect(coordinator.completeReplayRecovery()).toEqual({ - replayMadeProgress: false, - shouldReplay: true, - }); - expect(coordinator.getState()).toMatchObject({ - latestSequence: 3, - highestObservedSequence: 5, - pendingReplay: false, - inFlight: null, - }); - }); - - it("does not request another replay when a replay made no progress and nothing newer was observed", () => { - const coordinator = createOrchestrationRecoveryCoordinator(); - - coordinator.beginSnapshotRecovery("bootstrap"); - coordinator.completeSnapshotRecovery(3); - coordinator.beginReplayRecovery("sequence-gap"); - - expect(coordinator.completeReplayRecovery()).toEqual({ - replayMadeProgress: false, - shouldReplay: false, - }); - }); - - it("marks replay failure as unbootstrapped so snapshot fallback is recovery-only", () => { - const coordinator = createOrchestrationRecoveryCoordinator(); - - coordinator.beginSnapshotRecovery("bootstrap"); - coordinator.completeSnapshotRecovery(3); - coordinator.beginReplayRecovery("sequence-gap"); - coordinator.failReplayRecovery(); - - expect(coordinator.getState()).toMatchObject({ - bootstrapped: false, - inFlight: null, - }); - expect(coordinator.beginSnapshotRecovery("replay-failed")).toBe(true); - expect(coordinator.getState().inFlight).toEqual({ - kind: "snapshot", - reason: "replay-failed", - }); - }); - - it("keeps enough state to explain why bootstrap snapshot recovery requests replay", () => { - const coordinator = createOrchestrationRecoveryCoordinator(); - - expect(coordinator.beginSnapshotRecovery("bootstrap")).toBe(true); - expect(coordinator.classifyDomainEvent(4)).toBe("defer"); - expect(coordinator.completeSnapshotRecovery(2)).toBe(true); - - expect(coordinator.getState()).toMatchObject({ - latestSequence: 2, - highestObservedSequence: 4, - bootstrapped: true, - pendingReplay: false, - inFlight: null, - }); - }); - - it("reports skip state when snapshot recovery is requested while replay is in flight", () => { - const coordinator = createOrchestrationRecoveryCoordinator(); - - coordinator.beginSnapshotRecovery("bootstrap"); - coordinator.completeSnapshotRecovery(3); - expect(coordinator.beginReplayRecovery("sequence-gap")).toBe(true); - - expect(coordinator.beginSnapshotRecovery("bootstrap")).toBe(false); - expect(coordinator.getState()).toMatchObject({ - pendingReplay: true, - inFlight: { - kind: "replay", - reason: "sequence-gap", - }, - }); - }); -}); - -describe("deriveReplayRetryDecision", () => { - it("retries immediately when replay made progress", () => { - expect( - deriveReplayRetryDecision({ - previousTracker: { - attempts: 2, - latestSequence: 3, - highestObservedSequence: 5, - }, - completion: { - replayMadeProgress: true, - shouldReplay: true, - }, - recoveryState: { - latestSequence: 5, - highestObservedSequence: 5, - }, - baseDelayMs: 100, - maxNoProgressRetries: 3, - }), - ).toEqual({ - shouldRetry: true, - delayMs: 0, - tracker: null, - }); - }); - - it("caps no-progress retries for the same frontier", () => { - const first = deriveReplayRetryDecision({ - previousTracker: null, - completion: { - replayMadeProgress: false, - shouldReplay: true, - }, - recoveryState: { - latestSequence: 3, - highestObservedSequence: 5, - }, - baseDelayMs: 100, - maxNoProgressRetries: 3, - }); - - const second = deriveReplayRetryDecision({ - previousTracker: first.tracker, - completion: { - replayMadeProgress: false, - shouldReplay: true, - }, - recoveryState: { - latestSequence: 3, - highestObservedSequence: 5, - }, - baseDelayMs: 100, - maxNoProgressRetries: 3, - }); - - const third = deriveReplayRetryDecision({ - previousTracker: second.tracker, - completion: { - replayMadeProgress: false, - shouldReplay: true, - }, - recoveryState: { - latestSequence: 3, - highestObservedSequence: 5, - }, - baseDelayMs: 100, - maxNoProgressRetries: 3, - }); - - const fourth = deriveReplayRetryDecision({ - previousTracker: third.tracker, - completion: { - replayMadeProgress: false, - shouldReplay: true, - }, - recoveryState: { - latestSequence: 3, - highestObservedSequence: 5, - }, - baseDelayMs: 100, - maxNoProgressRetries: 3, - }); - - expect(first).toEqual({ - shouldRetry: true, - delayMs: 100, - tracker: { - attempts: 1, - latestSequence: 3, - highestObservedSequence: 5, - }, - }); - expect(second).toEqual({ - shouldRetry: true, - delayMs: 200, - tracker: { - attempts: 2, - latestSequence: 3, - highestObservedSequence: 5, - }, - }); - expect(third).toEqual({ - shouldRetry: true, - delayMs: 400, - tracker: { - attempts: 3, - latestSequence: 3, - highestObservedSequence: 5, - }, - }); - expect(fourth).toEqual({ - shouldRetry: false, - delayMs: 0, - tracker: null, - }); - }); - - it("resets the retry budget when the replay frontier changes", () => { - const exhausted = { - attempts: 3, - latestSequence: 3, - highestObservedSequence: 5, - }; - - expect( - deriveReplayRetryDecision({ - previousTracker: exhausted, - completion: { - replayMadeProgress: false, - shouldReplay: true, - }, - recoveryState: { - latestSequence: 3, - highestObservedSequence: 6, - }, - baseDelayMs: 100, - maxNoProgressRetries: 3, - }), - ).toEqual({ - shouldRetry: true, - delayMs: 100, - tracker: { - attempts: 1, - latestSequence: 3, - highestObservedSequence: 6, - }, - }); - }); -}); diff --git a/infra/relay/scripts/deploy.test.ts b/infra/relay/scripts/deploy.test.ts index 87098b25d..4447c3493 100644 --- a/infra/relay/scripts/deploy.test.ts +++ b/infra/relay/scripts/deploy.test.ts @@ -9,7 +9,6 @@ import { missingRelayPublicConfigFields, publicConfigFromOutput, reconcileRootEnvPublicConfig, - reconcileRootEnvRelayUrl, RelayDeployError, RelayDeployPublicConfigUnavailableError, serializeGithubOutput, @@ -87,25 +86,6 @@ describe("hasDeployChanges", () => { }); }); -describe("reconcileRootEnvRelayUrl", () => { - it("adds the relay URL to an empty root env file", () => { - expect(reconcileRootEnvRelayUrl("", "https://relay.example.test")).toBe( - "T3CODE_RELAY_URL=https://relay.example.test\n", - ); - }); - - it("preserves unrelated root env entries while replacing a previous relay URL", () => { - expect( - reconcileRootEnvRelayUrl( - "T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_example\nT3CODE_RELAY_URL=https://old.example.test\n", - "https://relay.example.test", - ), - ).toBe( - "T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_example\nT3CODE_RELAY_URL=https://relay.example.test\n", - ); - }); -}); - describe("reconcileRootEnvPublicConfig", () => { const config = { relayUrl: "https://relay.example.test", diff --git a/scripts/mobile-showcase.test.ts b/scripts/mobile-showcase.test.ts index d061ff8f9..0ce66d617 100644 --- a/scripts/mobile-showcase.test.ts +++ b/scripts/mobile-showcase.test.ts @@ -22,7 +22,6 @@ import { resolveAndroidSdkRoot, selectLanIpv4Address, showcaseCaptureDirectory, - showcaseSceneUrl, validateStoreAsset, validateStoreAssetCount, } from "./mobile-showcase.ts"; @@ -244,23 +243,6 @@ it("selects a reachable LAN IPv4 address", () => { ); }); -it("maps capture scenes to the real application routes", () => { - assert.equal(showcaseSceneUrl("threads", "environment-1"), "t3code://"); - assert.equal(showcaseSceneUrl("environments", "environment-1"), "t3code://settings/environments"); - assert.equal( - showcaseSceneUrl("thread", "environment-1"), - "t3code://threads/environment-1/remote-command-center", - ); - assert.equal( - showcaseSceneUrl("terminal", "environment-1"), - "t3code://threads/environment-1/remote-command-center/terminal?terminalId=term-1", - ); - assert.equal( - showcaseSceneUrl("review", "environment-1"), - "t3code://threads/environment-1/remote-command-center/review", - ); -}); - it("seeds a playful multi-environment project spectrum", () => { assert.deepStrictEqual( SHOWCASE_PROJECTS.map((project) => project.title), From af01aaf9a7725e75f101c6a4a0e0468062ee9ac0 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:06:38 +0200 Subject: [PATCH 05/14] test: favor behavior over implementation details (#7157) (cherry picked from commit 3583cd27dccda1b35fe80d9411a95a7217cc8176) --- .../terminal/terminalLaunchContext.test.ts | 10 +---- .../terminal/terminalLaunchContext.ts | 6 --- .../terminal/threadTerminalPanelModel.test.ts | 40 ------------------- .../terminal/threadTerminalPanelModel.ts | 12 ------ .../components/chat/MessagesTimeline.test.tsx | 8 +--- .../src/components/chat/MessagesTimeline.tsx | 2 +- .../components/composerFooterLayout.test.ts | 13 +++--- .../src/components/composerFooterLayout.ts | 4 +- packages/shared/src/terminalLabels.test.ts | 1 - 9 files changed, 9 insertions(+), 87 deletions(-) delete mode 100644 apps/mobile/src/features/terminal/threadTerminalPanelModel.test.ts diff --git a/apps/mobile/src/features/terminal/terminalLaunchContext.test.ts b/apps/mobile/src/features/terminal/terminalLaunchContext.test.ts index cbd446a88..470acffa3 100644 --- a/apps/mobile/src/features/terminal/terminalLaunchContext.test.ts +++ b/apps/mobile/src/features/terminal/terminalLaunchContext.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vite-plus/test"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { - peekPendingTerminalLaunch, resolvePreferredThreadWorktreePath, resolveTerminalOpenLocation, stagePendingTerminalLaunch, @@ -82,19 +81,13 @@ describe("pending terminal launches", () => { }, }); - expect(peekPendingTerminalLaunch(target)).toEqual({ - cwd: "/repo/worktrees/feature", - worktreePath: "/repo/worktrees/feature", - env: { FOO: "bar" }, - initialInput: "pnpm dev\r", - }); expect(takePendingTerminalLaunch(target)).toEqual({ cwd: "/repo/worktrees/feature", worktreePath: "/repo/worktrees/feature", env: { FOO: "bar" }, initialInput: "pnpm dev\r", }); - expect(peekPendingTerminalLaunch(target)).toBeNull(); + expect(takePendingTerminalLaunch(target)).toBeNull(); }); it("keeps pending launches isolated per terminal target", () => { @@ -118,7 +111,6 @@ describe("pending terminal launches", () => { }, }); - expect(peekPendingTerminalLaunch(otherTarget)).toBeNull(); expect(takePendingTerminalLaunch(otherTarget)).toBeNull(); expect(takePendingTerminalLaunch(primaryTarget)).toEqual({ cwd: "/repo/root", diff --git a/apps/mobile/src/features/terminal/terminalLaunchContext.ts b/apps/mobile/src/features/terminal/terminalLaunchContext.ts index c1a774920..af67497a3 100644 --- a/apps/mobile/src/features/terminal/terminalLaunchContext.ts +++ b/apps/mobile/src/features/terminal/terminalLaunchContext.ts @@ -36,12 +36,6 @@ export function stagePendingTerminalLaunch(input: { }); } -export function peekPendingTerminalLaunch( - target: PendingTerminalLaunchTarget, -): PendingTerminalLaunch | null { - return pendingTerminalLaunches.get(pendingTerminalLaunchKey(target)) ?? null; -} - export function takePendingTerminalLaunch( target: PendingTerminalLaunchTarget, ): PendingTerminalLaunch | null { diff --git a/apps/mobile/src/features/terminal/threadTerminalPanelModel.test.ts b/apps/mobile/src/features/terminal/threadTerminalPanelModel.test.ts deleted file mode 100644 index 871a28d85..000000000 --- a/apps/mobile/src/features/terminal/threadTerminalPanelModel.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - buildThreadTerminalAttachInput, - threadTerminalSubscriptionKey, - type ThreadTerminalSubscriptionIdentity, -} from "./threadTerminalPanelModel"; - -const identity: ThreadTerminalSubscriptionIdentity = { - environmentId: EnvironmentId.make("env-1"), - threadId: ThreadId.make("thread-1"), - terminalId: "default", - cwd: "/repo", - worktreePath: "/repo", -}; - -describe("threadTerminalSubscriptionKey", () => { - it("does not include mutable terminal dimensions", () => { - const initialAttach = buildThreadTerminalAttachInput(identity, { cols: 80, rows: 24 }); - const resizedAttach = buildThreadTerminalAttachInput(identity, { cols: 132, rows: 40 }); - - expect(initialAttach).not.toEqual(resizedAttach); - expect(threadTerminalSubscriptionKey({ ...identity, ...initialAttach })).toBe( - threadTerminalSubscriptionKey({ ...identity, ...resizedAttach }), - ); - }); - - it.each([ - ["environment", { environmentId: EnvironmentId.make("env-2") }], - ["thread", { threadId: ThreadId.make("thread-2") }], - ["terminal", { terminalId: "term-2" }], - ["cwd", { cwd: "/repo/packages/app" }], - ["worktree", { worktreePath: "/repo/worktrees/feature" }], - ])("changes when the %s identity changes", (_label, update) => { - expect(threadTerminalSubscriptionKey({ ...identity, ...update })).not.toBe( - threadTerminalSubscriptionKey(identity), - ); - }); -}); diff --git a/apps/mobile/src/features/terminal/threadTerminalPanelModel.ts b/apps/mobile/src/features/terminal/threadTerminalPanelModel.ts index 9f1d032d2..07ef46a7b 100644 --- a/apps/mobile/src/features/terminal/threadTerminalPanelModel.ts +++ b/apps/mobile/src/features/terminal/threadTerminalPanelModel.ts @@ -13,18 +13,6 @@ export interface TerminalGridSize { readonly rows: number; } -export function threadTerminalSubscriptionKey( - identity: ThreadTerminalSubscriptionIdentity, -): string { - return JSON.stringify([ - identity.environmentId, - identity.threadId, - identity.terminalId, - identity.cwd, - identity.worktreePath, - ]); -} - export function buildThreadTerminalAttachInput( identity: ThreadTerminalSubscriptionIdentity, gridSize: TerminalGridSize, diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 1efa9c86b..46a87d21c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -134,7 +134,6 @@ function matchMedia() { } let MessagesTimeline: typeof import("./MessagesTimeline").MessagesTimeline; -let toolCallExpandedBodyClassName: typeof import("./MessagesTimeline").toolCallExpandedBodyClassName; beforeAll(async () => { const classList = { @@ -168,7 +167,7 @@ beforeAll(async () => { }, }); - ({ MessagesTimeline, toolCallExpandedBodyClassName } = await import("./MessagesTimeline")); + ({ MessagesTimeline } = await import("./MessagesTimeline")); }, 30_000); const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); @@ -238,11 +237,6 @@ function buildAssistantTimelineEntry(text: string) { } describe("MessagesTimeline", () => { - it("sizes expanded tool details with the configured code font size", () => { - expect(toolCallExpandedBodyClassName).toContain("var(--font-size-code"); - expect(toolCallExpandedBodyClassName).not.toContain("text-[11px]"); - }); - it("uses the larger leading inset only when the top fade is enabled", () => { const timelineEntries = [buildUserTimelineEntry("Hello")]; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 4132ce890..1b4925aac 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2095,7 +2095,7 @@ function buildToolCallExpandedBody( return blocks.length > 0 ? blocks.join("\n\n") : null; } -export const toolCallExpandedBodyClassName = +const toolCallExpandedBodyClassName = "max-h-64 cursor-text overflow-auto whitespace-pre-wrap break-words font-mono text-secondary-label text-[length:var(--font-size-code,0.6875rem)] leading-relaxed select-text"; function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts index b9f2a6b6a..92e054df5 100644 --- a/apps/web/src/components/composerFooterLayout.test.ts +++ b/apps/web/src/components/composerFooterLayout.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vite-plus/test"; import { COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, - COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX, shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, } from "./composerFooterLayout"; @@ -38,16 +37,14 @@ describe("shouldUseCompactComposerFooter", () => { describe("shouldUseCompactComposerPrimaryActions", () => { it("matches the wide footer breakpoint", () => { - expect(COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX).toBe( - COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, - ); expect( - shouldUseCompactComposerPrimaryActions(COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX - 1, { - hasWideActions: true, - }), + shouldUseCompactComposerPrimaryActions( + COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX - 1, + { hasWideActions: true }, + ), ).toBe(true); expect( - shouldUseCompactComposerPrimaryActions(COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX, { + shouldUseCompactComposerPrimaryActions(COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, { hasWideActions: true, }), ).toBe(false); diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index ae5fd5666..5e0b3a8ea 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -1,7 +1,5 @@ export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620; export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 780; -export const COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX = - COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX; export function shouldUseCompactComposerFooter( width: number | null, @@ -20,5 +18,5 @@ export function shouldUseCompactComposerPrimaryActions( if (!options?.hasWideActions) { return false; } - return width !== null && width < COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX; + return width !== null && width < COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX; } diff --git a/packages/shared/src/terminalLabels.test.ts b/packages/shared/src/terminalLabels.test.ts index 4621f3af8..b8a146b0c 100644 --- a/packages/shared/src/terminalLabels.test.ts +++ b/packages/shared/src/terminalLabels.test.ts @@ -34,7 +34,6 @@ describe("resolveTerminalSessionLabel", () => { describe("nextTerminalId", () => { it("allocates term-1 when no terminals are listed yet", () => { expect(nextTerminalId([])).toBe(DEFAULT_TERMINAL_ID); - expect(nextTerminalId([])).toBe("term-1"); }); it("allocates term-2 when only term-1 exists", () => { From 4838326f11ca1904672549351c80e25907f01540 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Sun, 16 Aug 2026 09:13:15 +0100 Subject: [PATCH 06/14] feat(mobile): add built-in themes (#6619) (cherry picked from commit d23b181da0cee78bcc327b6673218b776a3cb023) --- .../workflows/mobile-showcase-screenshots.yml | 29 +- apps/mobile/global.css | 20 +- .../T3NativeControlsModule.kt | 6 + .../ios/T3NativeControlsModule.swift | 13 + apps/mobile/src/App.tsx | 77 +- apps/mobile/src/Stack.tsx | 27 +- .../src/components/AndroidAnchoredMenu.tsx | 6 +- apps/mobile/src/components/AppSymbol.tsx | 4 + .../mobile/src/components/ComposerToolbar.tsx | 14 +- apps/mobile/src/components/ControlPill.tsx | 6 +- apps/mobile/src/components/GlassSurface.tsx | 5 +- apps/mobile/src/components/LoadingScreen.tsx | 5 +- .../mobile/src/components/PierreEntryIcon.tsx | 4 +- apps/mobile/src/components/ProviderIcon.tsx | 5 +- apps/mobile/src/components/ThemedSwitch.tsx | 21 + .../connection/CloudEnvironmentRows.tsx | 8 +- .../src/features/files/SourceFileSurface.tsx | 23 +- .../features/files/ThreadFilesRouteScreen.tsx | 13 +- .../files/thread-file-navigator-pane.tsx | 6 +- .../layout/workspace-pane-divider.tsx | 23 +- .../review/ReviewCommentComposerSheet.tsx | 16 +- .../src/features/review/ReviewSheet.tsx | 27 +- .../review/nativeReviewDiffAdapter.test.ts | 25 + .../review/nativeReviewDiffAdapter.ts | 66 +- .../review/useNativeReviewDiffBridge.ts | 8 +- .../SettingsAppearanceRouteScreen.tsx | 2 + .../SettingsEnvironmentsRouteScreen.tsx | 9 +- .../AppearancePreferencesProvider.tsx | 120 ++- .../components/AppearancePreviews.tsx | 16 +- .../sections/ThemeAppearanceSection.tsx | 365 +++++++++ .../settings/components/SettingsSwitchRow.tsx | 9 +- .../showcase/ShowcaseCaptureCoordinator.tsx | 79 +- .../features/showcase/nativeShowcaseScene.ts | 17 + .../showcase/showcaseRenderSignal.test.ts | 52 ++ .../features/showcase/showcaseRenderSignal.ts | 39 + .../terminal/NativeTerminalSurface.tsx | 14 +- .../features/terminal/ThreadTerminalPanel.tsx | 14 +- .../terminal/ThreadTerminalRouteScreen.tsx | 10 +- .../features/terminal/terminalTheme.test.ts | 36 +- .../src/features/terminal/terminalTheme.ts | 30 + .../threads/ComposerCommandPopover.tsx | 26 +- .../threads/GitActionProgressOverlay.tsx | 6 +- .../threads/NewTaskContextPickerScreens.tsx | 4 +- .../features/threads/NewTaskDraftScreen.tsx | 11 +- .../src/features/threads/ThreadComposer.tsx | 31 +- .../features/threads/ThreadDetailScreen.tsx | 5 +- .../src/features/threads/ThreadFeed.tsx | 118 ++- .../threads/ThreadNavigationSidebar.tsx | 11 +- .../features/threads/ThreadSettingsSheet.tsx | 20 +- .../threads/sidebar-filter-button.tsx | 8 +- .../threads/sidebar-header-actions.tsx | 8 +- .../threads/sidebar-navigation-shell.tsx | 16 +- .../features/threads/thread-list-items.tsx | 30 +- .../features/threads/thread-list-v2-items.tsx | 22 +- .../src/features/threads/thread-work-log.tsx | 9 +- .../src/features/usage/usageProviders.ts | 4 +- apps/mobile/src/lib/mobileDefaultTheme.ts | 139 ++++ apps/mobile/src/lib/mobileTheme.test.ts | 231 ++++++ apps/mobile/src/lib/mobileTheme.ts | 314 ++++++++ apps/mobile/src/lib/storage.test.ts | 19 + .../src/lib/useMobileNavigationTheme.ts | 22 + apps/mobile/src/native/sheet-surface.ts | 25 +- .../src/persistence/mobile-preferences.ts | 34 + .../settings/ThemePreviewCircles.tsx | 35 +- apps/web/src/themePalette.test.ts | 11 + apps/web/src/themePalette.ts | 353 +-------- docs/README.md | 1 + .../mobile-app-store-screenshots.md | 69 +- docs/user/mobile-appearance.md | 15 + packages/shared/package.json | 8 + packages/shared/src/themePalettes.ts | 748 ++++++++++++++++++ packages/shared/src/themePreview.test.ts | 20 + packages/shared/src/themePreview.ts | 145 ++++ scripts/mobile-showcase.config.ts | 21 + scripts/mobile-showcase.test.ts | 60 +- scripts/mobile-showcase.ts | 60 +- 76 files changed, 3090 insertions(+), 838 deletions(-) create mode 100644 apps/mobile/src/components/ThemedSwitch.tsx create mode 100644 apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx create mode 100644 apps/mobile/src/features/showcase/showcaseRenderSignal.test.ts create mode 100644 apps/mobile/src/features/showcase/showcaseRenderSignal.ts create mode 100644 apps/mobile/src/lib/mobileDefaultTheme.ts create mode 100644 apps/mobile/src/lib/mobileTheme.test.ts create mode 100644 apps/mobile/src/lib/mobileTheme.ts create mode 100644 apps/mobile/src/lib/useMobileNavigationTheme.ts create mode 100644 docs/user/mobile-appearance.md create mode 100644 packages/shared/src/themePalettes.ts create mode 100644 packages/shared/src/themePreview.test.ts create mode 100644 packages/shared/src/themePreview.ts diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index 3eaaf508e..c64bccacd 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -21,6 +21,19 @@ on: - both - dark - light + theme: + description: Palette to capture (all multiplies the run by six) + required: true + default: t3-code + type: choice + options: + - t3-code + - t3-chat + - grove + - ocean + - ember + - iris + - all permissions: contents: read @@ -33,7 +46,9 @@ jobs: name: iPhone 6.9, iPhone 6.5, and iPad 13 if: inputs.platform == 'all' || inputs.platform == 'ios' runs-on: blacksmith-12vcpu-macos-26 - timeout-minutes: 60 + # Capturing every palette multiplies the device matrix by six, and only the + # one native build is shared between them. + timeout-minutes: ${{ inputs.theme == 'all' && 300 || 60 }} steps: - name: Checkout uses: actions/checkout@v6 @@ -62,10 +77,10 @@ jobs: "$vp_pnpm_bin/pnpm" --version - name: Capture iOS showcase - run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" + run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" - name: Validate App Store Connect assets - run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --validate-only + run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" --validate-only - name: Upload iOS screenshots if: always() @@ -80,7 +95,9 @@ jobs: name: Android phone, 7-inch tablet, and 10-inch tablet if: inputs.platform == 'all' || inputs.platform == 'android' runs-on: blacksmith-16vcpu-ubuntu-2404 - timeout-minutes: 60 + # Capturing every palette multiplies the device matrix by six, and only the + # one native build is shared between them. + timeout-minutes: ${{ inputs.theme == 'all' && 300 || 60 }} env: T3_SHOWCASE_ANDROID_ABI: x86_64 steps: @@ -137,10 +154,10 @@ jobs: cores: 8 ram-size: 4096M disable-animations: false - script: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" + script: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" - name: Validate Google Play assets - run: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --validate-only + run: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" --validate-only - name: Upload Android screenshots if: always() diff --git a/apps/mobile/global.css b/apps/mobile/global.css index bfb2448be..a42afc74d 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -12,6 +12,7 @@ /* Page backgrounds */ --color-screen: #f2f2f7; --color-sheet: rgba(242, 242, 247, 0.98); + --color-sheet-solid: #f2f2f7; /* Card / surface */ --color-card: #ffffff; @@ -39,13 +40,16 @@ /* Primary action */ --color-primary: #262626; --color-primary-foreground: #ffffff; - --color-primary-shadow: rgba(0, 0, 0, 0.18); + --color-primary-shadow: #000000; /* Secondary action */ --color-secondary: #ffffff; --color-secondary-foreground: #262626; --color-secondary-border: rgba(0, 0, 0, 0.08); - --color-switch-active: #34c759; + --color-switch-active-track: #34c759; + --color-switch-active-thumb: #ffffff; + --color-switch-inactive-track: rgba(0, 0, 0, 0.08); + --color-switch-inactive-thumb: #8e8e93; /* Danger */ --color-danger: #fef2f2; @@ -56,7 +60,7 @@ --color-input: #ffffff; --color-input-border: rgba(0, 0, 0, 0.1); --color-sidebar-search: rgba(118, 118, 128, 0.12); - --color-placeholder: #a3a3a3; + --color-placeholder: #737373; /* Icons */ --color-icon: #262626; @@ -90,6 +94,7 @@ --color-user-bubble: #007aff; --color-user-bubble-foreground: #ffffff; --color-user-bubble-foreground-muted: rgba(255, 255, 255, 0.78); + --color-user-bubble-skill-foreground: #f0abfc; /* Drawer / modal backdrop */ --color-backdrop: rgba(0, 0, 0, 0.22); @@ -106,6 +111,7 @@ /* Page backgrounds */ --color-screen: #0a0a0a; --color-sheet: rgba(14, 14, 14, 0.98); + --color-sheet-solid: #0e0e0e; /* Card / surface */ --color-card: #171717; @@ -133,13 +139,16 @@ /* Primary action */ --color-primary: #f5f5f5; --color-primary-foreground: #0a0a0a; - --color-primary-shadow: rgba(0, 0, 0, 0.22); + --color-primary-shadow: #000000; /* Secondary action */ --color-secondary: rgba(255, 255, 255, 0.04); --color-secondary-foreground: #f5f5f5; --color-secondary-border: rgba(255, 255, 255, 0.06); - --color-switch-active: #30d158; + --color-switch-active-track: #30d158; + --color-switch-active-thumb: #ffffff; + --color-switch-inactive-track: rgba(255, 255, 255, 0.06); + --color-switch-inactive-thumb: #8e8e93; /* Danger */ --color-danger: rgba(239, 68, 68, 0.14); @@ -184,6 +193,7 @@ --color-user-bubble: #0a84ff; --color-user-bubble-foreground: #ffffff; --color-user-bubble-foreground-muted: rgba(255, 255, 255, 0.78); + --color-user-bubble-skill-foreground: #f0abfc; /* Drawer / modal backdrop */ --color-backdrop: rgba(0, 0, 0, 0.48); diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt index f08ca9afb..6aca0cec2 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt @@ -22,6 +22,12 @@ class T3NativeControlsModule : Module() { storedScene ?: appContext.currentActivity?.intent?.getStringExtra("showcaseScene") } + // The palette is fixed for the whole capture, so it only ever arrives as a + // launch extra — unlike the scene, which the runner rewrites in place. + Function("getShowcaseTheme") { + appContext.currentActivity?.intent?.getStringExtra("showcaseTheme") + } + Function("prepareShowcaseCapture") { // Android app data is cleared by the host runner before launch. } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index f3125c3ce..6aa8fa6bb 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -33,6 +33,19 @@ public final class T3NativeControlsModule: Module { return arguments[flagIndex + 1] } + // The palette is fixed for the whole capture, so it only ever arrives as a + // launch argument — unlike the scene, which the runner rewrites in place. + Function("getShowcaseTheme") { () -> String? in + let arguments = ProcessInfo.processInfo.arguments + guard + let flagIndex = arguments.firstIndex(of: "--showcaseTheme"), + arguments.indices.contains(flagIndex + 1) + else { + return nil as String? + } + return arguments[flagIndex + 1] + } + Function("getShowcaseOrientation") { () -> String? in let arguments = ProcessInfo.processInfo.arguments guard diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index a745f96a6..4a454894c 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -2,11 +2,11 @@ import { BlurTargetView } from "expo-blur"; import * as Linking from "expo-linking"; import * as SplashScreen from "expo-splash-screen"; import { useEffect } from "react"; -import { StatusBar, useColorScheme } from "react-native"; +import { StatusBar } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { SafeAreaProvider } from "react-native-safe-area-context"; -import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigation/native"; +import { createStaticNavigation } from "@react-navigation/native"; import { RegistryContext } from "@effect/atom-react"; import { ConfirmDialogHost } from "./components/ConfirmDialogHost"; @@ -22,6 +22,7 @@ import { appAtomRegistry } from "./state/atom-registry"; import { OverlayPortalHost } from "./components/OverlayPortal"; import { appBlurTargetRef } from "./lib/appBlurTarget"; import { useThemeColor } from "./lib/useThemeColor"; +import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; import "../global.css"; @@ -62,45 +63,51 @@ function SplashScreenCoordinator() { } export default function App() { - const colorScheme = useColorScheme(); - const statusBarBg = useThemeColor("--color-status-bar"); - return ( - - - - - - {/* The navigation theme drives the NATIVE header appearance: native-stack - forwards `dark` as the nav bar's overrideUserInterfaceStyle. Without - this, React Navigation defaults to its light theme and every native - header (glass buttons, title, materials) is forced light even when - the system is in dark mode. */} - {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */} - - - - - - - {/* Anchored-menu overlays render here — in-window, so the - keyboard stays up while a dropdown is open. */} - - - - + ); } + +function AppContent() { + const { themeAppearance } = useAppearancePreferences(); + const statusBarBg = useThemeColor("--color-status-bar"); + const navigationTheme = useMobileNavigationTheme(themeAppearance); + + return ( + <> + + + + + + {/* The navigation theme drives the NATIVE header appearance: native-stack + forwards `dark` as the nav bar's overrideUserInterfaceStyle. Without + this, React Navigation defaults to its light theme and every native + header (glass buttons, title, materials) is forced light even when + the system is in dark mode. */} + {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */} + + + + + + + {/* Anchored-menu overlays render here — in-window, so the + keyboard stays up while a dropdown is open. */} + + + + + + ); +} diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 847a9ab0b..c1f2cef48 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -11,7 +11,7 @@ import { type NativeStackNavigationOptions, } from "@react-navigation/native-stack"; import { useEffect, useRef } from "react"; -import { Platform, Pressable, ScrollView, StyleSheet } from "react-native"; +import { Platform, Pressable, ScrollView, StyleSheet, View } from "react-native"; import { useResolveClassNames } from "uniwind"; import { AppText as Text } from "./components/AppText"; @@ -71,11 +71,7 @@ import { } from "./features/sharing/incoming-share-presentation"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; -import { - FORM_SHEET_PRESENTATION_OPTIONS, - NATIVE_SHEET_SURFACE_COLOR, - NATIVE_SHEET_SURFACE_CONTENT_STYLE, -} from "./native/sheet-surface"; +import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -96,11 +92,7 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = { headerLargeTitle: false, headerShadowVisible: false, headerShown: true, - headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED - ? { backgroundColor: "transparent" } - : NATIVE_SHEET_SURFACE_COLOR !== undefined - ? { backgroundColor: NATIVE_SHEET_SURFACE_COLOR as unknown as string } - : undefined, + headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? { backgroundColor: "transparent" } : undefined, headerTitleStyle: { fontSize: 18, fontWeight: "800" }, headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED ? HEADER_SCROLL_EDGE_EFFECTS : undefined, @@ -115,12 +107,6 @@ const SOLID_HEADER_OPTIONS: AppScreenOptions = { headerLargeTitle: false, headerShadowVisible: false, headerShown: true, - headerStyle: - NATIVE_SHEET_SURFACE_COLOR !== undefined - ? // native-stack types this as `string`, but the native side accepts any - // ColorValue including DynamicColorIOS. - { backgroundColor: NATIVE_SHEET_SURFACE_COLOR as unknown as string } - : undefined, headerTitleStyle: { fontSize: 18, fontWeight: "800" }, headerTransparent: false, unstable_navigationItemStyle: Platform.OS === "ios" ? "editor" : undefined, @@ -507,7 +493,6 @@ export const RootStack = createNativeStackNavigator({ linking: `${THREAD_LINKING_PREFIX}/files`, options: { ...GLASS_HEADER_OPTIONS, - contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE, title: "Files", }, }), @@ -636,7 +621,11 @@ export const RootStack = createNativeStackNavigator({ // The whole new-task flow (choose project → draft → add project) shares // draft state via NewTaskFlowProvider. The expo-router era mounted it in // app/new/_layout.tsx; this layout wrapper is the native-stack equivalent. - layout: ({ children }) => {children}, + layout: ({ children }) => ( + + {children} + + ), options: { gestureEnabled: true, headerShown: false, diff --git a/apps/mobile/src/components/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/AndroidAnchoredMenu.tsx index c4a0045ee..7a27e0c3b 100644 --- a/apps/mobile/src/components/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/AndroidAnchoredMenu.tsx @@ -3,11 +3,12 @@ import { BlurView } from "expo-blur"; import type { ReactNode } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import type { StyleProp, ViewStyle } from "react-native"; -import { BackHandler, Pressable, ScrollView, useColorScheme, View } from "react-native"; +import { BackHandler, Pressable, ScrollView, View } from "react-native"; import { useKeyboardState } from "react-native-keyboard-controller"; import Animated, { FadeIn } from "react-native-reanimated"; import { appBlurTargetRef } from "../lib/appBlurTarget"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { useThemeColor } from "../lib/useThemeColor"; import { cn } from "../lib/cn"; import { type AppSymbolName, SymbolView } from "./AppSymbol"; @@ -79,7 +80,8 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const anchorRef = useRef(null); const overlayRef = useRef(null); - const isDarkMode = useColorScheme() === "dark"; + const { themeAppearance } = useAppearancePreferences(); + const isDarkMode = themeAppearance === "dark"; const keyboardVisible = useKeyboardState((state) => state.isVisible); const keyboardHeight = useKeyboardState((state) => state.height); const rippleColor = useThemeColor("--color-subtle"); diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 89dc0cc04..74308467a 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -49,6 +49,7 @@ import { IconLink, IconMessage, IconMinus, + IconMoon, IconNetwork, IconPalette, IconPin, @@ -62,6 +63,7 @@ import { IconServer, IconSettings, IconSparkles, + IconSun, IconLayoutSidebarRight, IconTerminal2, IconTextDecrease, @@ -111,6 +113,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "doc.on.doc": IconCopy, "doc.text": IconFileText, ellipsis: IconDots, + moon: IconMoon, "ellipsis.circle": IconDotsCircleHorizontal, "exclamationmark.triangle": IconAlertTriangle, eye: IconEye, @@ -139,6 +142,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "slider.horizontal.3": IconAdjustmentsHorizontal, "square.and.pencil": IconEdit, "square.split.2x1": IconLayoutColumns, + "sun.max": IconSun, "stop.fill": IconPlayerStopFilled, terminal: IconTerminal2, "text.bubble": IconMessage, diff --git a/apps/mobile/src/components/ComposerToolbar.tsx b/apps/mobile/src/components/ComposerToolbar.tsx index d257edc89..117c0ba01 100644 --- a/apps/mobile/src/components/ComposerToolbar.tsx +++ b/apps/mobile/src/components/ComposerToolbar.tsx @@ -4,7 +4,6 @@ import { Pressable, ScrollView, View, - useColorScheme, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent, @@ -13,6 +12,8 @@ import { } from "react-native"; import { useThemeColor } from "../lib/useThemeColor"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { themeColorWithAlpha } from "../lib/mobileTheme"; import { cn } from "../lib/cn"; import { AppText as Text } from "./AppText"; import { SymbolView } from "./AppSymbol"; @@ -214,21 +215,22 @@ export function ComposerToolbarButton(props: { readonly style?: StyleProp; readonly testID?: string; }) { - const isDarkMode = useColorScheme() === "dark"; + const { themeAppearance } = useAppearancePreferences(); + const isDarkMode = themeAppearance === "dark"; const iconColor = useThemeColor("--color-icon"); const iconSubtle = useThemeColor("--color-icon-subtle"); const primaryFg = useThemeColor("--color-primary-foreground"); const dangerFg = useThemeColor("--color-danger-foreground"); const variant = props.variant ?? "default"; const isCircle = !props.label && props.showChevron === false; - const defaultBorderColor = isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)"; - const activeBorderColor = isDarkMode ? "rgba(255,255,255,0.13)" : "rgba(0,0,0,0.1)"; + const defaultBorderColor = useThemeColor("--color-border-subtle"); + const activeBorderColor = useThemeColor("--color-border"); const filledBorderColor = variant === "danger" - ? "rgba(255,255,255,0.14)" + ? themeColorWithAlpha(String(dangerFg), 0.14) : props.disabled ? defaultBorderColor - : "rgba(255,255,255,0.18)"; + : themeColorWithAlpha(String(primaryFg), 0.18); const iconTintColor = variant === "primary" ? props.disabled diff --git a/apps/mobile/src/components/ControlPill.tsx b/apps/mobile/src/components/ControlPill.tsx index abcfc7f7b..f05f303d6 100644 --- a/apps/mobile/src/components/ControlPill.tsx +++ b/apps/mobile/src/components/ControlPill.tsx @@ -8,8 +8,9 @@ import { type ReactNode, useRef, } from "react"; -import { Platform, Pressable, useColorScheme, View } from "react-native"; +import { Platform, Pressable, View } from "react-native"; import { useThemeColor } from "../lib/useThemeColor"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { cn } from "../lib/cn"; import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu"; @@ -117,7 +118,8 @@ export function ControlPillMenu( readonly className?: string; }, ) { - const isDarkMode = useColorScheme() === "dark"; + const { themeAppearance } = useAppearancePreferences(); + const isDarkMode = themeAppearance === "dark"; if (Platform.OS === "android") { // Long-press menus keep their child interactive: the child element gets diff --git a/apps/mobile/src/components/GlassSurface.tsx b/apps/mobile/src/components/GlassSurface.tsx index f0b1f863f..add1c3b5e 100644 --- a/apps/mobile/src/components/GlassSurface.tsx +++ b/apps/mobile/src/components/GlassSurface.tsx @@ -2,7 +2,6 @@ import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect"; import type { ReactNode } from "react"; import { Platform, - useColorScheme, View, type ColorValue, type StyleProp, @@ -10,6 +9,7 @@ import { type ViewStyle, } from "react-native"; import { useThemeColor } from "../lib/useThemeColor"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; interface GlassSurfaceProps extends Omit { readonly children: ReactNode; @@ -29,7 +29,8 @@ export function GlassSurface({ style, ...props }: GlassSurfaceProps) { - const isDarkMode = useColorScheme() === "dark"; + const { themeAppearance } = useAppearancePreferences(); + const isDarkMode = themeAppearance === "dark"; const borderColor = useThemeColor("--color-border"); const glassSurface = useThemeColor("--color-glass-surface"); const glassTint = useThemeColor("--color-glass-tint"); diff --git a/apps/mobile/src/components/LoadingScreen.tsx b/apps/mobile/src/components/LoadingScreen.tsx index 2739c5ce4..275381a9c 100644 --- a/apps/mobile/src/components/LoadingScreen.tsx +++ b/apps/mobile/src/components/LoadingScreen.tsx @@ -1,6 +1,7 @@ -import { ActivityIndicator, StatusBar, View, useColorScheme } from "react-native"; +import { ActivityIndicator, StatusBar, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../lib/useThemeColor"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { AppText as Text } from "./AppText"; import { BrandMark } from "./BrandMark"; @@ -9,7 +10,7 @@ export function LoadingScreen(props: { readonly message: string; readonly messagePlacement?: "above-spinner" | "below-spinner"; }) { - const colorScheme = useColorScheme(); + const { themeAppearance: colorScheme } = useAppearancePreferences(); const screenBg = useThemeColor("--color-screen"); const insets = useSafeAreaInsets(); const messagePlacement = props.messagePlacement ?? "below-spinner"; diff --git a/apps/mobile/src/components/PierreEntryIcon.tsx b/apps/mobile/src/components/PierreEntryIcon.tsx index fa79c4f60..9cb6898fb 100644 --- a/apps/mobile/src/components/PierreEntryIcon.tsx +++ b/apps/mobile/src/components/PierreEntryIcon.tsx @@ -3,6 +3,7 @@ import { Image, type ImageStyle, type StyleProp } from "react-native"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; +import { useThemeColor } from "../lib/useThemeColor"; export function PierreEntryIcon(props: { readonly path: string; @@ -11,8 +12,9 @@ export function PierreEntryIcon(props: { readonly style?: StyleProp; }) { const size = props.size ?? 16; + const folderColor = useThemeColor("--color-icon-subtle"); if (props.kind === "directory") { - return ; + return ; } return ( diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index a98a7a2bd..6f6a40d64 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -1,7 +1,7 @@ -import { useColorScheme } from "react-native"; import { Circle, Path, Svg } from "react-native-svg"; import { providerIconKind } from "./providerIconKind"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; type ProviderIconProps = { readonly provider: string | null | undefined; @@ -9,7 +9,8 @@ type ProviderIconProps = { }; export function ProviderIcon(props: ProviderIconProps) { - const isDarkMode = useColorScheme() === "dark"; + const { themeAppearance } = useAppearancePreferences(); + const isDarkMode = themeAppearance === "dark"; const size = props.size ?? 16; const mono = isDarkMode ? "#e5e5e5" : "#171717"; const iconKind = providerIconKind(props.provider); diff --git a/apps/mobile/src/components/ThemedSwitch.tsx b/apps/mobile/src/components/ThemedSwitch.tsx new file mode 100644 index 000000000..270ee084e --- /dev/null +++ b/apps/mobile/src/components/ThemedSwitch.tsx @@ -0,0 +1,21 @@ +import { Platform, Switch, type SwitchProps } from "react-native"; + +import { useThemeColor } from "../lib/useThemeColor"; + +export function ThemedSwitch(props: SwitchProps) { + const activeTrack = String(useThemeColor("--color-switch-active-track")); + const inactiveTrack = String(useThemeColor("--color-switch-inactive-track")); + const activeThumb = String(useThemeColor("--color-switch-active-thumb")); + const inactiveThumb = String(useThemeColor("--color-switch-inactive-thumb")); + + return ( + + ); +} diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 1c408d7c1..a0c86122d 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -9,13 +9,13 @@ import { useCallback, useState } from "react"; import { ActivityIndicator, Pressable, - Switch, type NativeSyntheticEvent, type TextLayoutEventData, View, } from "react-native"; import { AppText as Text } from "../../components/AppText"; +import { ThemedSwitch } from "../../components/ThemedSwitch"; import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -277,8 +277,6 @@ function CloudEnvironmentRowShell(props: { readonly statusText?: string; readonly value: boolean; }) { - const activeTrack = String(useThemeColor("--color-switch-active")); - const track = String(useThemeColor("--color-secondary-border")); const chevron = useThemeColor("--color-chevron"); const isRetrying = props.connectionState === "connecting" || props.connectionState === "reconnecting"; @@ -391,11 +389,9 @@ function CloudEnvironmentRowShell(props: { ) : null} - diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index 9774130eb..942d0b4ff 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -2,14 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import type { ComponentType } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - FlatList, - ScrollView, - Text as NativeText, - useColorScheme, - useWindowDimensions, - View, -} from "react-native"; +import { FlatList, ScrollView, Text as NativeText, useWindowDimensions, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; import { LoadingStrip } from "../../components/LoadingStrip"; @@ -23,6 +16,7 @@ import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter"; import { cn } from "../../lib/cn"; import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { buildNativeSourceTokens, NATIVE_SOURCE_CONTENT_WIDTH, @@ -115,8 +109,7 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { }); function useSourceFileModel(props: SourceFileSurfaceProps) { - const colorScheme = useColorScheme(); - const theme: "dark" | "light" = colorScheme === "dark" ? "dark" : "light"; + const { themeAppearance: theme } = useAppearancePreferences(); const document = useMemo(() => prepareSourceFileDocument(props.contents), [props.contents]); const { contents: normalizedContents, lines, rowsJson } = document; const targetIndex = @@ -159,8 +152,9 @@ function NativeSourceFileSurface( ) { const { NativeView, onRefresh } = props; const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface(); + const { themeAppearance, themeId } = useAppearancePreferences(); const { width: viewportWidth } = useWindowDimensions(); - const { rowsJson, status, targetIndex, theme, tokens } = useSourceFileModel(props); + const { rowsJson, status, targetIndex, tokens } = useSourceFileModel(props); const [isPullRefreshing, setIsPullRefreshing] = useState(false); const handlePullToRefresh = useCallback(async () => { if (!onRefresh) { @@ -178,7 +172,10 @@ function NativeSourceFileSurface( () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]), [targetIndex], ); - const themeJson = useMemo(() => JSON.stringify(createNativeReviewDiffTheme(theme)), [theme]); + const themeJson = useMemo( + () => JSON.stringify(createNativeReviewDiffTheme(themeAppearance, themeId)), + [themeAppearance, themeId], + ); const styleJson = useMemo(() => JSON.stringify(nativeSourceStyle), [nativeSourceStyle]); const contentWidth = codeWordBreak ? Math.max(240, viewportWidth - codeSurface.gutterWidth - 24) @@ -191,7 +188,7 @@ function NativeSourceFileSurface( collapsable={false} testID="source-native-code-view" style={{ flex: 1 }} - appearanceScheme={theme} + appearanceScheme={themeAppearance} contentResetKey={props.path} contentWidth={contentWidth} initialRowIndex={targetIndex ?? -1} diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 7f5105aac..28356be18 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -1,7 +1,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useEffect, useRef, useState } from "react"; -import { ActivityIndicator, Platform, useColorScheme, View } from "react-native"; +import { ActivityIndicator, Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import { @@ -35,6 +35,7 @@ import { } from "../layout/native-mail-search-toolbar"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; import { FileMarkdownPreview } from "./FileMarkdownPreview"; import { FileTreeBrowser } from "./FileTreeBrowser"; @@ -242,10 +243,10 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { const { fileInspector, layout, panes, showAuxiliaryPane, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); const [searchQuery, setSearchQuery] = useState(""); - const colorScheme = useColorScheme(); const isAndroid = Platform.OS === "android"; - const highlightTheme = colorScheme === "dark" ? "dark" : "light"; + const { themeAppearance: highlightTheme } = useAppearancePreferences(); const iconColor = String(useThemeColor("--color-icon-muted")); + const sheetSurfaceColor = String(useThemeColor("--color-sheet-solid")); const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( props.route.params, ); @@ -362,10 +363,12 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { return ( <> - {/* Static header config (glass preset, title, contentStyle) lives in Stack.tsx. - Only genuinely dynamic options are set here. */} + {/* 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, diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index c06f7cc96..e13f3f61b 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -1,7 +1,7 @@ import type { EnvironmentId, ProjectListEntriesResult } from "@t3tools/contracts"; import { SymbolView } from "../../components/AppSymbol"; import { useCallback, useMemo, useState, type ComponentProps } from "react"; -import { Platform, Pressable, useColorScheme, View, type NativeSyntheticEvent } from "react-native"; +import { Platform, Pressable, View, type NativeSyntheticEvent } from "react-native"; import { Screen, ScreenStack, @@ -15,6 +15,7 @@ import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; import { useThemeColor } from "../../lib/useThemeColor"; import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { FileTreeBrowser } from "./FileTreeBrowser"; import { preloadWorkspaceFileContents } from "./preload-workspace-file"; @@ -27,8 +28,7 @@ export function ThreadFileNavigatorPane(props: { readonly onSelectFile: (path: string) => void; }) { const [searchQuery, setSearchQuery] = useState(""); - const colorScheme = useColorScheme(); - const highlightTheme = colorScheme === "dark" ? "dark" : "light"; + const { themeAppearance: highlightTheme } = useAppearancePreferences(); const iconColor = String(useThemeColor("--color-icon-muted")); const foregroundColor = String(useThemeColor("--color-foreground")); const sheetColor = String(useThemeColor("--color-sheet")); diff --git a/apps/mobile/src/features/layout/workspace-pane-divider.tsx b/apps/mobile/src/features/layout/workspace-pane-divider.tsx index 3be3cdf60..d476452ef 100644 --- a/apps/mobile/src/features/layout/workspace-pane-divider.tsx +++ b/apps/mobile/src/features/layout/workspace-pane-divider.tsx @@ -1,14 +1,8 @@ import { useCallback, useMemo, useRef, useState } from "react"; -import { - Platform, - PlatformColor, - Pressable, - StyleSheet, - View, - type AccessibilityActionEvent, -} from "react-native"; +import { Pressable, StyleSheet, View, type AccessibilityActionEvent } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { runOnJS } from "react-native-reanimated"; +import { useThemeColor } from "../../lib/useThemeColor"; const ACCESSIBILITY_RESIZE_STEP = 24; @@ -28,6 +22,8 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { latestProps.current = props; const [hovered, setHovered] = useState(false); const [dragging, setDragging] = useState(false); + const dividerColor = useThemeColor("--color-border"); + const activeDividerColor = useThemeColor("--color-primary"); const handleResizeStart = useCallback(() => { setDragging(true); latestProps.current.onResizeStart?.(); @@ -84,7 +80,13 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { onHoverIn={() => setHovered(true)} onHoverOut={() => setHovered(false)} > - + ); @@ -93,14 +95,11 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { const styles = StyleSheet.create({ line: { alignSelf: "center", - backgroundColor: - Platform.OS === "ios" ? PlatformColor("separator") : "rgba(120, 120, 128, 0.28)", height: "100%", opacity: 0.7, width: StyleSheet.hairlineWidth, }, activeLine: { - backgroundColor: Platform.OS === "ios" ? PlatformColor("systemBlueColor") : "#0a84ff", opacity: 1, width: 2, }, diff --git a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx index c6d678ddc..40f8fcf15 100644 --- a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx +++ b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx @@ -2,14 +2,7 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native" import { TextInputWrapper } from "expo-paste-input"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { - Platform, - Pressable, - ScrollView, - View, - useColorScheme, - useWindowDimensions, -} from "react-native"; +import { Platform, Pressable, ScrollView, View, useWindowDimensions } from "react-native"; import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import ImageViewing from "react-native-image-viewing"; @@ -33,10 +26,10 @@ import { useReviewCommentTarget, } from "./reviewCommentSelection"; import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { changeTone, DiffTokenText, ReviewChangeBar } from "./reviewDiffRendering"; import { highlightReviewSelectedLines, - type ReviewDiffTheme, type ReviewHighlightedToken, } from "./shikiReviewHighlighter"; @@ -52,7 +45,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp const navigation = useNavigation(); const insets = useSafeAreaInsets(); const { width } = useWindowDimensions(); - const colorScheme = useColorScheme(); + const { themeAppearance: selectedTheme } = useAppearancePreferences(); const iconTint = String(useThemeColor("--color-icon")); const target = useReviewCommentTarget(); const { codeSurface } = useAppearanceCodeSurface(); @@ -72,7 +65,6 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp const lastLine = selectedLines[selectedLines.length - 1] ?? null; const firstNumber = firstLine ? getReviewUnifiedLineNumber(firstLine) : null; const lastNumber = lastLine ? getReviewUnifiedLineNumber(lastLine) : null; - const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme; const canSubmit = commentText.trim().length > 0 && target !== null && !!environmentId && !!threadId; const selectionLabel = @@ -162,7 +154,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp }, [attachments, commentText, dismissComposer, environmentId, target, threadId]); return ( - + void) | null; readonly onClear: () => void; }) { + const foreground = useThemeColor("--color-primary-foreground"); if (!props.title) { return null; } @@ -106,10 +107,10 @@ function ReviewSelectionActionBar(props: { - {props.title} + {props.title} ); @@ -128,22 +129,22 @@ function ReviewSelectionActionBar(props: { > {props.onOpenComment ? ( {content} ) : ( - + {content} )} - + ); @@ -346,7 +347,7 @@ export function ReviewSheet(props: ReviewSheetProps) { const { panes, showAuxiliaryPane, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const colorScheme = useColorScheme(); + const { themeAppearance: selectedTheme } = useAppearancePreferences(); const headerIcon = String(useThemeColor("--color-icon")); const { environmentId, threadId } = props.route.params; const environment = useEnvironmentPresentation(environmentId); @@ -371,7 +372,6 @@ export function ReviewSheet(props: ReviewSheetProps) { // selected thread (it always does when reached from the thread's toolbar). const gitMenuAvailable = selectedThread !== null && String(selectedThread.id) === String(threadId); - const selectedTheme = colorScheme === "dark" ? "dark" : "light"; // With a solid (non-overlay) header the content lays out below the header // natively, so no manual top inset is needed. (Android renders its own // in-flow AndroidScreenHeader, so it needs no inset either.) @@ -436,7 +436,6 @@ export function ReviewSheet(props: ReviewSheetProps) { sectionId: selectedSection?.id ?? null, diff: selectedSection?.diff, data: nativeReviewDiffData, - scheme: selectedTheme, collapsedFileIds, viewedFileIds, selectedRowIds: commentSelection.selectedRowIds, @@ -444,7 +443,7 @@ export function ReviewSheet(props: ReviewSheetProps) { }); const showcaseReviewKey = SHOWCASE_ENABLED && parsedDiff.kind === "files" && selectedSection - ? `${reviewCache.threadKey}:${selectedSection.id}:${nativeBridge.tokensResetKey}` + ? `${reviewCache.threadKey}:${selectedSection.id}:${nativeBridge.tokensResetKey}:${nativeBridge.themeId}` : null; const handleNativeDebug = useCallback( (event: NativeSyntheticEvent>) => { @@ -457,9 +456,9 @@ export function ReviewSheet(props: ReviewSheetProps) { return; } showcasedReviewDrawRef.current = showcaseReviewKey; - markNativeShowcaseReady("review"); + reportShowcaseSceneRendered({ scene: "review", themeId: nativeBridge.themeId }); }, - [nativeBridge.onDebug, showcaseReviewKey], + [nativeBridge.onDebug, nativeBridge.themeId, showcaseReviewKey], ); const handleSelectFile = useCallback( diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts index 1722b06d6..dbd1d7aeb 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; +import { MOBILE_THEME_IDS } from "../../lib/mobileTheme"; import { + createNativeReviewDiffTheme, getCachedNativeReviewDiffData, type BuildNativeReviewDiffDataInput, } from "./nativeReviewDiffAdapter"; @@ -54,3 +56,26 @@ describe("getCachedNativeReviewDiffData", () => { expect(changed).not.toBe(first); }); }); + +describe("createNativeReviewDiffTheme", () => { + it("serializes every native color as cross-platform opaque hex", () => { + for (const themeId of MOBILE_THEME_IDS) { + for (const appearance of ["light", "dark"] as const) { + const theme = createNativeReviewDiffTheme(appearance, themeId); + for (const color of Object.values(theme)) { + expect(color, `${themeId}/${appearance}`).toMatch(/^#[\da-f]{6}$/i); + } + } + } + }); + + it("uses the selected app palette for native code surfaces", () => { + const standard = createNativeReviewDiffTheme("dark", "t3-code"); + const iris = createNativeReviewDiffTheme("dark", "iris"); + + expect(iris.background).not.toBe(standard.background); + expect(iris.hunkText).not.toBe(standard.hunkText); + expect(iris.addBar).toBe(standard.addBar); + expect(iris.deleteBar).toBe(standard.deleteBar); + }); +}); diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index 6d82940bb..66beae22e 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -8,7 +8,12 @@ import { pipe } from "effect/Function"; import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; import { resolveMobileCodeSurface } from "../../lib/appearancePreferences"; import { MOBILE_CODE_SURFACE } from "../../lib/typography"; -import { getPierreTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme"; +import { + DEFAULT_MOBILE_THEME_ID, + getMobileThemeVariables, + type MobileThemeId, +} from "../../lib/mobileTheme"; +import { getMobileTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme"; import { computeWordAltDiffRanges } from "./reviewWordDiffs"; import { getReviewFilePreviewState, @@ -20,6 +25,9 @@ import type { ReviewInlineComment } from "./reviewCommentSelection"; const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4; const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45; +const NATIVE_HEX_COLOR = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i; +const NATIVE_RGBA_COLOR = + /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/; export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800; @@ -28,6 +36,23 @@ export const NATIVE_REVIEW_DIFF_STYLE = createNativeReviewDiffStyle( resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize), ); +function opaqueNativeHexColor(color: string, background: string): string { + const hex = NATIVE_HEX_COLOR.exec(color); + if (hex) return color; + + const rgba = NATIVE_RGBA_COLOR.exec(color); + const backgroundHex = NATIVE_HEX_COLOR.exec(background); + if (!rgba || !backgroundHex) return background; + + const alpha = rgba[4] === undefined ? 1 : Math.min(1, Math.max(0, Number(rgba[4]))); + const channels = [1, 2, 3].map((index) => { + const foreground = Number(rgba[index]); + const behind = Number.parseInt(backgroundHex[index], 16); + return Math.round(foreground * alpha + behind * (1 - alpha)); + }); + return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; +} + export function createNativeReviewDiffStyle(codeSurface: ResolvedMobileCodeSurface) { return { rowHeight: codeSurface.rowHeight, @@ -112,21 +137,28 @@ function buildReviewCommentsCacheKey(comments: ReadonlyArray opaqueNativeHexColor(color, background); if (scheme === "dark") { return { // Match the app surface (--color-sheet) so code views blend with the rest of // the app instead of using a distinct code-editor background. - background: "#0e0e0e", - text: terminalTheme.foreground, - mutedText: terminalTheme.mutedForeground, - headerBackground: "#0e0e0e", - border: terminalTheme.border, - hunkBackground: "#071f28", - hunkText: terminalBlue ?? "#009fff", + background, + text: nativeColor(appTheme["--color-md-code-text"]), + mutedText: nativeColor(appTheme["--color-foreground-muted"]), + headerBackground: background, + border: nativeColor(appTheme["--color-border"]), + hunkBackground: nativeColor(appTheme["--color-subtle-strong"]), + hunkText: nativeColor(appTheme["--color-primary"]), addBackground: "#0d2f28", deleteBackground: "#391415", addBar: "#00cab1", @@ -139,13 +171,13 @@ export function createNativeReviewDiffTheme( return { // Match the app surface (--color-sheet) so code views blend with the rest of the // app instead of using a distinct code-editor background. - background: "#f2f2f7", - text: "#070707", - mutedText: terminalTheme.mutedForeground, - headerBackground: "#f2f2f7", - border: terminalTheme.border, - hunkBackground: "#e0f2ff", - hunkText: terminalBlue ?? "#009fff", + background, + text: nativeColor(appTheme["--color-md-code-text"]), + mutedText: nativeColor(appTheme["--color-foreground-muted"]), + headerBackground: background, + border: nativeColor(appTheme["--color-border"]), + hunkBackground: nativeColor(appTheme["--color-subtle-strong"]), + hunkText: nativeColor(appTheme["--color-primary"]), addBackground: "#e5f8f5", deleteBackground: "#ffe6e7", addBar: "#00cab1", diff --git a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts index d28e45844..f5effb948 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts +++ b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts @@ -1,9 +1,9 @@ import { useCallback, useMemo, useState } from "react"; import type { NativeSyntheticEvent } from "react-native"; -import { type NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffHighlighter"; import { createNativeReviewDiffTheme, type NativeReviewDiffData } from "./nativeReviewDiffAdapter"; import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighting"; import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; @@ -14,7 +14,6 @@ export function useNativeReviewDiffBridge(input: { readonly sectionId: string | null; readonly diff: string | null | undefined; readonly data: NativeReviewDiffData; - readonly scheme: NativeReviewDiffHighlightScheme; readonly collapsedFileIds: ReadonlyArray; readonly viewedFileIds: ReadonlyArray; readonly selectedRowIds: ReadonlyArray; @@ -25,18 +24,18 @@ export function useNativeReviewDiffBridge(input: { collapsedFileIds, data, diff, - scheme, sectionId, selectedRowIds, threadKey, viewedFileIds, } = input; const { nativeReviewDiffStyle } = useAppearanceCodeSurface(); + const { themeAppearance: scheme, themeId } = useAppearancePreferences(); const [collapsedCommentIds, setCollapsedCommentIds] = useState>( () => new Set(), ); - const theme = useMemo(() => createNativeReviewDiffTheme(scheme), [scheme]); + const theme = useMemo(() => createNativeReviewDiffTheme(scheme, themeId), [scheme, themeId]); const rowsJson = useMemo(() => JSON.stringify(data.rows), [data.rows]); const collapsedFileIdsJson = useMemo(() => JSON.stringify(collapsedFileIds), [collapsedFileIds]); const viewedFileIdsJson = useMemo(() => JSON.stringify(viewedFileIds), [viewedFileIds]); @@ -106,6 +105,7 @@ export function useNativeReviewDiffBridge(input: { ); return { + themeId, theme, rowsJson, collapsedFileIdsJson, diff --git a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx index 5b62942bb..a97193d6b 100644 --- a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx @@ -7,6 +7,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection"; import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection"; import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection"; +import { ThemeAppearanceSection } from "./appearance/sections/ThemeAppearanceSection"; export function SettingsAppearanceRouteScreen() { const navigation = useNavigation(); @@ -29,6 +30,7 @@ export function SettingsAppearanceRouteScreen() { paddingBottom: Math.max(insets.bottom, 18) + 18, }} > + diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index aa30242ea..6b6d589fa 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -2,7 +2,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac import { useNavigation } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import type { EnvironmentId } from "@t3tools/contracts"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -20,7 +20,6 @@ import { SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS, SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS, } from "../showcase/showcaseEnvironmentRows"; -import { markNativeShowcaseReady } from "../showcase/nativeShowcaseScene"; const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; @@ -48,12 +47,6 @@ export function SettingsEnvironmentsRouteScreen() { const accentColor = useThemeColor("--color-icon-muted"); const headerIconColor = useThemeColor("--color-icon"); - useEffect(() => { - if (!SHOWCASE_ENABLED) return; - const timer = setTimeout(() => markNativeShowcaseReady("environments"), 500); - return () => clearTimeout(timer); - }, []); - const handleToggle = useCallback((environmentId: EnvironmentId) => { setExpandedId((prev) => (prev === environmentId ? null : environmentId)); }, []); diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index 7eaafc0e9..96a01c051 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -1,4 +1,5 @@ -import { createContext, use, useCallback, useEffect, useMemo, type ReactNode } from "react"; +import { createContext, use, useCallback, useLayoutEffect, useMemo, type ReactNode } from "react"; +import { useColorScheme } from "react-native"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; @@ -9,16 +10,37 @@ import { resolveAppearance, resolveAppearancePreferences, resolveTextScaleVariables, - type AppearancePreferences, type ResolvedAppearance, } from "../../../lib/appearancePreferences"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; +import type { Preferences } from "../../../persistence/mobile-preferences"; +import { + createMobileThemePairPatch, + createMobileThemeSelectionPatch, + getMobileThemeVariables, + normalizeMobileThemeMode, + resolveMobileThemeIds, + type MobileThemeAppearance, + type MobileThemeId, + type MobileThemeIds, + type MobileThemeMode, +} from "../../../lib/mobileTheme"; import { cacheTerminalFontSize } from "../../terminal/terminalUiState"; interface AppearancePreferencesContextValue { /** Effective values with base-size derivation applied. Use this for rendering. */ readonly appearance: ResolvedAppearance; + readonly themeId: MobileThemeId; + readonly themeIds: MobileThemeIds; + readonly themeMode: MobileThemeMode; + readonly themeAppearance: MobileThemeAppearance; readonly isReady: boolean; + readonly setThemeIdForAppearance: ( + appearance: MobileThemeAppearance, + value: MobileThemeId, + ) => void; + readonly setThemeIdForBothAppearances: (value: MobileThemeId) => void; + readonly setThemeMode: (value: MobileThemeMode) => void; readonly setBaseFontSize: (value: number) => void; /** Pass null to clear the override and follow the base font size. */ readonly setTerminalFontSize: (value: number | null) => void; @@ -30,46 +52,85 @@ interface AppearancePreferencesContextValue { const AppearancePreferencesContext = createContext(null); /** - * Injects the scaled `--text-*` variables into Uniwind so every - * className-based text size (`text-sm`, `text-base`, ...) re-resolves live. - * Updates the current theme last so the active stylesheet settles correctly. + * Injects palette and text-scale variables into both adaptive stylesheets. + * Updating the active sheet last lets the visible app settle in one pass. */ -function applyTextScaleVariables(baseFontSize: number) { - const variables = resolveTextScaleVariables(baseFontSize); +function applyAppearanceVariables(baseFontSize: number, themeIds: MobileThemeIds) { + const textVariables = resolveTextScaleVariables(baseFontSize); const currentTheme = Uniwind.currentTheme; + const activeAppearance = + currentTheme === "light" || currentTheme === "dark" ? currentTheme : null; for (const theme of ["light", "dark"] as const) { - if (theme !== currentTheme) { + const variables = { ...getMobileThemeVariables(themeIds[theme], theme), ...textVariables }; + if (theme !== activeAppearance) { Uniwind.updateCSSVariables(theme, variables); } } - Uniwind.updateCSSVariables(currentTheme, variables); + if (activeAppearance !== null) { + Uniwind.updateCSSVariables(activeAppearance, { + ...getMobileThemeVariables(themeIds[activeAppearance], activeAppearance), + ...textVariables, + }); + } } export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) { const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const systemColorScheme = useColorScheme() === "dark" ? "dark" : "light"; + const storedPreferences = AsyncResult.isSuccess(preferencesResult) + ? preferencesResult.value + : null; const preferences = useMemo( - () => - resolveAppearancePreferences( - AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : null, - ), - [preferencesResult], + () => resolveAppearancePreferences(storedPreferences), + [storedPreferences], + ); + const themeMode = normalizeMobileThemeMode(storedPreferences?.themeMode); + const themeAppearance = themeMode === "system" ? systemColorScheme : themeMode; + const themeIds = useMemo( + () => resolveMobileThemeIds(storedPreferences ?? {}), + [storedPreferences], ); + const themeId = themeIds[themeAppearance]; const isReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting; - useEffect(() => { - applyTextScaleVariables(preferences.baseFontSize); + useLayoutEffect(() => { + applyAppearanceVariables(preferences.baseFontSize, themeIds); + Uniwind.setTheme(themeMode); cacheTerminalFontSize(resolveAppearance(preferences).terminalFontSize); - }, [preferences]); + }, [preferences, themeIds, themeMode]); const updatePreferences = useCallback( - (patch: Partial) => { + (patch: Partial) => { savePreferences(patch); }, [savePreferences], ); + const setThemeIdForAppearance = useCallback( + (appearance: MobileThemeAppearance, value: MobileThemeId) => { + updatePreferences( + createMobileThemeSelectionPatch(themeIds, themeAppearance, appearance, value), + ); + }, + [themeAppearance, themeIds, updatePreferences], + ); + + const setThemeIdForBothAppearances = useCallback( + (value: MobileThemeId) => { + updatePreferences(createMobileThemePairPatch(value)); + }, + [updatePreferences], + ); + + const setThemeMode = useCallback( + (value: MobileThemeMode) => { + updatePreferences({ themeMode: value }); + }, + [updatePreferences], + ); + const setBaseFontSize = useCallback( (value: number) => { updatePreferences({ baseFontSize: value }); @@ -101,13 +162,34 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN const value = useMemo( (): AppearancePreferencesContextValue => ({ appearance: resolveAppearance(preferences), + themeId, + themeIds, + themeMode, + themeAppearance, isReady, + setThemeIdForAppearance, + setThemeIdForBothAppearances, + setThemeMode, setBaseFontSize, setTerminalFontSize, setCodeFontSize, setCodeWordBreak, }), - [preferences, isReady, setBaseFontSize, setTerminalFontSize, setCodeFontSize, setCodeWordBreak], + [ + preferences, + themeId, + themeIds, + themeMode, + themeAppearance, + isReady, + setThemeIdForAppearance, + setThemeIdForBothAppearances, + setThemeMode, + setBaseFontSize, + setTerminalFontSize, + setCodeFontSize, + setCodeWordBreak, + ], ); return ( diff --git a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx index 276b0f018..8fd166ed2 100644 --- a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx +++ b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx @@ -1,11 +1,4 @@ -import { - Platform, - ScrollView, - type StyleProp, - type TextStyle, - View, - useColorScheme, -} from "react-native"; +import { Platform, ScrollView, type StyleProp, type TextStyle, View } from "react-native"; import { AppText as Text } from "../../../../components/AppText"; import { @@ -13,7 +6,8 @@ import { resolveMobileCodeSurface, } from "../../../../lib/appearancePreferences"; import { useThemeColor } from "../../../../lib/useThemeColor"; -import { getPierreTerminalTheme } from "../../../terminal/terminalTheme"; +import { getMobileTerminalTheme } from "../../../terminal/terminalTheme"; +import { useAppearancePreferences } from "../AppearancePreferencesProvider"; const CODE_FONT_FAMILY = Platform.select({ ios: "ui-monospace", @@ -53,8 +47,8 @@ export function TextAppearancePreview(props: { readonly fontSize: number }) { * on the shared card background so it reads like the other previews. */ export function TerminalAppearancePreview(props: { readonly fontSize: number }) { - const scheme = useColorScheme() === "light" ? "light" : "dark"; - const theme = getPierreTerminalTheme(scheme); + const { themeAppearance: scheme, themeId } = useAppearancePreferences(); + const theme = getMobileTerminalTheme(themeId, scheme); const lineHeight = Math.round(props.fontSize * 1.6); const lineStyle = { fontFamily: "Menlo", diff --git a/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx new file mode 100644 index 000000000..ab2a99313 --- /dev/null +++ b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx @@ -0,0 +1,365 @@ +import { memo, useId } from "react"; +import { Pressable, View } from "react-native"; +import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg"; + +import { mixThemePreviewBase, THEME_PREVIEW_RENDER_SPECS } from "@t3tools/shared/themePreview"; + +import { SymbolView } from "../../../../components/AppSymbol"; +import { AppText as Text } from "../../../../components/AppText"; +import { + getMobileThemeVariables, + getMobileThemePreviewColors, + MOBILE_THEME_OPTIONS, + type MobileThemeAppearance, + type MobileThemeId, + type MobileThemeIds, + type MobileThemeMode, + type MobileThemeVariables, +} from "../../../../lib/mobileTheme"; +import { useThemeColor } from "../../../../lib/useThemeColor"; +import { useAppearancePreferences } from "../AppearancePreferencesProvider"; + +const APPEARANCE_MODES: ReadonlyArray<{ + readonly id: MobileThemeMode; + readonly label: string; +}> = [ + { id: "system", label: "System" }, + { id: "light", label: "Light" }, + { id: "dark", label: "Dark" }, +]; + +const PreviewOrb = memo(function PreviewOrb(props: { + readonly appearance: MobileThemeAppearance; + readonly compact?: boolean; + readonly themeId: MobileThemeId; +}) { + const idPrefix = useId().replaceAll(":", ""); + const accentGradientId = `${idPrefix}-accent-glow`; + const actionGradientId = `${idPrefix}-action-glow`; + const colors = 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]), + Math.max(spec.accent.center[1], 1 - spec.accent.center[1]), + ); + const actionRadius = Math.hypot( + Math.max(spec.action.center[0], 1 - spec.action.center[0]), + Math.max(spec.action.center[1], 1 - spec.action.center[1]), + ); + const position = (value: number) => `${value * 100}%`; + const radius = (value: number) => `${value * 100}%`; + + return ( + + + + + + + + + + + + + + + + + + + + + ); +}); + +function ThemeCard(props: { + readonly disabled: boolean; + readonly darkSelected: boolean; + readonly label: string; + readonly lightSelected: boolean; + readonly onSelectBoth: () => void; + readonly onSelect: (appearance: MobileThemeAppearance) => void; + readonly themeId: MobileThemeId; +}) { + const badgeBackground = useThemeColor("--color-card"); + const badgeIcon = useThemeColor("--color-icon"); + + const choice = (appearance: MobileThemeAppearance, selected: boolean) => ( + props.onSelect(appearance)} + > + + {selected ? ( + + + + ) : null} + + ); + + return ( + + + + {choice("light", props.lightSelected)} + {choice("dark", props.darkSelected)} + + + + {props.label} + + + + ); +} + +function PreviewPane(props: { readonly colors: MobileThemeVariables; readonly compact?: boolean }) { + return ( + + + + + + + + + + + + + + + + + + + ); +} + +function ModePreview(props: { readonly mode: MobileThemeMode; readonly themeIds: MobileThemeIds }) { + const light = getMobileThemeVariables(props.themeIds.light, "light"); + const dark = getMobileThemeVariables(props.themeIds.dark, "dark"); + const currentBorder = useThemeColor("--color-border"); + const currentFrame = useThemeColor("--color-drawer"); + const currentIndicator = useThemeColor("--color-foreground-muted"); + const frameColor = + props.mode === "light" + ? light["--color-border"] + : props.mode === "dark" + ? dark["--color-border"] + : currentBorder; + const frameBackground = + props.mode === "light" + ? light["--color-drawer"] + : props.mode === "dark" + ? dark["--color-drawer"] + : currentFrame; + const indicatorColor = + props.mode === "light" + ? light["--color-foreground-muted"] + : props.mode === "dark" + ? dark["--color-foreground-muted"] + : currentIndicator; + + return ( + + + {props.mode === "system" ? ( + <> + + + + ) : ( + + )} + + + + ); +} + +function ModeCard(props: { + readonly disabled: boolean; + readonly label: string; + readonly mode: MobileThemeMode; + readonly onPress: () => void; + readonly selected: boolean; + readonly themeIds: MobileThemeIds; +}) { + return ( + + + + {props.label} + + + ); +} + +function SectionLabel({ children }: { readonly children: string }) { + return {children}; +} + +export function ThemeAppearanceSection() { + const { + isReady, + setThemeIdForAppearance, + setThemeIdForBothAppearances, + setThemeMode, + themeIds, + themeMode, + } = useAppearancePreferences(); + + return ( + + + Color scheme + + {APPEARANCE_MODES.map((mode) => ( + setThemeMode(mode.id)} + selected={mode.id === themeMode} + themeIds={themeIds} + /> + ))} + + + + + Themes + + {MOBILE_THEME_OPTIONS.map((theme) => ( + setThemeIdForAppearance(appearance, theme.id)} + onSelectBoth={() => setThemeIdForBothAppearances(theme.id)} + themeId={theme.id} + /> + ))} + + + + ); +} diff --git a/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx b/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx index c6c835f11..2a63385a0 100644 --- a/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx +++ b/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx @@ -1,8 +1,9 @@ import type { ComponentProps } from "react"; -import { Switch, View } from "react-native"; +import { View } from "react-native"; import { SymbolView } from "../../../components/AppSymbol"; import { AppText as Text } from "../../../components/AppText"; +import { ThemedSwitch } from "../../../components/ThemedSwitch"; import { useThemeColor } from "../../../lib/useThemeColor"; type SymbolName = ComponentProps["name"]; @@ -16,8 +17,6 @@ export function SettingsSwitchRow(props: { readonly onValueChange: (value: boolean) => void; }) { const icon = useThemeColor("--color-icon"); - const activeTrack = String(useThemeColor("--color-switch-active")); - const track = String(useThemeColor("--color-secondary-border")); return ( {props.subtitle} ) : null} - diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx index 424822c35..557c3b190 100644 --- a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { Keyboard, View } from "react-native"; import { CommonActions, @@ -10,6 +10,8 @@ import { import { AsyncResult } from "effect/unstable/reactivity"; import { useConnectionController } from "../connection/useConnectionController"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import type { MobileThemeId } from "../../lib/mobileTheme"; import { useProjects, useThreadShells } from "../../state/entities"; import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; import { holdEditingQueuedMessage } from "../../state/use-thread-outbox"; @@ -19,6 +21,7 @@ import { getNativeShowcaseOrientation, getNativeShowcasePairingUrls, getNativeShowcaseScene, + getNativeShowcaseTheme, markNativeShowcaseReady, type ShowcaseScene, } from "./nativeShowcaseScene"; @@ -27,6 +30,12 @@ import { SHOWCASE_PENDING_TASK_DEFINITIONS, } from "./showcasePendingTasks"; import { retryShowcaseOperation } from "./showcaseRetry"; +import { + clearShowcaseRenderSignal, + getShowcaseRenderSignal, + isShowcaseNativeContentReady, + subscribeToShowcaseRenderSignal, +} from "./showcaseRenderSignal"; const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; const SHOWCASE_THREAD_ID = "remote-command-center"; @@ -48,6 +57,12 @@ function sceneFromPathname(pathname: string): ShowcaseScene | null { export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) { const navigation = useNavigation(); const { connectPairingUrl } = useConnectionController(); + const { + isReady: appearancePreferencesReady, + themeId, + themeIds, + setThemeIdForBothAppearances, + } = useAppearancePreferences(); const workspace = useWorkspaceState(); const projects = useProjects(); const threads = useThreadShells(); @@ -56,18 +71,32 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) const [pairingUrls, setPairingUrls] = useState>([]); const [pendingTasksReady, setPendingTasksReady] = useState(false); const [requestedScene, setRequestedScene] = useState(null); + const [requestedTheme, setRequestedTheme] = useState(null); + const [themeRequestSettled, setThemeRequestSettled] = useState(false); const [readyScene, setReadyScene] = useState(null); const [orientationSettled, setOrientationSettled] = useState(false); + const requestedSceneRef = useRef(null); + const renderSignal = useSyncExternalStore( + subscribeToShowcaseRenderSignal, + getShowcaseRenderSignal, + getShowcaseRenderSignal, + ); useEffect(() => { if (!SHOWCASE_ENABLED || pairingUrls.length > 0) return; - const readPairingUrls = () => { + const readLaunchRequest = () => { const values = getNativeShowcasePairingUrls(); - if (values.length > 0) setPairingUrls(values); + if (values.length === 0) return; + // The palette rides the same launch request as the pairing URLs, so + // reading it here settles it without a timeout that could expire while + // the request is still on its way. + setRequestedTheme(getNativeShowcaseTheme()); + setThemeRequestSettled(true); + setPairingUrls(values); }; - readPairingUrls(); - const interval = setInterval(readPairingUrls, 250); + readLaunchRequest(); + const interval = setInterval(readLaunchRequest, 250); return () => clearInterval(interval); }, [pairingUrls.length]); @@ -95,13 +124,38 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) const readRequestedScene = () => { const value = getNativeShowcaseScene(); - if (value) setRequestedScene(value); + if (!value || requestedSceneRef.current === value) return; + requestedSceneRef.current = value; + // A native draw belongs only to the scene request that produced it. In + // particular, revisiting review must wait for its newly mounted surface. + clearShowcaseRenderSignal(); + setRequestedScene(value); }; readRequestedScene(); const interval = setInterval(readRequestedScene, 250); return () => clearInterval(interval); }, []); + // Captures pick a palette for both color schemes so the requested theme is + // used whichever system appearance the runner set on the device. + const themeApplied = + requestedTheme === null + ? themeRequestSettled + : themeIds.light === requestedTheme && themeIds.dark === requestedTheme; + + useEffect(() => { + if ( + !SHOWCASE_ENABLED || + requestedTheme === null || + themeApplied || + // Writing before stored preferences load would be overwritten by them. + !appearancePreferencesReady + ) { + return; + } + setThemeIdForBothAppearances(requestedTheme); + }, [appearancePreferencesReady, requestedTheme, setThemeIdForBothAppearances, themeApplied]); + useEffect(() => { if (!SHOWCASE_ENABLED || pairingUrls.length === 0) return; let cancelled = false; @@ -219,17 +273,14 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) !hasFixture || // Never report a scene ready while the capture orientation is still // being applied — a screenshot taken early has the wrong dimensions. - !orientationSettled + !orientationSettled || + // Likewise for the palette: an early screenshot shows the default theme. + !themeApplied || + !isShowcaseNativeContentReady({ scene, themeId, renderSignal }) ) { setReadyScene(null); return; } - // Review owns its readiness marker because route activation happens before - // the VCS request is parsed and the native diff surface is mounted. - if (scene === "review") { - setReadyScene(null); - return; - } if (scene === "terminal") Keyboard.dismiss(); let renderFrame: number | null = null; @@ -247,7 +298,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) if (renderFrame !== null) cancelAnimationFrame(renderFrame); if (readyFrame !== null) cancelAnimationFrame(readyFrame); }; - }, [hasFixture, orientationSettled, requestedScene, scene]); + }, [hasFixture, orientationSettled, renderSignal, requestedScene, scene, themeApplied, themeId]); if (!SHOWCASE_ENABLED || readyScene === null) return null; diff --git a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts index 07ca60cf5..116182919 100644 --- a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts +++ b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts @@ -1,5 +1,7 @@ import { requireOptionalNativeModule } from "expo"; +import { MOBILE_THEME_IDS, type MobileThemeId } from "../../lib/mobileTheme"; + export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const; export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; @@ -8,6 +10,7 @@ export type ShowcaseOrientation = "portrait" | "landscape"; interface NativeShowcaseControls { readonly getShowcasePairingUrl?: () => string | null; readonly getShowcaseScene?: () => string | null; + readonly getShowcaseTheme?: () => string | null; readonly getShowcaseOrientation?: () => string | null; readonly applyShowcaseOrientation?: (orientation: ShowcaseOrientation) => Promise; readonly getInterfaceOrientation?: () => Promise; @@ -56,6 +59,20 @@ export function getNativeShowcaseScene(): ShowcaseScene | null { } } +/** + * Returns null when the runner requested no palette, which leaves the stored + * theme preference untouched. An unknown id also reads as null rather than + * silently falling back, so a capture never claims to show a theme it does not. + */ +export function getNativeShowcaseTheme(): MobileThemeId | null { + try { + const theme = nativeShowcaseControls()?.getShowcaseTheme?.()?.trim(); + return MOBILE_THEME_IDS.find((candidate) => candidate === theme) ?? null; + } catch { + return null; + } +} + export function prepareNativeShowcaseCapture(): void { try { nativeShowcaseControls()?.prepareShowcaseCapture?.(); diff --git a/apps/mobile/src/features/showcase/showcaseRenderSignal.test.ts b/apps/mobile/src/features/showcase/showcaseRenderSignal.test.ts new file mode 100644 index 000000000..fdf044e77 --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseRenderSignal.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + clearShowcaseRenderSignal, + getShowcaseRenderSignal, + isShowcaseNativeContentReady, + reportShowcaseSceneRendered, + subscribeToShowcaseRenderSignal, +} from "./showcaseRenderSignal"; + +afterEach(clearShowcaseRenderSignal); + +describe("showcase native content readiness", () => { + it("does not gate scenes whose content is rendered by React Native", () => { + expect( + isShowcaseNativeContentReady({ scene: "environments", themeId: "grove", renderSignal: null }), + ).toBe(true); + }); + + it("waits for the native review surface to draw the active theme", () => { + expect( + isShowcaseNativeContentReady({ scene: "review", themeId: "grove", renderSignal: null }), + ).toBe(false); + expect( + isShowcaseNativeContentReady({ + scene: "review", + themeId: "grove", + renderSignal: { scene: "review", themeId: "ocean" }, + }), + ).toBe(false); + expect( + isShowcaseNativeContentReady({ + scene: "review", + themeId: "grove", + renderSignal: { scene: "review", themeId: "grove" }, + }), + ).toBe(true); + }); + + it("clears a draw when the runner requests another scene", () => { + const listener = vi.fn(); + const unsubscribe = subscribeToShowcaseRenderSignal(listener); + + reportShowcaseSceneRendered({ scene: "review", themeId: "iris" }); + expect(getShowcaseRenderSignal()).toEqual({ scene: "review", themeId: "iris" }); + clearShowcaseRenderSignal(); + expect(getShowcaseRenderSignal()).toBeNull(); + expect(listener).toHaveBeenCalledTimes(2); + + unsubscribe(); + }); +}); diff --git a/apps/mobile/src/features/showcase/showcaseRenderSignal.ts b/apps/mobile/src/features/showcase/showcaseRenderSignal.ts new file mode 100644 index 000000000..014f08f78 --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseRenderSignal.ts @@ -0,0 +1,39 @@ +import type { MobileThemeId } from "../../lib/mobileTheme"; +import type { ShowcaseScene } from "./nativeShowcaseScene"; + +export type ShowcaseRenderSignal = Readonly<{ + scene: ShowcaseScene; + themeId: MobileThemeId; +}>; + +const listeners = new Set<() => void>(); +let renderSignal: ShowcaseRenderSignal | null = null; + +export function getShowcaseRenderSignal(): ShowcaseRenderSignal | null { + return renderSignal; +} + +export function subscribeToShowcaseRenderSignal(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function reportShowcaseSceneRendered(signal: ShowcaseRenderSignal): void { + renderSignal = signal; + for (const listener of listeners) listener(); +} + +export function clearShowcaseRenderSignal(): void { + if (renderSignal === null) return; + renderSignal = null; + for (const listener of listeners) listener(); +} + +export function isShowcaseNativeContentReady(input: { + readonly scene: ShowcaseScene; + readonly themeId: MobileThemeId; + readonly renderSignal: ShowcaseRenderSignal | null; +}): boolean { + if (input.scene !== "review") return true; + return input.renderSignal?.scene === "review" && input.renderSignal.themeId === input.themeId; +} diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx index b205b4df7..37dec1fe4 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx @@ -7,18 +7,18 @@ import { type LayoutChangeEvent, type NativeSyntheticEvent, type ViewProps, - useColorScheme, } from "react-native"; import { AppText as Text } from "../../components/AppText"; import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { getNativeTerminalHardwareKeyRevision, resolveNativeTerminalSurfaceView, } from "./nativeTerminalModule"; import { buildGhosttyThemeConfig, - getPierreTerminalTheme, + getMobileTerminalTheme, type TerminalTheme, } from "./terminalTheme"; import { terminalDebugLog } from "./terminalDebugLog"; @@ -60,8 +60,8 @@ function estimateGridSize(input: { const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: TerminalSurfaceProps) { const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize; const inputRef = useRef(null); - const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; - const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme); + const { themeAppearance, themeId } = useAppearancePreferences(); + const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance); const statusLabel = props.isRunning ? "Native terminal unavailable. Using text fallback." : "Open terminal to start a shell."; @@ -173,8 +173,8 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) { const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize; - const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; - const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme); + const { themeAppearance, themeId } = useAppearancePreferences(); + const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance); const { onInput, onResize } = props; const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView(); const hasNativeSurface = Boolean(NativeTerminalSurfaceView); @@ -215,7 +215,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf return ( ({ cols: DEFAULT_TERMINAL_COLS, @@ -214,13 +216,13 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( } return ( - - + + - + Terminal - + {nativeTerminalAvailable ? "Native Ghostty surface" : "Text fallback active"} @@ -231,10 +233,10 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( ) : null} - + diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index a80d90d82..f370401e8 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -5,7 +5,7 @@ import { SymbolView } from "../../components/AppSymbol"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Platform, Pressable, View, useColorScheme } from "react-native"; +import { Platform, Pressable, View } from "react-native"; import { KeyboardController, KeyboardEvents, @@ -44,7 +44,7 @@ import { useSelectedThreadDetail } from "../../state/use-thread-detail"; import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { TerminalSurface } from "./NativeTerminalSurface"; -import { getPierreTerminalTheme } from "./terminalTheme"; +import { getMobileTerminalTheme } from "./terminalTheme"; import { terminalDebugLog } from "./terminalDebugLog"; import { getTerminalBufferReplayKey, @@ -166,7 +166,6 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close"); const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); - const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; const { state: workspaceState } = useWorkspaceState(); const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); const params = props.route.params; @@ -186,6 +185,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) const { isReady: hasResolvedFontPreference, appearance, + themeAppearance: appearanceScheme, + themeId, setTerminalFontSize, } = useAppearancePreferences(); const fontSize = appearance.terminalFontSize; @@ -466,7 +467,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) [selectedEnvironmentConnection?.environmentLabel], ); - const terminalTheme = getPierreTerminalTheme(appearanceScheme); + const terminalTheme = getMobileTerminalTheme(themeId, appearanceScheme); const usesNativeHeaderGlass = Platform.OS === "ios"; const pendingModifier = pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null; @@ -1228,6 +1229,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) onResize={handleResize} style={{ flex: 1 }} terminalKey={terminalKey} + theme={terminalTheme} /> diff --git a/apps/mobile/src/features/terminal/terminalTheme.test.ts b/apps/mobile/src/features/terminal/terminalTheme.test.ts index 3bf37b2ea..24edb384b 100644 --- a/apps/mobile/src/features/terminal/terminalTheme.test.ts +++ b/apps/mobile/src/features/terminal/terminalTheme.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vite-plus/test"; +import { BUILT_IN_THEMES, getThemeColorsForAppearance } from "@t3tools/shared/themePalettes"; -import { buildGhosttyThemeConfig, getPierreTerminalTheme } from "./terminalTheme"; +import { themeColorToNativeColor } from "../../lib/mobileTheme"; + +import { + buildGhosttyThemeConfig, + getMobileTerminalTheme, + getPierreTerminalTheme, +} from "./terminalTheme"; describe("getPierreTerminalTheme", () => { it("returns the Pierre light terminal palette", () => { @@ -22,6 +29,33 @@ describe("getPierreTerminalTheme", () => { }); }); +describe("getMobileTerminalTheme", () => { + it("preserves the Pierre terminal for the default theme", () => { + for (const scheme of ["light", "dark"] as const) { + expect(getMobileTerminalTheme("t3-code", scheme)).toEqual(getPierreTerminalTheme(scheme)); + } + }); + + it("applies the selected palette without replacing ANSI status colors", () => { + const standard = getMobileTerminalTheme("t3-code", "dark"); + const ocean = getMobileTerminalTheme("ocean", "dark"); + + expect(ocean.background).not.toBe(standard.background); + expect(ocean.cursorForeground).not.toBe(standard.cursorForeground); + expect(ocean.palette).toEqual(standard.palette); + }); + + it("uses the canonical desktop terminal roles for built-in themes", () => { + const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === "ocean")!; + const colors = getThemeColorsForAppearance(theme, "dark")!; + const terminal = getMobileTerminalTheme("ocean", "dark"); + + expect(terminal.background).toBe(themeColorToNativeColor(colors.terminalBackground)); + expect(terminal.foreground).toBe(themeColorToNativeColor(colors.terminalForeground)); + expect(terminal.cursorForeground).toBe(themeColorToNativeColor(colors.terminalCursor)); + }); +}); + describe("buildGhosttyThemeConfig", () => { it("serializes theme colors into a ghostty config file", () => { const config = buildGhosttyThemeConfig(getPierreTerminalTheme("dark")); diff --git a/apps/mobile/src/features/terminal/terminalTheme.ts b/apps/mobile/src/features/terminal/terminalTheme.ts index c5ebd10b6..9a9130225 100644 --- a/apps/mobile/src/features/terminal/terminalTheme.ts +++ b/apps/mobile/src/features/terminal/terminalTheme.ts @@ -1,3 +1,11 @@ +import { BUILT_IN_THEMES, getThemeColorsForAppearance } from "@t3tools/shared/themePalettes"; + +import { + getMobileThemeVariables, + themeColorToNativeColor, + type MobileThemeId, +} from "../../lib/mobileTheme"; + export type TerminalAppearanceScheme = "light" | "dark"; export interface TerminalTheme { @@ -70,6 +78,28 @@ export function getPierreTerminalTheme(scheme: TerminalAppearanceScheme): Termin return scheme === "light" ? PIERRE_LIGHT_THEME : PIERRE_DARK_THEME; } +export function getMobileTerminalTheme( + themeId: MobileThemeId, + scheme: TerminalAppearanceScheme, +): TerminalTheme { + const base = getPierreTerminalTheme(scheme); + if (themeId === "t3-code") return base; + + const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0]; + const palette = getThemeColorsForAppearance(theme, scheme) ?? theme.colors; + const colors = getMobileThemeVariables(themeId, scheme); + const background = themeColorToNativeColor(palette.terminalBackground); + return { + ...base, + background, + foreground: themeColorToNativeColor(palette.terminalForeground), + mutedForeground: colors["--color-foreground-muted"], + border: colors["--color-border"], + cursorForeground: themeColorToNativeColor(palette.terminalCursor), + cursorBackground: background, + }; +} + export function buildGhosttyThemeConfig(theme: TerminalTheme): string { const lines = [ `background = ${theme.background}`, diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index 17758721f..0eea51719 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -2,11 +2,12 @@ import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger"; import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts"; import { SymbolView } from "../../components/AppSymbol"; import { memo } from "react"; -import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native"; +import { Pressable, ScrollView, View, type ViewStyle } from "react-native"; import { AppText as Text } from "../../components/AppText"; import { GlassSurface } from "../../components/GlassSurface"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; +import { useThemeColor } from "../../lib/useThemeColor"; export type ComposerCommandItem = | { readonly id: string; @@ -45,11 +46,8 @@ interface ComposerCommandPopoverProps { readonly onSelect: (item: ComposerCommandItem) => void; } -function PopoverSurface(props: { - readonly children: React.ReactNode; - readonly isDarkMode: boolean; - readonly style?: ViewStyle; -}) { +function PopoverSurface(props: { readonly children: React.ReactNode; readonly style?: ViewStyle }) { + const tintColor = useThemeColor("--color-glass-surface"); const baseStyle: ViewStyle = { borderRadius: 16, overflow: "hidden", @@ -57,11 +55,7 @@ function PopoverSurface(props: { }; return ( - + {props.children} ); @@ -114,7 +108,8 @@ const CommandRow = memo(function CommandRow(props: { readonly isLast: boolean; }) { const iconName = itemIcon(props.item); - const iconColor = "#a1a1aa"; + const iconColor = useThemeColor("--color-icon-subtle"); + const borderColor = useThemeColor("--color-border"); return ( {props.item.type === "path" ? ( @@ -139,7 +134,7 @@ const CommandRow = memo(function CommandRow(props: { {props.item.label} {props.item.description ? ( - + {props.item.description} ) : null} @@ -150,11 +145,10 @@ const CommandRow = memo(function CommandRow(props: { export const ComposerCommandPopover = memo(function ComposerCommandPopover( props: ComposerCommandPopoverProps, ) { - const isDarkMode = useColorScheme() === "dark"; const label = groupLabel(props.triggerKind); return ( - + {label ? ( diff --git a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx index bc4157035..8aeadc95c 100644 --- a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx +++ b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx @@ -2,7 +2,7 @@ import * as Haptics from "expo-haptics"; import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; import { SymbolView } from "../../components/AppSymbol"; import { useCallback, useEffect, useRef } from "react"; -import { ActivityIndicator, Pressable, StyleSheet, useColorScheme, View } from "react-native"; +import { ActivityIndicator, Pressable, StyleSheet, View } from "react-native"; import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -11,6 +11,7 @@ import { APP_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { useThemeColor } from "../../lib/useThemeColor"; import type { GitActionProgress } from "../../state/use-vcs-action-state"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; const OVERLAY_LAYOUT_TRANSITION = LinearTransition.duration(220); const OVERLAY_TOP_GAP = 8; @@ -69,7 +70,8 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) { const iconColor = useThemeColor("--color-icon"); const glassBorder = useThemeColor("--color-header-border"); const glassTint = useThemeColor("--color-glass-tint"); - const isDarkMode = useColorScheme() === "dark"; + const { themeAppearance } = useAppearancePreferences(); + const isDarkMode = themeAppearance === "dark"; const content = ( <> diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index 68bf0d05c..97bb2ab98 100644 --- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -13,7 +13,6 @@ import { Platform, Pressable, ScrollView, - Switch, TextInput, View, } from "react-native"; @@ -22,6 +21,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; +import { ThemedSwitch } from "../../components/ThemedSwitch"; import { cn } from "../../lib/cn"; import { useFontFamily } from "../../lib/useFontFamily"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -97,7 +97,7 @@ function ToggleRow(props: { {props.title} - state.isVisible); const controlsBottomPadding = Math.max(insets.bottom, 10); const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8); @@ -290,11 +292,12 @@ export function NewTaskDraftScreen(props: { }, [props.pendingTaskId, cancelEditingPendingTask]); const foregroundColor = useThemeColor("--color-foreground"); + const sheetColor = String(useThemeColor("--color-sheet")); const projectUnderlineColor = useThemeColor("--color-foreground-muted"); const regularFontFamily = useFontFamily("regular"); const bodyText = useScaledTextRole("body"); - const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; - const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)"; + const sheetFadeOpaque = sheetColor; + const sheetFadeTransparent = themeColorWithAlpha(sheetColor, 0); // A new navigation to this mounted screen delivers a fresh initialProjectRef // reference — treat it as a new request and let it apply again. diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index a36c8421b..16e31e587 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -76,7 +76,6 @@ import { Platform, Pressable, StyleSheet, - useColorScheme, View, type ViewStyle, } from "react-native"; @@ -90,6 +89,7 @@ import Animated, { } from "react-native-reanimated"; import { useThemeColor } from "../../lib/useThemeColor"; import { presentMobileContextWindow } from "../../lib/contextWindow"; +import { themeColorWithAlpha } from "../../lib/mobileTheme"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { scopedThreadKey } from "../../lib/scopedEntities"; @@ -118,6 +118,7 @@ import { showModelSelectionInteractionModeToggle, } from "../../lib/modelOptions"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import type { RemoteClientConnectionState } from "../../lib/connection"; import { insertRankedSearchResult, @@ -267,11 +268,14 @@ export function ComposerSurface(props: { /** Existing thread composers morph between pill and card layouts. */ readonly animateLayout?: boolean; }) { + const cardColor = useThemeColor("--color-card-translucent"); + const borderColor = useThemeColor("--color-border"); + const shadowColor = useThemeColor("--color-primary-shadow"); // Drop shadow lives on a wrapper: `overflow: "hidden"` on the surface itself // (needed to clip content to the pill shape) would clip the shadow on iOS. const shadowStyle: ViewStyle = { borderRadius: props.style.borderRadius, - shadowColor: "#000000", + shadowColor, shadowOpacity: props.isDarkMode ? 0.35 : 0.12, shadowRadius: 14, shadowOffset: { width: 0, height: 6 }, @@ -286,9 +290,9 @@ export function ComposerSurface(props: { {isReconnecting ? ( - + ) : ( )} @@ -427,7 +432,8 @@ const ContextWindowIndicator = memo(function ContextWindowIndicator(props: { export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { const navigation = useNavigation(); - const isDarkMode = useColorScheme() === "dark"; + const { themeAppearance } = useAppearancePreferences(); + const isDarkMode = themeAppearance === "dark"; const foregroundColor = useThemeColor("--color-foreground"); const bodyText = useScaledTextRole("body"); const fallbackInputRef = useRef(null); @@ -502,8 +508,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer environmentLabel: props.environmentLabel, threadSyncPhase: props.threadSyncPhase, }); - const toolbarFadeOpaque = isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)"; - const toolbarFadeTransparent = isDarkMode ? "rgba(0,0,0,0)" : "rgba(255,255,255,0)"; + const toolbarSurface = String(useThemeColor("--color-card")); + const backdropSurface = String(useThemeColor("--color-screen")); + const toolbarFadeOpaque = themeColorWithAlpha(toolbarSurface, 0.95); + const toolbarFadeTransparent = themeColorWithAlpha(toolbarSurface, 0); + const backdropGradient = `linear-gradient(to bottom, ${themeColorWithAlpha(backdropSurface, 0)} 0%, ${themeColorWithAlpha(backdropSurface, 0.6)} 55%, ${themeColorWithAlpha(backdropSurface, 0.9)} 100%)`; const selectedProviderStatus = useMemo(() => { if (!props.serverConfig) return null; return ( @@ -1717,9 +1726,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer style={[ StyleSheet.absoluteFill, { - experimental_backgroundImage: isDarkMode - ? "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.6) 55%, rgba(0,0,0,0.9) 100%)" - : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.6) 55%, rgba(255,255,255,0.9) 100%)", + experimental_backgroundImage: backdropGradient, }, ]} /> diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 7f575cc86..9a6e440e3 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -44,7 +44,6 @@ import { AppState, Keyboard, Platform, - useColorScheme, useWindowDimensions, View, type GestureResponderEvent, @@ -65,6 +64,7 @@ import Animated, { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ControlPill } from "../../components/ControlPill"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; @@ -620,7 +620,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread }, [freeze, scrollMessageToEnd]); const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled; - const isDarkMode = useColorScheme() === "dark"; + const { themeAppearance } = useAppearancePreferences(); + const isDarkMode = themeAppearance === "dark"; const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { feedTouchStartRef.current = { diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index a4b554dbd..183bd1a80 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -37,7 +37,6 @@ import { StyleSheet, Text as NativeText, type ColorValue, - useColorScheme, useWindowDimensions, View, } from "react-native"; @@ -194,43 +193,6 @@ function MessageAttachmentImage(props: { ); } -const MARKDOWN_COLORS = { - light: { - body: "#111111", - strong: "#000000", - link: "#2563eb", - blockquoteBorder: "rgba(0, 0, 0, 0.08)", - blockquoteBackground: "rgba(0, 0, 0, 0.02)", - codeBackground: "rgba(0, 0, 0, 0.04)", - codeText: "#262626", - inlineCodeText: "#5f6368", - horizontalRule: "rgba(0, 0, 0, 0.08)", - userBody: "#ffffff", - userCodeBackground: "rgba(255, 255, 255, 0.22)", - userCodeText: "#ffffff", - userInlineCodeText: "rgba(255, 255, 255, 0.82)", - userFenceBackground: "rgba(0, 0, 0, 0.16)", - userFenceText: "#ffffff", - }, - dark: { - body: "#e5e5e5", - strong: "#f5f5f5", - link: "#60a5fa", - blockquoteBorder: "rgba(255, 255, 255, 0.1)", - blockquoteBackground: "rgba(255, 255, 255, 0.03)", - codeBackground: "rgba(255, 255, 255, 0.06)", - codeText: "#e5e5e5", - inlineCodeText: "#b8bcc2", - horizontalRule: "rgba(255, 255, 255, 0.08)", - userBody: "#ffffff", - userCodeBackground: "rgba(255, 255, 255, 0.18)", - userCodeText: "#ffffff", - userInlineCodeText: "rgba(255, 255, 255, 0.82)", - userFenceBackground: "rgba(0, 0, 0, 0.28)", - userFenceText: "#ffffff", - }, -} as const; - const MARKDOWN_MONO_FONT = Platform.select({ ios: "ui-monospace", android: "monospace", @@ -426,14 +388,12 @@ function MarkdownCodeBlock(props: { } function useReviewCommentColors(): ReviewCommentColors { - const colorScheme = useColorScheme(); - const isDark = colorScheme === "dark"; - const background = isDark ? "#151515" : "#ffffff"; - const border = isDark ? "#2a2a2a" : "#d7d7d7"; - const mutedBackground = isDark ? "#242424" : "#f2f2f2"; - const text = isDark ? "#f3f3f3" : "#111111"; - const mutedText = isDark ? "#8f8f8f" : "#666666"; - const codeBackground = isDark ? "#0f0f0f" : "#ffffff"; + const background = useThemeColor("--color-card"); + const border = useThemeColor("--color-border"); + const mutedBackground = useThemeColor("--color-subtle"); + const text = useThemeColor("--color-foreground"); + const mutedText = useThemeColor("--color-foreground-muted"); + const codeBackground = useThemeColor("--color-md-code-bg"); return useMemo( () => ({ @@ -449,8 +409,7 @@ function useReviewCommentColors(): ReviewCommentColors { } function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets { - const colorScheme = useColorScheme(); - const { appearance } = useAppearancePreferences(); + const { appearance, themeAppearance } = useAppearancePreferences(); const markdownFontSizes = useMemo( () => resolveMarkdownFontSizes(appearance.baseFontSize), [appearance.baseFontSize], @@ -459,31 +418,30 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe () => resolveNativeMarkdownTypography(appearance.baseFontSize), [appearance.baseFontSize], ); - const themeMode = colorScheme === "dark" ? "dark" : "light"; - const colors = MARKDOWN_COLORS[themeMode]; + const themeMode = themeAppearance; + const markdownBodyColor = String(useThemeColor("--color-md-body")); + const markdownStrongColor = String(useThemeColor("--color-md-strong")); + const markdownLinkColor = String(useThemeColor("--color-md-link")); + const markdownBlockquoteBg = String(useThemeColor("--color-md-blockquote-bg")); + const markdownBlockquoteBorder = String(useThemeColor("--color-md-blockquote-border")); + const markdownCodeBg = String(useThemeColor("--color-md-code-bg")); + const markdownCodeText = String(useThemeColor("--color-md-code-text")); + const markdownInlineCodeText = String(useThemeColor("--color-foreground-secondary")); + const markdownHrColor = String(useThemeColor("--color-md-hr")); + const markdownUserBodyColor = String(useThemeColor("--color-user-bubble-foreground")); + const markdownUserCodeBg = String(useThemeColor("--color-md-user-code-bg")); + const markdownUserCodeText = String(useThemeColor("--color-md-user-code-text")); + const markdownUserInlineCodeText = String(useThemeColor("--color-user-bubble-foreground-muted")); + const markdownUserFenceBg = String(useThemeColor("--color-md-user-fence-bg")); + const markdownUserFenceText = String(useThemeColor("--color-md-user-fence-text")); const iconSubtleColor = String(useThemeColor("--color-icon-subtle")); const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground")); + const userBubbleSkillForeground = String(useThemeColor("--color-user-bubble-skill-foreground")); const userBubbleForegroundMuted = String(useThemeColor("--color-user-bubble-foreground-muted")); const regularFontFamily = useFontFamily("regular"); const boldFontFamily = useFontFamily("bold"); return useMemo(() => { - const markdownBodyColor = colors.body; - const markdownStrongColor = colors.strong; - const markdownLinkColor = colors.link; - const markdownBlockquoteBg = colors.blockquoteBackground; - const markdownBlockquoteBorder = colors.blockquoteBorder; - const markdownCodeBg = colors.codeBackground; - const markdownCodeText = colors.codeText; - const markdownInlineCodeText = colors.inlineCodeText; - const markdownHrColor = colors.horizontalRule; - const markdownUserBodyColor = colors.userBody; - const markdownUserCodeBg = colors.userCodeBackground; - const markdownUserCodeText = colors.userCodeText; - const markdownUserInlineCodeText = colors.userInlineCodeText; - const markdownUserFenceBg = colors.userFenceBackground; - const markdownUserFenceText = colors.userFenceText; - const baseTheme: PartialMarkdownTheme = { colors: { text: markdownBodyColor, @@ -759,8 +717,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe codeColor: markdownUserCodeText, codeBackgroundColor: markdownUserCodeBg, codeBlockBackgroundColor: markdownUserFenceBg, - fileTextColor: "#ffffff", - skillTextColor: "#f0abfc", + fileTextColor: markdownUserBodyColor, + skillTextColor: userBubbleSkillForeground, quoteMarkerColor: markdownUserBodyColor, dividerColor: markdownUserBodyColor, fontSize: nativeMarkdownTypography.fontSize, @@ -807,15 +765,30 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe }; }, [ boldFontFamily, - colors, iconSubtleColor, inlineSkillForeground, + markdownBlockquoteBg, + markdownBlockquoteBorder, + markdownBodyColor, + markdownCodeBg, + markdownCodeText, markdownFontSizes, + markdownHrColor, + markdownInlineCodeText, + markdownLinkColor, + markdownStrongColor, + markdownUserBodyColor, + markdownUserCodeBg, + markdownUserCodeText, + markdownUserFenceBg, + markdownUserFenceText, + markdownUserInlineCodeText, nativeMarkdownTypography, onLinkPress, regularFontFamily, themeMode, userBubbleForegroundMuted, + userBubbleSkillForeground, ]); } @@ -1149,8 +1122,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { readonly colors: ReviewCommentColors; }) { const { codeSurface, nativeReviewDiffStyle } = useAppearanceCodeSurface(); - const colorScheme = useColorScheme(); - const appearanceScheme = colorScheme === "light" ? "light" : "dark"; + const { themeAppearance: appearanceScheme, themeId } = useAppearancePreferences(); const NativeReviewDiffView = resolveNativeReviewDiffView(); const patch = useMemo(() => buildReviewCommentPatch(props.comment), [props.comment]); const parsedDiff = useMemo( @@ -1163,8 +1135,8 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { [nativeReviewDiffData.rows], ); const nativeReviewDiffTheme = useMemo( - () => createNativeReviewDiffTheme(appearanceScheme), - [appearanceScheme], + () => createNativeReviewDiffTheme(appearanceScheme, themeId), + [appearanceScheme, themeId], ); const nativeRowsJson = useMemo(() => JSON.stringify(compactNativeRows), [compactNativeRows]); const nativeThemeJson = useMemo( diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 12e974fe8..007778c0a 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -15,7 +15,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native"; -import { Platform, Pressable, StyleSheet, TextInput, View, useColorScheme } from "react-native"; +import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -38,6 +38,7 @@ import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { hasCustomHomeListOptions, PROJECT_SORT_OPTIONS, @@ -102,6 +103,8 @@ function SidebarHeaderButtonGroup(props: { readonly children: ReactNode; readonly colorScheme: "light" | "dark"; }) { + const fallbackBackground = useThemeColor("--color-glass-surface"); + const fallbackBorder = useThemeColor("--color-header-border"); if (isLiquidGlassSupported) { return ( @@ -192,7 +193,7 @@ function ThreadNavigationSidebarPane( props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean }, ) { const insets = useSafeAreaInsets(); - const colorScheme = useColorScheme() === "dark" ? "dark" : "light"; + const { themeAppearance: colorScheme } = useAppearancePreferences(); const projects = useProjects(); const threads = useThreadShells(); const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 076646715..f8237baac 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -27,7 +27,7 @@ import { useState, type ReactNode, } from "react"; -import { Platform, Pressable, ScrollView, Switch, TextInput, View } from "react-native"; +import { Platform, Pressable, ScrollView, TextInput, View } from "react-native"; import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -35,6 +35,7 @@ import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { AndroidSheetHeader } from "../../components/AndroidScreenHeader"; import { ProviderIcon } from "../../components/ProviderIcon"; +import { ThemedSwitch } from "../../components/ThemedSwitch"; import { cn } from "../../lib/cn"; import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; import { applyProviderOptionSelection } from "../../lib/providerOptions"; @@ -46,10 +47,6 @@ import { nativeHeaderScrollEdgeEffects, } from "../../native/StackHeader"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; -import { - NATIVE_SHEET_SURFACE_COLOR, - NATIVE_SHEET_SURFACE_CONTENT_STYLE, -} from "../../native/sheet-surface"; import { useNewTaskFlow } from "./new-task-flow-provider"; import { createNativeMailSearchToolbarItem, @@ -283,7 +280,7 @@ function SwitchRow(props: { )} > {props.label} - ({ onClose: props.onClose, @@ -1177,17 +1173,13 @@ function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation) initialRouteName="ThreadSettingsModels" screenOptions={{ animation: "slide_from_right", - contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE ?? { - backgroundColor: nativeSheetBackground, - }, + contentStyle: { backgroundColor: solidSheetBackground }, gestureEnabled: true, headerBackButtonDisplayMode: "minimal", headerBackTitle: "", headerShadowVisible: false, headerStyle: { - backgroundColor: (NATIVE_LIQUID_GLASS_SUPPORTED - ? "transparent" - : nativeSheetBackground) as unknown as string, + backgroundColor: NATIVE_LIQUID_GLASS_SUPPORTED ? "transparent" : solidSheetBackground, }, headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, headerTintColor: foreground, diff --git a/apps/mobile/src/features/threads/sidebar-filter-button.tsx b/apps/mobile/src/features/threads/sidebar-filter-button.tsx index b1afe594f..0c33da436 100644 --- a/apps/mobile/src/features/threads/sidebar-filter-button.tsx +++ b/apps/mobile/src/features/threads/sidebar-filter-button.tsx @@ -1,5 +1,5 @@ import { SymbolView } from "../../components/AppSymbol"; -import { Pressable, StyleSheet, useColorScheme } from "react-native"; +import { Pressable, StyleSheet } from "react-native"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -15,10 +15,8 @@ export function SidebarFilterButton(props: { }) { const iconColor = useThemeColor("--color-foreground"); const pressedBackgroundColor = useThemeColor("--color-subtle"); - const colorScheme = useColorScheme() === "dark" ? "dark" : "light"; - const idleBackgroundColor = - colorScheme === "dark" ? "rgba(118,118,128,0.24)" : "rgba(255,255,255,0.72)"; - const borderColor = colorScheme === "dark" ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)"; + const idleBackgroundColor = useThemeColor("--color-glass-surface"); + const borderColor = useThemeColor("--color-header-border"); return ( - + , -) { +function pullRequestTintColor(state: ThreadPr["state"], colorScheme: "light" | "dark") { const dark = colorScheme === "dark"; switch (state) { case "open": @@ -440,7 +439,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { >["simultaneousWithExternalGesture"]; }) { const { width: windowWidth } = useWindowDimensions(); - const colorScheme = useColorScheme(); + const { themeAppearance: colorScheme } = useAppearancePreferences(); const compact = props.variant === "compact"; const selected = props.selected === true; // Recycling-safe: resets when the list container is reused for another @@ -453,6 +452,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const drawerColor = useThemeColor("--color-drawer"); const pressedBackgroundColor = useThemeColor("--color-subtle"); const selectedBackgroundColor = useThemeColor("--color-user-bubble"); + const selectedForegroundColor = useThemeColor("--color-user-bubble-foreground"); const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } = props; @@ -467,10 +467,16 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ); const backgroundColor = compact ? screenColor : drawerColor; - const effectivePressedBackground = selected ? "rgba(255,255,255,0.16)" : pressedBackgroundColor; + const effectivePressedBackground = selected + ? themeColorWithAlpha(String(selectedForegroundColor), 0.16) + : pressedBackgroundColor; const effectiveStatus = selected && status - ? { ...status, pillClassName: "bg-white/20", textClassName: "text-white" } + ? { + ...status, + pillClassName: "bg-user-bubble-foreground/20", + textClassName: "text-user-bubble-foreground", + } : status; const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); @@ -538,11 +544,15 @@ export const ThreadListRow = memo(function ThreadListRow(props: { {pr.label} 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 1c25f949e..86d442dfe 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -6,14 +6,7 @@ import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import type { MenuAction } from "@react-native-menu/menu"; import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; -import { - Alert, - Platform, - Pressable, - useColorScheme, - useWindowDimensions, - View, -} from "react-native"; +import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { SymbolView } from "../../components/AppSymbol"; @@ -27,6 +20,7 @@ import { useThemeColor } from "../../lib/useThemeColor"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr } from "../../state/use-thread-pr"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; import { resolveThreadListV2SnoozeMenuSelection, @@ -120,7 +114,7 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS readonly onToggle: () => void; readonly pane?: "screen" | "sidebar"; }) { - const colorScheme = useColorScheme(); + const { themeAppearance: colorScheme } = useAppearancePreferences(); return ( @@ -689,7 +683,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { {statusLabel?.label ?? timeLabel} @@ -766,7 +762,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { {pr ? ( #{pr.label} diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 529adac1d..a5adacb8d 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -1,12 +1,13 @@ import * as Haptics from "expo-haptics"; import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; -import { LayoutAnimation, Pressable, ScrollView, useColorScheme, View } from "react-native"; +import { LayoutAnimation, Pressable, ScrollView, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; import { scaledTypographyLineHeight } from "../../lib/appearancePreferences"; import { cn } from "../../lib/cn"; import type { ThreadFeedActivity } from "../../lib/threadActivity"; import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; +import { useThemeColor } from "../../lib/useThemeColor"; import Animated, { FadeIn } from "react-native-reanimated"; const WORK_LOG_LAYOUT_ANIMATION = { @@ -127,8 +128,7 @@ export function ThreadWorkLog(props: { readonly onCopyRow: (rowId: string, value: string) => void; readonly onToggleRow: (rowId: string) => void; }) { - const colorScheme = useColorScheme(); - const pressedBackground = colorScheme === "dark" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.035)"; + const pressedBackground = useThemeColor("--color-subtle"); const rows = visibleWorkLogActivities(props.activities).map((activity) => ({ ...activity, detail: compactActivityDetail(activity.detail), @@ -281,8 +281,7 @@ export function ThreadWorkGroupToggle(props: { readonly onlyToolActivities: boolean; readonly onToggle: () => void; }) { - const colorScheme = useColorScheme(); - const pressedBackground = colorScheme === "dark" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.035)"; + const pressedBackground = useThemeColor("--color-subtle"); const noun = props.onlyToolActivities ? props.hiddenCount === 1 ? "tool call" diff --git a/apps/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts index 3e2d027a9..9a9ec5f22 100644 --- a/apps/mobile/src/features/usage/usageProviders.ts +++ b/apps/mobile/src/features/usage/usageProviders.ts @@ -1,5 +1,5 @@ import type { UsageProviderKind } from "@t3tools/contracts"; -import { useColorScheme } from "react-native"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; /** * Series and table order. The chart stacks providers from the bottom in this @@ -17,7 +17,7 @@ export const PROVIDER_LABEL: Record = { * with the theme or its bars vanish against the matching background. */ export function useProviderColors(): Record { - const scheme = useColorScheme(); + const { themeAppearance: scheme } = useAppearancePreferences(); return { claude: "#d97757", codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43", diff --git a/apps/mobile/src/lib/mobileDefaultTheme.ts b/apps/mobile/src/lib/mobileDefaultTheme.ts new file mode 100644 index 000000000..e76d7960a --- /dev/null +++ b/apps/mobile/src/lib/mobileDefaultTheme.ts @@ -0,0 +1,139 @@ +import type { MobileThemeVariables } from "./mobileTheme"; + +/** The existing Pylon mobile palette, retained as the upgrade-safe default. */ +export const DEFAULT_MOBILE_THEME_VARIABLES = { + light: { + "--color-screen": "#f2f2f7", + "--color-sheet": "rgba(242, 242, 247, 0.98)", + "--color-sheet-solid": "#f2f2f7", + "--color-card": "#ffffff", + "--color-card-alt": "#f5f5f5", + "--color-card-translucent": "rgba(255, 255, 255, 0.8)", + "--color-foreground": "#262626", + "--color-foreground-secondary": "#525252", + "--color-foreground-muted": "#737373", + "--color-foreground-tertiary": "#8e8e93", + "--color-border": "rgba(0, 0, 0, 0.08)", + "--color-border-subtle": "rgba(0, 0, 0, 0.06)", + "--color-separator": "rgba(0, 0, 0, 0.04)", + "--color-subtle": "rgba(0, 0, 0, 0.04)", + "--color-subtle-strong": "rgba(0, 0, 0, 0.08)", + "--color-inline-skill-background": "rgba(217, 70, 239, 0.12)", + "--color-inline-skill-border": "rgba(217, 70, 239, 0.25)", + "--color-inline-skill-foreground": "#a21caf", + "--color-primary": "#262626", + "--color-primary-foreground": "#ffffff", + "--color-primary-shadow": "#000000", + "--color-secondary": "#ffffff", + "--color-secondary-foreground": "#262626", + "--color-secondary-border": "rgba(0, 0, 0, 0.08)", + "--color-switch-active-track": "#34c759", + "--color-switch-active-thumb": "#ffffff", + "--color-switch-inactive-track": "rgba(0, 0, 0, 0.08)", + "--color-switch-inactive-thumb": "#8e8e93", + "--color-danger": "#fef2f2", + "--color-danger-border": "rgba(239, 68, 68, 0.12)", + "--color-danger-foreground": "#dc2626", + "--color-input": "#ffffff", + "--color-input-border": "rgba(0, 0, 0, 0.1)", + "--color-sidebar-search": "rgba(118, 118, 128, 0.12)", + "--color-placeholder": "#737373", + "--color-icon": "#262626", + "--color-icon-muted": "#525252", + "--color-icon-subtle": "#a3a3a3", + "--color-header": "rgba(255, 255, 255, 0.97)", + "--color-header-border": "rgba(0, 0, 0, 0.06)", + "--color-glass-surface": "rgba(255, 255, 255, 0.72)", + "--color-glass-tint": "rgba(255, 255, 255, 0.18)", + "--color-status-bar": "#f2f2f7", + "--color-md-body": "#111111", + "--color-md-strong": "#000000", + "--color-md-link": "#2563eb", + "--color-md-blockquote-border": "rgba(0, 0, 0, 0.08)", + "--color-md-blockquote-bg": "rgba(0, 0, 0, 0.02)", + "--color-md-code-bg": "rgba(0, 0, 0, 0.04)", + "--color-md-code-text": "#262626", + "--color-md-user-code-bg": "rgba(255, 255, 255, 0.22)", + "--color-md-user-code-text": "#ffffff", + "--color-md-user-fence-bg": "rgba(0, 0, 0, 0.16)", + "--color-md-user-fence-text": "#ffffff", + "--color-md-hr": "rgba(0, 0, 0, 0.08)", + "--color-user-bubble": "#007aff", + "--color-user-bubble-foreground": "#ffffff", + "--color-user-bubble-foreground-muted": "rgba(255, 255, 255, 0.78)", + "--color-user-bubble-skill-foreground": "#f0abfc", + "--color-backdrop": "rgba(0, 0, 0, 0.22)", + "--color-drawer": "rgba(255, 255, 255, 0.99)", + "--color-drawer-shadow": "rgba(0, 0, 0, 0.12)", + "--color-dot-separator": "rgba(0, 0, 0, 0.2)", + "--color-wordmark": "#262626", + "--color-chevron": "rgba(0, 0, 0, 0.2)", + }, + dark: { + "--color-screen": "#0a0a0a", + "--color-sheet": "rgba(14, 14, 14, 0.98)", + "--color-sheet-solid": "#0e0e0e", + "--color-card": "#171717", + "--color-card-alt": "#1c1c1c", + "--color-card-translucent": "rgba(17, 17, 17, 0.8)", + "--color-foreground": "#f5f5f5", + "--color-foreground-secondary": "#a3a3a3", + "--color-foreground-muted": "#8e8e93", + "--color-foreground-tertiary": "#636366", + "--color-border": "rgba(255, 255, 255, 0.06)", + "--color-border-subtle": "rgba(255, 255, 255, 0.04)", + "--color-separator": "rgba(255, 255, 255, 0.03)", + "--color-subtle": "rgba(255, 255, 255, 0.04)", + "--color-subtle-strong": "rgba(255, 255, 255, 0.08)", + "--color-inline-skill-background": "rgba(217, 70, 239, 0.12)", + "--color-inline-skill-border": "rgba(217, 70, 239, 0.25)", + "--color-inline-skill-foreground": "#f0abfc", + "--color-primary": "#f5f5f5", + "--color-primary-foreground": "#0a0a0a", + "--color-primary-shadow": "#000000", + "--color-secondary": "rgba(255, 255, 255, 0.04)", + "--color-secondary-foreground": "#f5f5f5", + "--color-secondary-border": "rgba(255, 255, 255, 0.06)", + "--color-switch-active-track": "#30d158", + "--color-switch-active-thumb": "#ffffff", + "--color-switch-inactive-track": "rgba(255, 255, 255, 0.06)", + "--color-switch-inactive-thumb": "#8e8e93", + "--color-danger": "rgba(239, 68, 68, 0.14)", + "--color-danger-border": "rgba(248, 113, 113, 0.18)", + "--color-danger-foreground": "#fca5a5", + "--color-input": "#141414", + "--color-input-border": "rgba(255, 255, 255, 0.08)", + "--color-sidebar-search": "rgba(118, 118, 128, 0.24)", + "--color-placeholder": "#8e8e93", + "--color-icon": "#f5f5f5", + "--color-icon-muted": "#a3a3a3", + "--color-icon-subtle": "#8e8e93", + "--color-header": "rgba(10, 10, 10, 0.97)", + "--color-header-border": "rgba(255, 255, 255, 0.06)", + "--color-glass-surface": "rgba(23, 23, 23, 0.78)", + "--color-glass-tint": "rgba(23, 23, 23, 0.24)", + "--color-status-bar": "#0a0a0a", + "--color-md-body": "#e5e5e5", + "--color-md-strong": "#f5f5f5", + "--color-md-link": "#60a5fa", + "--color-md-blockquote-border": "rgba(255, 255, 255, 0.1)", + "--color-md-blockquote-bg": "rgba(255, 255, 255, 0.03)", + "--color-md-code-bg": "rgba(255, 255, 255, 0.06)", + "--color-md-code-text": "#e5e5e5", + "--color-md-user-code-bg": "rgba(255, 255, 255, 0.18)", + "--color-md-user-code-text": "#ffffff", + "--color-md-user-fence-bg": "rgba(0, 0, 0, 0.28)", + "--color-md-user-fence-text": "#ffffff", + "--color-md-hr": "rgba(255, 255, 255, 0.08)", + "--color-user-bubble": "#0a84ff", + "--color-user-bubble-foreground": "#ffffff", + "--color-user-bubble-foreground-muted": "rgba(255, 255, 255, 0.78)", + "--color-user-bubble-skill-foreground": "#f0abfc", + "--color-backdrop": "rgba(0, 0, 0, 0.48)", + "--color-drawer": "rgba(14, 14, 14, 0.99)", + "--color-drawer-shadow": "rgba(0, 0, 0, 0.32)", + "--color-dot-separator": "rgba(255, 255, 255, 0.2)", + "--color-wordmark": "#f5f5f5", + "--color-chevron": "rgba(255, 255, 255, 0.2)", + }, +} as const satisfies Readonly>; diff --git a/apps/mobile/src/lib/mobileTheme.test.ts b/apps/mobile/src/lib/mobileTheme.test.ts new file mode 100644 index 000000000..d5744952b --- /dev/null +++ b/apps/mobile/src/lib/mobileTheme.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as NodeFS from "node:fs"; + +import { BUILT_IN_THEME_IDS, BUILT_IN_THEMES } from "@t3tools/shared/themePalettes"; +import { DEFAULT_MOBILE_THEME_VARIABLES } from "./mobileDefaultTheme"; + +import { + createMobileThemePairPatch, + createMobileThemeSelectionPatch, + createMobileThemeVariables, + DEFAULT_MOBILE_THEME_ID, + getMobileThemePreviewColors, + getMobileThemeVariables, + MOBILE_THEME_IDS, + normalizeMobileThemeId, + normalizeMobileThemeMode, + resolveMobileThemeIds, + themeColorWithAlpha, + themeColorToNativeColor, +} from "./mobileTheme"; + +function relativeLuminance(hex: string): number { + const channels = hex + .slice(1) + .match(/.{2}/g)! + .map((channel) => Number.parseInt(channel, 16) / 255) + .map((channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4)); + return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!; +} + +function contrastRatio(first: string, second: string): number { + const firstLuminance = relativeLuminance(first); + const secondLuminance = relativeLuminance(second); + return ( + (Math.max(firstLuminance, secondLuminance) + 0.05) / + (Math.min(firstLuminance, secondLuminance) + 0.05) + ); +} + +function compositeOver(overlay: string, background: string): string { + const overlayMatch = /^rgba\((\d+), (\d+), (\d+), ([\d.]+)\)$/.exec(overlay)!; + const backgroundChannels = background + .slice(1) + .match(/.{2}/g)! + .map((channel) => Number.parseInt(channel, 16)); + const alpha = Number(overlayMatch[4]); + const channels = [1, 2, 3].map((index) => + Math.round(Number(overlayMatch[index]) * alpha + backgroundChannels[index - 1]! * (1 - alpha)), + ); + return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; +} + +describe("mobile themes", () => { + it("declares every runtime theme variable in the static stylesheet", () => { + const stylesheet = NodeFS.readFileSync(new URL("../../global.css", import.meta.url), "utf8"); + const stylesheetVariables = new Set( + Array.from(stylesheet.matchAll(/--color-[a-z0-9-]+/g), ([variable]) => variable), + ); + + expect(Array.from(stylesheetVariables).sort()).toEqual( + Object.keys(DEFAULT_MOBILE_THEME_VARIABLES.light).sort(), + ); + }); + + it("shares all built-in desktop palettes", () => { + expect(BUILT_IN_THEMES.map((theme) => theme.id)).toEqual(BUILT_IN_THEME_IDS); + for (const themeId of BUILT_IN_THEME_IDS) { + expect(getMobileThemeVariables(themeId, "light")["--color-screen"]).toMatch(/^#/); + expect(getMobileThemeVariables(themeId, "dark")["--color-screen"]).toMatch(/^#/); + } + }); + + it("preserves the existing mobile palette as the default", () => { + expect(getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, "light")["--color-screen"]).toBe( + "#f2f2f7", + ); + expect(getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, "dark")["--color-screen"]).toBe( + "#0a0a0a", + ); + expect( + getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, "light")[ + "--color-user-bubble-skill-foreground" + ], + ).toBe("#f0abfc"); + }); + + it("applies palette overrides on top of the selected built-in theme", () => { + const variables = getMobileThemeVariables("ocean", "dark", { + "--color-primary": "#123456", + }); + + expect(variables["--color-primary"]).toBe("#123456"); + expect(variables["--color-screen"]).toMatch(/^#/); + }); + + it("uses the same preview roles and standard artwork as desktop", () => { + expect(getMobileThemePreviewColors(DEFAULT_MOBILE_THEME_ID, "light")).toEqual({ + canvas: "#fcfcfc", + accent: "#f4f4f5", + messageAction: "#4f46e5", + }); + const desktopOcean = BUILT_IN_THEMES.find((theme) => theme.id === "ocean")!; + expect(getMobileThemePreviewColors("ocean", "light")).toEqual({ + canvas: themeColorToNativeColor(desktopOcean.colors.canvas), + accent: themeColorToNativeColor(desktopOcean.colors.accent), + messageAction: themeColorToNativeColor(desktopOcean.colors.messageAction), + }); + }); + + it("normalizes persisted theme preferences", () => { + expect(normalizeMobileThemeId("ocean")).toBe("ocean"); + expect(normalizeMobileThemeId("missing-theme")).toBe(DEFAULT_MOBILE_THEME_ID); + expect(normalizeMobileThemeMode("dark")).toBe("dark"); + expect(normalizeMobileThemeMode("sepia")).toBe("system"); + }); + + it("migrates one theme choice to both appearances and preserves independent choices", () => { + expect(resolveMobileThemeIds({ themeId: "grove" })).toEqual({ + light: "grove", + dark: "grove", + }); + expect( + resolveMobileThemeIds({ themeId: "grove", lightThemeId: "iris", darkThemeId: "ocean" }), + ).toEqual({ light: "iris", dark: "ocean" }); + expect(resolveMobileThemeIds({ themeId: "grove", lightThemeId: "missing" })).toEqual({ + light: DEFAULT_MOBILE_THEME_ID, + dark: "grove", + }); + }); + + it("changes either theme without switching the active appearance", () => { + const themeIds = { light: "t3-chat", dark: "grove" } as const; + expect(createMobileThemeSelectionPatch(themeIds, "light", "dark", "ocean")).toEqual({ + lightThemeId: "t3-chat", + darkThemeId: "ocean", + themeId: "t3-chat", + }); + expect(createMobileThemeSelectionPatch(themeIds, "light", "light", "iris")).toEqual({ + lightThemeId: "iris", + darkThemeId: "grove", + themeId: "iris", + }); + }); + + it("changes both appearance themes from the card action", () => { + expect(createMobileThemePairPatch("ember")).toEqual({ + lightThemeId: "ember", + darkThemeId: "ember", + themeId: "ember", + }); + }); + + it("converts OKLCH colors to React Native sRGB ColorValues", () => { + expect(themeColorToNativeColor("oklch(1 0 0)")).toBe("#ffffff"); + expect(themeColorToNativeColor("oklch(0 0 0)")).toBe("#000000"); + expect(themeColorToNativeColor("#123456")).toBe("#123456"); + }); + + it("changes native palette color opacity for fades", () => { + expect(themeColorWithAlpha("#123456", 0)).toBe("rgba(18, 52, 86, 0)"); + expect(themeColorWithAlpha("rgba(18, 52, 86, 0.98)", 0)).toBe("rgba(18, 52, 86, 0)"); + }); + + it("maps semantic palette roles onto every mobile color variable", () => { + const variables = createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"); + expect(Object.keys(variables)).toHaveLength(65); + expect(variables["--color-sheet-solid"]).toBe( + themeColorToNativeColor(BUILT_IN_THEMES[0].colors.chrome), + ); + expect(variables["--color-primary"]).not.toBe(variables["--color-screen"]); + expect(variables["--color-primary-shadow"]).toBe("#000000"); + expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.22)"); + expect(variables["--color-drawer-shadow"]).toBe("rgba(0, 0, 0, 0.12)"); + expect(variables["--color-user-bubble-foreground"]).toMatch(/^#/); + expect(Object.keys(DEFAULT_MOBILE_THEME_VARIABLES.light).sort()).toEqual( + Object.keys(variables).sort(), + ); + expect(Object.keys(DEFAULT_MOBILE_THEME_VARIABLES.dark).sort()).toEqual( + Object.keys(variables).sort(), + ); + }); + + it("keeps every built-in shadow and backdrop black-based in dark mode", () => { + for (const theme of BUILT_IN_THEMES) { + const variables = getMobileThemeVariables(normalizeMobileThemeId(theme.id), "dark"); + expect(variables["--color-primary-shadow"]).toBe("#000000"); + expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.48)"); + expect(variables["--color-drawer-shadow"]).toBe("rgba(0, 0, 0, 0.32)"); + } + }); + + it("keeps placeholders and selected-row labels readable on their mobile surfaces", () => { + for (const themeId of MOBILE_THEME_IDS) { + for (const appearance of ["light", "dark"] as const) { + const variables = getMobileThemeVariables(themeId, appearance); + expect( + contrastRatio(variables["--color-placeholder"], variables["--color-input"]), + ).toBeGreaterThanOrEqual(4.5); + } + } + + for (const themeId of BUILT_IN_THEME_IDS) { + for (const appearance of ["light", "dark"] as const) { + const variables = getMobileThemeVariables(themeId, appearance); + expect( + contrastRatio( + variables["--color-user-bubble-foreground"], + variables["--color-user-bubble"], + ), + ).toBeGreaterThanOrEqual(4.5); + expect( + contrastRatio( + variables["--color-user-bubble-skill-foreground"], + variables["--color-user-bubble"], + ), + ).toBeGreaterThanOrEqual(4.5); + expect(variables["--color-user-bubble-skill-foreground"]).not.toBe( + variables["--color-user-bubble-foreground"], + ); + const fenceSurface = compositeOver( + variables["--color-md-user-fence-bg"], + variables["--color-user-bubble"], + ); + expect(fenceSurface).not.toBe(variables["--color-user-bubble"]); + expect( + contrastRatio(variables["--color-md-user-fence-text"], fenceSurface), + ).toBeGreaterThanOrEqual(4.5); + } + } + }); +}); diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts new file mode 100644 index 000000000..fa907085b --- /dev/null +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -0,0 +1,314 @@ +import { + BUILT_IN_THEMES, + getThemeColorsForAppearance, + MOBILE_DEFAULT_THEME_ID, + MOBILE_THEME_IDS as SHARED_MOBILE_THEME_IDS, + type MobileThemeId as SharedMobileThemeId, + type ThemeAppearance, + type ThemeColors, +} from "@t3tools/shared/themePalettes"; +import { + STANDARD_THEME_PREVIEW_COLORS, + type ThemePreviewColors, +} from "@t3tools/shared/themePreview"; +import { DEFAULT_MOBILE_THEME_VARIABLES } from "./mobileDefaultTheme"; + +export const DEFAULT_MOBILE_THEME_ID = MOBILE_DEFAULT_THEME_ID; +export const MOBILE_THEME_IDS = SHARED_MOBILE_THEME_IDS; +export type MobileThemeId = SharedMobileThemeId; +export type MobileThemeAppearance = ThemeAppearance; +export type MobileThemeMode = MobileThemeAppearance | "system"; +export type MobileThemeIds = Readonly>; + +export const MOBILE_THEME_OPTIONS: ReadonlyArray<{ + readonly id: MobileThemeId; + readonly label: string; +}> = [ + { id: DEFAULT_MOBILE_THEME_ID, label: "Pylon" }, + ...BUILT_IN_THEMES.map((theme) => ({ id: theme.id as MobileThemeId, label: theme.label })), +]; + +type MobileThemeVariable = `--color-${string}`; +export type MobileThemeVariables = Readonly>; + +export function normalizeMobileThemeId(value: unknown): MobileThemeId { + return typeof value === "string" && (MOBILE_THEME_IDS as readonly string[]).includes(value) + ? (value as MobileThemeId) + : DEFAULT_MOBILE_THEME_ID; +} + +export function normalizeMobileThemeMode(value: unknown): MobileThemeMode { + return value === "light" || value === "dark" || value === "system" ? value : "system"; +} + +export function resolveMobileThemeIds(preferences: { + readonly themeId?: unknown; + readonly lightThemeId?: unknown; + readonly darkThemeId?: unknown; +}): MobileThemeIds { + const legacyThemeId = normalizeMobileThemeId(preferences.themeId); + return { + light: + preferences.lightThemeId === undefined + ? legacyThemeId + : normalizeMobileThemeId(preferences.lightThemeId), + dark: + preferences.darkThemeId === undefined + ? legacyThemeId + : normalizeMobileThemeId(preferences.darkThemeId), + }; +} + +export function createMobileThemeSelectionPatch( + themeIds: MobileThemeIds, + activeAppearance: MobileThemeAppearance, + selectedAppearance: MobileThemeAppearance, + value: MobileThemeId, +) { + const nextThemeIds: MobileThemeIds = { + light: selectedAppearance === "light" ? value : themeIds.light, + dark: selectedAppearance === "dark" ? value : themeIds.dark, + }; + return { + lightThemeId: nextThemeIds.light, + darkThemeId: nextThemeIds.dark, + // Keep older OTA bundles on the theme for the appearance currently in use. + themeId: nextThemeIds[activeAppearance], + }; +} + +export function createMobileThemePairPatch(value: MobileThemeId) { + return { + lightThemeId: value, + darkThemeId: value, + themeId: value, + }; +} + +const OKLCH_PATTERN = /^oklch\(\s*([\d.]+)\s+([\d.]+)\s+(-?[\d.]+)(?:\s*\/\s*([\d.]+))?\s*\)$/; + +function linearToSrgb(value: number): number { + const converted = value <= 0.0031308 ? 12.92 * value : 1.055 * value ** (1 / 2.4) - 0.055; + return Math.round(Math.min(1, Math.max(0, converted)) * 255); +} + +/** React Native does not accept OKLCH ColorValues, so palettes cross the app boundary as sRGB. */ +export function themeColorToNativeColor(value: string): string { + const match = OKLCH_PATTERN.exec(value); + if (!match) return value; + + const lightness = Number(match[1]); + const chroma = Number(match[2]); + const hue = (Number(match[3]) * Math.PI) / 180; + const alpha = match[4] === undefined ? 1 : Number(match[4]); + const a = chroma * Math.cos(hue); + const b = chroma * Math.sin(hue); + const lPrime = lightness + 0.3963377774 * a + 0.2158037573 * b; + const mPrime = lightness - 0.1055613458 * a - 0.0638541728 * b; + const sPrime = lightness - 0.0894841775 * a - 1.291485548 * b; + const l = lPrime ** 3; + const m = mPrime ** 3; + const s = sPrime ** 3; + const red = linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s); + const green = linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s); + const blue = linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s); + + return alpha < 1 + ? `rgba(${red}, ${green}, ${blue}, ${Number(alpha.toFixed(4))})` + : `#${[red, green, blue].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; +} + +function nativeColors(colors: ThemeColors): ThemeColors { + return Object.fromEntries( + Object.entries(colors).map(([role, color]) => [role, themeColorToNativeColor(color)]), + ) as ThemeColors; +} + +function withAlpha(color: string, alpha: number): string { + const hex = color.startsWith("#") ? color.slice(1) : ""; + if (hex.length !== 6) return color; + const [red, green, blue] = [0, 2, 4].map((offset) => + Number.parseInt(hex.slice(offset, offset + 2), 16), + ); + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; +} + +function rgbChannels(color: string): readonly [number, number, number] | null { + const match = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(color); + return match + ? [Number.parseInt(match[1], 16), Number.parseInt(match[2], 16), Number.parseInt(match[3], 16)] + : null; +} + +function relativeLuminance(channels: readonly [number, number, number]): number { + const [red, green, blue] = channels.map((channel) => { + const value = channel / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * red! + 0.7152 * green! + 0.0722 * blue!; +} + +function contrastRatio( + first: readonly [number, number, number], + second: readonly [number, number, number], +): number { + const firstLuminance = relativeLuminance(first); + const secondLuminance = relativeLuminance(second); + return ( + (Math.max(firstLuminance, secondLuminance) + 0.05) / + (Math.min(firstLuminance, secondLuminance) + 0.05) + ); +} + +/** Preserve the theme's action hue while making it readable as skill text on a message bubble. */ +function readableMessageAccent(accent: string, surface: string): string { + const accentChannels = rgbChannels(accent); + const surfaceChannels = rgbChannels(surface); + if ( + !accentChannels || + !surfaceChannels || + contrastRatio(accentChannels, surfaceChannels) >= 4.5 + ) { + return accent; + } + + const black = [0, 0, 0] as const; + const white = [255, 255, 255] as const; + const target = + contrastRatio(black, surfaceChannels) >= contrastRatio(white, surfaceChannels) ? black : white; + let readable: readonly [number, number, number] = target; + let lowerAmount = 0; + let upperAmount = 1; + for (let index = 0; index < 12; index += 1) { + const amount = (lowerAmount + upperAmount) / 2; + const candidate: readonly [number, number, number] = [ + Math.round(accentChannels[0] + (target[0] - accentChannels[0]) * amount), + Math.round(accentChannels[1] + (target[1] - accentChannels[1]) * amount), + Math.round(accentChannels[2] + (target[2] - accentChannels[2]) * amount), + ]; + if (contrastRatio(candidate, surfaceChannels) >= 4.5) { + readable = candidate; + upperAmount = amount; + } else { + lowerAmount = amount; + } + } + return `#${readable.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; +} + +export function themeColorWithAlpha(color: string, alpha: number): string { + const hex = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(color); + if (hex) { + return `rgba(${Number.parseInt(hex[1], 16)}, ${Number.parseInt(hex[2], 16)}, ${Number.parseInt(hex[3], 16)}, ${alpha})`; + } + const rgb = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/.exec(color); + return rgb ? `rgba(${rgb[1]}, ${rgb[2]}, ${rgb[3]}, ${alpha})` : color; +} + +export function createMobileThemeVariables( + colors: ThemeColors, + appearance: MobileThemeAppearance, +): MobileThemeVariables { + const c = nativeColors(colors); + return { + "--color-screen": c.canvas, + "--color-sheet": withAlpha(c.chrome, 0.98), + "--color-sheet-solid": c.chrome, + "--color-card": c.surfaceRaised, + "--color-card-alt": c.surface, + "--color-card-translucent": withAlpha(c.surfaceRaised, 0.8), + "--color-foreground": c.text, + "--color-foreground-secondary": c.textMuted, + "--color-foreground-muted": c.mutedForeground, + "--color-foreground-tertiary": c.secondaryLabel, + "--color-border": c.border, + "--color-border-subtle": withAlpha(c.border, 0.7), + "--color-separator": withAlpha(c.border, 0.55), + "--color-subtle": c.muted, + "--color-subtle-strong": c.secondary, + "--color-inline-skill-background": c.accentSurface, + "--color-inline-skill-border": withAlpha(c.accent, 0.42), + "--color-inline-skill-foreground": c.accentSurfaceForeground, + "--color-primary": c.accent, + "--color-primary-foreground": c.accentForeground, + "--color-primary-shadow": "#000000", + "--color-secondary": c.secondary, + "--color-secondary-foreground": c.secondaryForeground, + "--color-secondary-border": c.border, + "--color-switch-active-track": c.accent, + "--color-switch-active-thumb": c.accentForeground, + "--color-switch-inactive-track": c.secondary, + "--color-switch-inactive-thumb": c.mutedForeground, + "--color-danger": c.errorSurface, + "--color-danger-border": withAlpha(c.error, 0.32), + "--color-danger-foreground": c.errorForeground, + "--color-input": c.surfaceRaised, + "--color-input-border": c.input, + "--color-sidebar-search": c.sidebarControlSurface, + "--color-placeholder": c.placeholder, + "--color-icon": c.text, + "--color-icon-muted": c.iconMuted, + "--color-icon-subtle": c.secondaryLabel, + "--color-header": withAlpha(c.toolbar, 0.97), + "--color-header-border": c.toolbarBorder, + "--color-glass-surface": withAlpha(c.surfaceOverlay, 0.74), + "--color-glass-tint": withAlpha(c.surfaceOverlay, 0.22), + "--color-status-bar": c.canvas, + "--color-md-body": c.text, + "--color-md-strong": c.toolbarForeground, + "--color-md-link": c.accent, + "--color-md-blockquote-border": c.border, + "--color-md-blockquote-bg": c.muted, + "--color-md-code-bg": c.codeBackground, + "--color-md-code-text": c.codeForeground, + "--color-md-user-code-bg": withAlpha(c.messageForeground, 0.18), + "--color-md-user-code-text": c.messageForeground, + "--color-md-user-fence-bg": withAlpha("#000000", appearance === "dark" ? 0.28 : 0.16), + "--color-md-user-fence-text": c.messageForeground, + "--color-md-hr": c.border, + "--color-user-bubble": c.messageSurface, + "--color-user-bubble-foreground": c.messageForeground, + "--color-user-bubble-foreground-muted": withAlpha(c.messageForeground, 0.78), + "--color-user-bubble-skill-foreground": readableMessageAccent( + c.messageAction, + c.messageSurface, + ), + "--color-backdrop": withAlpha("#000000", appearance === "dark" ? 0.48 : 0.22), + "--color-drawer": withAlpha(c.sidebar, 0.99), + "--color-drawer-shadow": withAlpha("#000000", appearance === "dark" ? 0.32 : 0.12), + "--color-dot-separator": withAlpha(c.textMuted, 0.35), + "--color-wordmark": c.text, + "--color-chevron": withAlpha(c.textMuted, 0.42), + }; +} + +export function getMobileThemeVariables( + themeId: MobileThemeId, + appearance: MobileThemeAppearance, + overrides: Partial | null = null, +): MobileThemeVariables { + const baseVariables = (() => { + if (themeId === DEFAULT_MOBILE_THEME_ID) return DEFAULT_MOBILE_THEME_VARIABLES[appearance]; + const theme = + BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0]; + const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors; + return createMobileThemeVariables(colors, appearance); + })(); + + // The complete base record guarantees that optional overrides cannot leave a token undefined. + return overrides ? ({ ...baseVariables, ...overrides } as MobileThemeVariables) : baseVariables; +} + +export function getMobileThemePreviewColors( + themeId: MobileThemeId, + appearance: MobileThemeAppearance, +): ThemePreviewColors { + if (themeId === DEFAULT_MOBILE_THEME_ID) 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 { + canvas: themeColorToNativeColor(colors.canvas), + accent: themeColorToNativeColor(colors.accent), + messageAction: themeColorToNativeColor(colors.messageAction), + }; +} diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index a97252c7b..7b94dc629 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -177,6 +177,25 @@ describe("mobile connection storage", () => { await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 }); }); + it("persists independent light and dark theme choices", async () => { + mocks.setPreferencesJson( + JSON.stringify({ + themeId: "grove", + lightThemeId: "iris", + darkThemeId: "ocean", + themeMode: "system", + }), + 10, + ); + + await expect(loadPreferences()).resolves.toEqual({ + themeId: "grove", + lightThemeId: "iris", + darkThemeId: "ocean", + themeMode: "system", + }); + }); + it("falls back to secure storage when SQLite cannot save preferences", async () => { mocks.setDatabaseFailures(true, true); await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 }); diff --git a/apps/mobile/src/lib/useMobileNavigationTheme.ts b/apps/mobile/src/lib/useMobileNavigationTheme.ts new file mode 100644 index 000000000..6711f72c7 --- /dev/null +++ b/apps/mobile/src/lib/useMobileNavigationTheme.ts @@ -0,0 +1,22 @@ +import { DarkTheme, DefaultTheme, type Theme } from "@react-navigation/native"; +import { useMemo } from "react"; + +import type { MobileThemeAppearance } from "./mobileTheme"; +import { useThemeColor } from "./useThemeColor"; + +export function useMobileNavigationTheme(appearance: MobileThemeAppearance): Theme { + const primary = String(useThemeColor("--color-primary")); + const background = String(useThemeColor("--color-screen")); + const card = String(useThemeColor("--color-sheet-solid")); + const text = String(useThemeColor("--color-foreground")); + const border = String(useThemeColor("--color-header-border")); + const notification = String(useThemeColor("--color-danger-foreground")); + + return useMemo(() => { + const base = appearance === "dark" ? DarkTheme : DefaultTheme; + return { + ...base, + colors: { ...base.colors, primary, background, card, text, border, notification }, + }; + }, [appearance, background, border, card, notification, primary, text]); +} diff --git a/apps/mobile/src/native/sheet-surface.ts b/apps/mobile/src/native/sheet-surface.ts index eb2e8a8d1..1b973b0ff 100644 --- a/apps/mobile/src/native/sheet-surface.ts +++ b/apps/mobile/src/native/sheet-surface.ts @@ -1,28 +1,7 @@ -import { DynamicColorIOS, Platform, type ColorValue, type ViewStyle } from "react-native"; - /** - * One opaque surface for content rendered inside a native form sheet. - * - * UIKit owns the outer sheet material and rounded corners. The presented route - * owns this surface so nested navigators never expose a differently colored - * native container while their screens move. - */ -export const NATIVE_SHEET_SURFACE_COLOR: ColorValue | undefined = - Platform.OS === "ios" ? DynamicColorIOS({ light: "#f2f2f7", dark: "#0e0e0e" }) : undefined; - -export const NATIVE_SHEET_SURFACE_CONTENT_STYLE: ViewStyle | undefined = - NATIVE_SHEET_SURFACE_COLOR === undefined - ? undefined - : { backgroundColor: NATIVE_SHEET_SURFACE_COLOR }; - -/** - * Paint the adaptive background on the presented screen itself. Nested stacks - * can stay transparent over this single surface, so a push never exposes an - * unpainted form-sheet host behind the moving child view controllers. + * Form sheets inherit the live React Navigation palette supplied by App. Each + * presented route paints its content with bg-sheet, including nested pushes. */ export const FORM_SHEET_PRESENTATION_OPTIONS = { presentation: "formSheet" as const, - ...(NATIVE_SHEET_SURFACE_CONTENT_STYLE === undefined - ? null - : { contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE }), }; diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 1da9c9f7a..dfaeab9cd 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -6,6 +6,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; +import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme"; import * as MobileDatabase from "./mobile-database"; import * as MobileSecureStorage from "./mobile-secure-storage"; @@ -16,6 +17,10 @@ const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback"; export interface Preferences { readonly liveActivitiesEnabled?: boolean; + readonly themeId?: MobileThemeId; + readonly lightThemeId?: MobileThemeId; + readonly darkThemeId?: MobileThemeId; + readonly themeMode?: MobileThemeMode; readonly baseFontSize?: number; readonly terminalFontSize?: number | null; readonly markdownFontSize?: number; @@ -79,6 +84,10 @@ export class MobilePreferencesStore extends Context.Service< function sanitizePreferences(parsed: Preferences): Preferences { const preferences: { liveActivitiesEnabled?: boolean; + themeId?: MobileThemeId; + lightThemeId?: MobileThemeId; + darkThemeId?: MobileThemeId; + themeMode?: MobileThemeMode; baseFontSize?: number; terminalFontSize?: number | null; markdownFontSize?: number; @@ -96,6 +105,31 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.liveActivitiesEnabled === "boolean") { preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled; } + if ( + typeof parsed.themeId === "string" && + (MOBILE_THEME_IDS as readonly string[]).includes(parsed.themeId) + ) { + preferences.themeId = parsed.themeId as MobileThemeId; + } + if ( + typeof parsed.lightThemeId === "string" && + (MOBILE_THEME_IDS as readonly string[]).includes(parsed.lightThemeId) + ) { + preferences.lightThemeId = parsed.lightThemeId as MobileThemeId; + } + if ( + typeof parsed.darkThemeId === "string" && + (MOBILE_THEME_IDS as readonly string[]).includes(parsed.darkThemeId) + ) { + preferences.darkThemeId = parsed.darkThemeId as MobileThemeId; + } + if ( + parsed.themeMode === "system" || + parsed.themeMode === "light" || + parsed.themeMode === "dark" + ) { + preferences.themeMode = parsed.themeMode; + } 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/web/src/components/settings/ThemePreviewCircles.tsx b/apps/web/src/components/settings/ThemePreviewCircles.tsx index 6000e5c6b..54f29d6a2 100644 --- a/apps/web/src/components/settings/ThemePreviewCircles.tsx +++ b/apps/web/src/components/settings/ThemePreviewCircles.tsx @@ -1,5 +1,9 @@ import { MoonIcon, SunIcon } from "lucide-react"; import type { CSSProperties } from "react"; +import { + STANDARD_THEME_PREVIEW_COLORS as SHARED_STANDARD_THEME_PREVIEW_COLORS, + THEME_PREVIEW_RENDER_SPECS, +} from "@t3tools/shared/themePreview"; import { cn } from "../../lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { @@ -37,21 +41,17 @@ const STANDARD_THEME_PREVIEW_COLORS: Record< > = { light: { sidebar: "#fafafa", - canvas: "#fcfcfc", surface: "#ffffff", accentSurface: "#f4f4f5", - accent: "#f4f4f5", messageSurface: "#e4e4e7", - messageAction: "#4f46e5", + ...SHARED_STANDARD_THEME_PREVIEW_COLORS.light, }, dark: { sidebar: "#0f0f10", - canvas: "#0a0a0a", surface: "#121212", accentSurface: "#27272a", - accent: "#1c1c1f", messageSurface: "#27272a", - messageAction: "#8b9cff", + ...SHARED_STANDARD_THEME_PREVIEW_COLORS.dark, }, }; @@ -102,23 +102,20 @@ function getThemePreviewStyle( colors: ThemeCardPreviewColors, mode: ThemeAppearance, ): CSSProperties { - const isDark = mode === "dark"; + const spec = THEME_PREVIEW_RENDER_SPECS[mode]; // The canvas carries the ball's light/dark identity, so it stays dominant: // a near-true base with a contained accent glow, instead of an accent wash // that makes both modes read alike. - const modeBase = isDark - ? `color-mix(in oklab, ${colors.canvas} 80%, #09090b)` - : `color-mix(in oklab, ${colors.canvas} 80%, #ffffff)`; - const accentPosition = isDark ? "28% 78%" : "72% 22%"; - const actionPosition = isDark ? "82% 18%" : "18% 82%"; - const accentFade = isDark ? 62 : 72; + const modeBase = `color-mix(in oklab, ${colors.canvas} ${spec.baseWeight * 100}%, ${spec.baseTarget})`; + const accentPosition = `${spec.accent.center[0] * 100}% ${spec.accent.center[1] * 100}%`; + const actionPosition = `${spec.action.center[0] * 100}% ${spec.action.center[1] * 100}%`; return { backgroundColor: modeBase, backgroundImage: [ - `radial-gradient(circle at ${accentPosition} in oklab, ${colors.accent} 0%, color-mix(in oklab, ${colors.accent} ${accentFade}%, transparent) 28%, transparent 58%)`, + `radial-gradient(circle at ${accentPosition} in oklab, ${colors.accent} 0%, color-mix(in oklab, ${colors.accent} ${spec.accent.middleOpacity * 100}%, transparent) ${spec.accent.middleOffset * 100}%, transparent ${spec.accent.endOffset * 100}%)`, // The action color is a soft tint from the opposite corner, not a second // light source — two bright hotspots read as headlights. - `radial-gradient(circle at ${actionPosition} in oklab, color-mix(in oklab, ${colors.messageAction} 45%, transparent) 0%, transparent 55%)`, + `radial-gradient(circle at ${actionPosition} in oklab, color-mix(in oklab, ${colors.messageAction} ${spec.action.startOpacity * 100}%, transparent) 0%, transparent ${spec.action.endOffset * 100}%)`, ].join(", "), }; } @@ -145,8 +142,12 @@ export function ThemePreviewCircle({ style={{ boxShadow: themePreviewEdgeShadow(mode) }} > ); diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index 402c71962..b0a6b9caf 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vite-plus/test"; +import { BUILT_IN_THEMES } from "@t3tools/shared/themePalettes"; import { applyThemeColorPreview, @@ -78,6 +79,16 @@ function contrastRatio(first: string, second: string): number { } describe("theme files", () => { + it("keeps every built-in palette value in canonical OKLCH form", () => { + for (const theme of BUILT_IN_THEMES) { + for (const colors of [theme.colors, ...Object.values(theme.variants ?? {})]) { + for (const value of Object.values(colors)) { + expect(toCanonicalThemeColor(value)).toBe(value); + } + } + } + }); + it("derives a readable palette from extreme simple-editor colors", () => { const light = createManagedThemeColors("light", "#111827", "#ffff00"); const dark = createManagedThemeColors("dark", "#ffffff", "#ffff00"); diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 24145dd15..a5717b2d8 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -1,6 +1,23 @@ import * as Schema from "effect/Schema"; import "culori/css"; import { converter, parse } from "culori/fn"; +import { + BUILT_IN_THEMES, + EMBER_THEME, + GROVE_THEME, + IRIS_THEME, + OCEAN_THEME, + T3_CHAT_THEME, + THEME_COLOR_ROLES, + type ThemeAppearance, + type ThemeColorRole, + type ThemeColors, + type ThemeDefinition, + type ThemeVariants, +} from "@t3tools/shared/themePalettes"; + +export { EMBER_THEME, GROVE_THEME, IRIS_THEME, OCEAN_THEME, T3_CHAT_THEME, THEME_COLOR_ROLES }; +export type { ThemeAppearance, ThemeColorRole, ThemeColors, ThemeDefinition, ThemeVariants }; export const T3_CHAT_THEME_ID = "t3-chat" as const; export const T3_CHAT_THEME_LABEL = "T3 Chat"; @@ -23,90 +40,11 @@ const LEGACY_T3_CHAT_DARK_THEME_ID = "t3-chat-dark"; export const ThemePreference = Schema.String; export type ThemePreference = typeof ThemePreference.Type; -export const THEME_COLOR_ROLES = [ - "canvas", - "chrome", - "toolbar", - "toolbarForeground", - "toolbarBorder", - "toolbarControl", - "toolbarControlForeground", - "toolbarControlHover", - "surface", - "surfaceRaised", - "surfaceOverlay", - "text", - "textMuted", - "border", - "input", - "focus", - "accent", - "accentForeground", - "secondary", - "secondaryForeground", - "muted", - "mutedForeground", - "placeholder", - "secondaryLabel", - "iconMuted", - "error", - "errorForeground", - "errorSurface", - "warning", - "warningForeground", - "warningSurface", - "update", - "updateForeground", - "updateSurface", - "accentSurface", - "accentSurfaceForeground", - "messageSurface", - "messageForeground", - "messageAction", - "messageActionForeground", - "messageActionHover", - "codeBackground", - "codeForeground", - "sidebar", - "sidebarForeground", - "sidebarMutedForeground", - "sidebarControlSurface", - "sidebarRowHover", - "sidebarRowActive", - "sidebarRowSelected", - "sidebarBorder", - "terminalBackground", - "terminalForeground", - "terminalCursor", - "terminalSelection", - "terminalScrollbar", - "terminalScrollbarHover", -] as const; - -export type ThemeColorRole = (typeof THEME_COLOR_ROLES)[number]; const THEME_COLOR_ROLE_SET: ReadonlySet = new Set(THEME_COLOR_ROLES); -export type ThemeAppearance = "light" | "dark"; - -export type ThemeColors = Readonly>; export type ThemeColorOverrides = Readonly>>; -export type ThemeVariants = Readonly>>; export type ThemeVariantOverrides = Readonly>>; export type ThemePreferenceMode = ThemeAppearance | "system"; export type ThemeCollection = Readonly<{ id: string; label: string }>; -export type ThemeDefinition = Readonly<{ - id: string; - label: string; - appearance: ThemeAppearance; - colors: ThemeColors; - variants?: ThemeVariants; - /** Groups related imported variants into one library card. */ - collection?: ThemeCollection; - /** Allows Dev/Nightly artwork to render over a maintainer-controlled sidebar. */ - sidebarArtwork?: boolean; - /** True when the palette was generated by the guided editor from its - * canvas and accent; such themes reopen in guided mode. */ - managed?: boolean; -}>; export type ThemeFile = Readonly<{ version: typeof THEME_FILE_VERSION; id: string; @@ -366,155 +304,6 @@ function legacyThemeMode(theme: ThemePreference): ThemeAppearance | null { return theme === LEGACY_T3_CHAT_DARK_THEME_ID ? "dark" : null; } -/** - * Maintainer palettes use product color roles rather than Tailwind or component - * names so the same definitions can feed other clients and native surfaces. - */ -// Measured from the live t3.chat default theme. Translucent chat surfaces are -// flattened over --chat-background so this opaque palette reproduces the -// pixels users see after T3 Chat's blur and noise layers are composited. -// Foreground pairs deviate where necessary to keep normal text at WCAG AA. -const T3_CHAT_LIGHT_COLORS: ThemeColors = { - canvas: "#fdf7fd", - // Pylon's workspace header belongs to the chat panel, so keep it seamless - // with the light chat canvas rather than mapping it to T3 Chat's outer shell. - chrome: "#fdf7fd", - toolbar: "#fdf7fd", - toolbarForeground: "#501854", - toolbarBorder: "#efbdeb", - // T3 Chat's light chrome controls sit on its pale gradient-noise surface, - // not the substantially darker solid accent token. - toolbarControl: "#f3e6f5", - toolbarControlForeground: "#501854", - toolbarControlHover: "#eccfe3", - surface: "#faf3fb", - surfaceRaised: "#fdfafd", - surfaceOverlay: "#ffffff", - text: "#501854", - textMuted: "#ac1668", - border: "#eee1ed", - input: "#e7c1dc", - focus: "#db2777", - accent: "#db2777", - accentForeground: "#ffffff", - secondary: "#f1c4e6", - secondaryForeground: "#77347c", - muted: "#eaa7cb", - mutedForeground: "#8d1255", - placeholder: "#8b5f90", - secondaryLabel: "#ac1668", - iconMuted: "#ac1668", - error: "#f7086c", - errorForeground: "#9d174d", - errorSurface: "#fde4f1", - warning: "#f59e0b", - warningForeground: "#b05109", - warningSurface: "#fcf0ea", - update: "#db2777", - updateForeground: "#ac1668", - updateSurface: "#fadfef", - accentSurface: "#f3e6f5", - accentSurfaceForeground: "#454554", - messageSurface: "#f7def2", - messageForeground: "#492c61", - messageAction: "#db2777", - messageActionForeground: "#ffffff", - messageActionHover: "#c12269", - // T3 Chat uses a light lavender code surface in light mode. Keeping the - // dark plum pair here also leaked the dark palette into Pylon's diffs. - codeBackground: "#f5ecf9", - codeForeground: "#673c8b", - // The live sidebar is transparent over T3 Chat's outer shell. Use that - // rendered shell color rather than its unused, darker sidebar token. - sidebar: "#f2e1f4", - sidebarForeground: "#454554", - sidebarMutedForeground: "#ac1668", - sidebarControlSurface: "#f8f8f7", - sidebarRowHover: "#f8f8f7", - sidebarRowActive: "#f8f8f7", - sidebarRowSelected: "#f8f8f7", - sidebarBorder: "#eceae9", - terminalBackground: "#fdf7fd", - terminalForeground: "#501854", - terminalCursor: "#db2777", - terminalSelection: "#f1c4e6", - terminalScrollbar: "#e7c1dc", - terminalScrollbarHover: "#eaa7cb", -}; - -const T3_CHAT_DARK_COLORS: ThemeColors = { - canvas: "#1f1a24", - // Pylon's workspace header belongs to the chat panel, so keep it seamless - // with the canvas rather than mapping it to T3 Chat's outer shell. - chrome: "#1f1a24", - toolbar: "#1f1a24", - toolbarForeground: "#f9f8fb", - toolbarBorder: "#27242c", - toolbarControl: "#362d3d", - toolbarControlForeground: "#d4c7e1", - toolbarControlHover: "#463753", - // Cards and panels stay in T3 Chat's plum surface family. Near-black here - // made the right-panel surface picker look unrelated to the chat canvas. - surface: "#29232d", - // Pre-composited for the composer's 80% glass layer; this resolves to the - // measured #29232d input fill over the canvas. - surfaceRaised: "#2c2631", - surfaceOverlay: "#100a0e", - text: "#f9f8fb", - textMuted: "#e7d0dd", - border: "#27242c", - input: "#302029", - focus: "#db2777", - accent: "#a3004c", - accentForeground: "#fbd0e8", - secondary: "#362d3d", - secondaryForeground: "#d4c7e1", - muted: "#423a45", - mutedForeground: "#e7d0dd", - placeholder: "#968d9f", - secondaryLabel: "#e7d0dd", - iconMuted: "#d4c7e1", - error: "#9d174d", - errorForeground: "#fbd0e8", - errorSurface: "#331a2b", - warning: "#f59e0b", - warningForeground: "#fbbf24", - warningSurface: "#412f20", - update: "#a3004c", - updateForeground: "#fbd0e8", - updateSurface: "#37152b", - accentSurface: "#463753", - accentSurfaceForeground: "#f8f1f5", - messageSurface: "#2b2431", - messageForeground: "#f2ebfa", - messageAction: "#a3004c", - messageActionForeground: "#fbd0e8", - messageActionHover: "#a2004c", - // Diffs and file previews are full workspace surfaces in Pylon. Keep them - // continuous with the themed canvas instead of dropping to near-black. - codeBackground: "#1f1a24", - codeForeground: "#d8c3ef", - // The live sidebar starts from #131314, then gains its hue from a pink - // gradient/noise stack. This pre-grain base lands on the same #1a131a - // visible shell color after our surface-grain layer is composited. - sidebar: "#171018", - sidebarForeground: "#f4f4f5", - sidebarMutedForeground: "#e7d0dd", - sidebarControlSurface: "#261922", - sidebarRowHover: "#261922", - sidebarRowActive: "#261922", - sidebarRowSelected: "#261922", - // T3 Chat draws the chat panel edge in this muted pink. The resize rail uses - // the same role on hover, so it stays pink instead of falling back to black. - sidebarBorder: "#322028", - terminalBackground: "#1f1a24", - terminalForeground: "#f9f8fb", - terminalCursor: "#db2777", - terminalSelection: "#362d3d", - terminalScrollbar: "#302029", - terminalScrollbarHover: "#423a45", -}; - /** * The palette Pylon wears with no theme installed, captured from the app's * stock tokens (index.css) so a draft seeded from the default look paints the @@ -1431,43 +1220,11 @@ export function createManagedThemeColors( }; } -export const T3_CHAT_THEME: ThemeDefinition = { - id: T3_CHAT_THEME_ID, - label: T3_CHAT_THEME_LABEL, - appearance: "light", - colors: decodeThemeColors(T3_CHAT_LIGHT_COLORS), - variants: { - dark: decodeThemeColors(T3_CHAT_DARK_COLORS), - }, - sidebarArtwork: true, -}; - /** Theme-file defaults follow the flagship palette for the requested mode. */ export function getDefaultThemeColors(appearance: ThemeAppearance): ThemeColors { return appearance === "dark" ? T3_CHAT_THEME.variants!.dark! : T3_CHAT_THEME.colors; } -/** - * A companion action color in the T3 Chat mold. This gives send buttons, - * status pills, and theme previews a second voice; foreground and hover follow - * the same rules as the managed generator. - */ -function themeActionColors( - action: string, -): Pick { - const rgb = parseThemeRgbColor(action, THEME_DARK_FOREGROUND); - const foreground = readableThemeForeground(rgb); - const towardOpposite = - foreground === THEME_LIGHT_FOREGROUND || foreground === THEME_WHITE_FOREGROUND - ? THEME_BLACK_FOREGROUND - : THEME_WHITE_FOREGROUND; - return { - messageAction: toCanonicalThemeColor(action) ?? themeRgbToThemeColor(rgb), - messageActionForeground: themeRgbToThemeColor(foreground), - messageActionHover: themeRgbToThemeColor(mixThemeRgbColors(rgb, towardOpposite, 0.12)), - }; -} - /** * Update one Advanced-editor color family without normalizing the rest of an * imported or hand-tuned palette. The editor exposes a representative role @@ -1660,81 +1417,7 @@ export function updateThemeColorFamily( } } -export const GROVE_THEME: ThemeDefinition = { - id: GROVE_THEME_ID, - label: GROVE_THEME_LABEL, - appearance: "light", - colors: { - ...createManagedThemeColors("light", "#f2f8f4", "#19734a"), - ...themeActionColors("#8f6410"), - }, - variants: { - dark: { - ...createManagedThemeColors("dark", "#1d2b24", "#69d69a"), - ...themeActionColors("#e3b34e"), - }, - }, - sidebarArtwork: true, -}; - -export const OCEAN_THEME: ThemeDefinition = { - id: OCEAN_THEME_ID, - label: OCEAN_THEME_LABEL, - appearance: "light", - colors: { - ...createManagedThemeColors("light", "#f2f7fb", "#2878b8"), - ...themeActionColors("#0a6f75"), - }, - variants: { - dark: { - ...createManagedThemeColors("dark", "#1b2938", "#70b9ee"), - ...themeActionColors("#5bd0d6"), - }, - }, - sidebarArtwork: true, -}; - -export const EMBER_THEME: ThemeDefinition = { - id: EMBER_THEME_ID, - label: EMBER_THEME_LABEL, - appearance: "light", - colors: { - ...createManagedThemeColors("light", "#fff6ef", "#c4602f"), - ...themeActionColors("#b23535"), - }, - variants: { - dark: { - ...createManagedThemeColors("dark", "#30231e", "#f39a62"), - ...themeActionColors("#f78a7a"), - }, - }, - sidebarArtwork: true, -}; - -export const IRIS_THEME: ThemeDefinition = { - id: IRIS_THEME_ID, - label: IRIS_THEME_LABEL, - appearance: "light", - colors: { - ...createManagedThemeColors("light", "#f7f4fc", "#7254b9"), - ...themeActionColors("#a82c87"), - }, - variants: { - dark: { - ...createManagedThemeColors("dark", "#29243b", "#ad92f5"), - ...themeActionColors("#f099d8"), - }, - }, - sidebarArtwork: true, -}; - -const BUILT_IN_THEME_DEFINITIONS: ReadonlyArray = [ - T3_CHAT_THEME, - GROVE_THEME, - OCEAN_THEME, - EMBER_THEME, - IRIS_THEME, -]; +const BUILT_IN_THEME_DEFINITIONS: ReadonlyArray = BUILT_IN_THEMES; export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | null { const themeId = themeIdFromPreference(theme); diff --git a/docs/README.md b/docs/README.md index 857d38ff5..6aa19b669 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ - [Organizing threads](./user/thread-sidebar.md) - [Review usage](./user/usage.md) - [Customize a project icon](./user/project-settings.md) +- [Mobile appearance](./user/mobile-appearance.md) - [Remote access](./user/remote-access.md) - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) diff --git a/docs/operations/mobile-app-store-screenshots.md b/docs/operations/mobile-app-store-screenshots.md index dbbbd0ff3..c6c78b1ed 100644 --- a/docs/operations/mobile-app-store-screenshots.md +++ b/docs/operations/mobile-app-store-screenshots.md @@ -30,7 +30,7 @@ The command: 4. Starts an isolated Metro server, builds the selected native apps, and boots each device. 5. Pairs each clean app installation with Moonbase Terminal, Suspense Station, and Kernel Cabin. 6. Navigates to the real application route for every requested scene. -7. Sets the requested system appearance and normalizes status bars, converts captures to 24-bit RGB PNGs without alpha, and +7. Sets the requested system appearance and palette, normalizes status bars, converts captures to 24-bit RGB PNGs without alpha, and validates dimensions, aspect ratio, file size, and screenshot count before succeeding. 8. Writes store-ready folders beneath `artifacts/app-store/screenshots/` that can be uploaded directly to App Store Connect or Google Play Console. @@ -51,49 +51,60 @@ shared across every checkout. The readiness check only verifies that the port is verify process ownership. Concurrent screenshot harnesses in different worktrees can therefore collide or attach to the wrong Metro process. -Every configured device defaults to dark appearance, so plain `pnpm screenshots:mobile` produces -30 dark PNGs. Pass `--appearance light`, `--appearance dark`, or `--appearance both` to override the -configured appearance; `both` produces 60 PNGs. +Every configured device defaults to dark appearance and the `t3-code` palette, so plain +`pnpm screenshots:mobile` produces 30 dark PNGs. Pass `--appearance light`, `--appearance dark`, or +`--appearance both` to override the configured appearance; `both` produces 60 PNGs. + +Pass `--theme ` (repeatable) or `--theme all` to capture the app's other palettes: `t3-code`, +`t3-chat`, `grove`, `ocean`, `ember`, and `iris`. The runner hands the palette to the app as a launch +argument, the app applies it to both color schemes, and a scene only reports itself ready once the +requested palette is active — so a capture can never show the previous theme. `--theme all` +multiplies the run by six; only the native build is shared. The default matrix is: -| Output folder | Capture target | Upload dimensions | Store slot | -| ----------------------------- | ------------------------- | ----------------- | ----------------------------------------- | -| `apple/iphone-6.9/dark/` | iPhone 17 Pro Max | 1320×2868 | App Store Connect iPhone 6.9-inch | -| `apple/iphone-6.5/dark/` | disposable iPhone 14 Plus | 1284×2778 | App Store Connect iPhone 6.5-inch | -| `apple/ipad-13/dark/` | iPad Pro 13-inch (M5) | 2752×2064 | App Store Connect iPad 13-inch, landscape | -| `google-play/phone/dark/` | Pixel AVD at 420 dpi | 1080×1920 | Google Play phone, portrait 9:16 | -| `google-play/tablet-7/dark/` | Pixel AVD at 600dp width | 1080×1920 | Google Play 7-inch tablet, portrait 9:16 | -| `google-play/tablet-10/dark/` | Pixel AVD at 800dp width | 1440×2560 | Google Play 10-inch tablet, portrait 9:16 | - -Each target captures thread, terminal, review, thread list, and environments. Each appearance -folder's five screenshots satisfy the configured Apple limit of 1–10, Google +| Output folder | Capture target | Upload dimensions | Store slot | +| ------------------------------------- | ------------------------- | ----------------- | ----------------------------------------- | +| `apple/iphone-6.9/dark/t3-code/` | iPhone 17 Pro Max | 1320×2868 | App Store Connect iPhone 6.9-inch | +| `apple/iphone-6.5/dark/t3-code/` | disposable iPhone 14 Plus | 1284×2778 | App Store Connect iPhone 6.5-inch | +| `apple/ipad-13/dark/t3-code/` | iPad Pro 13-inch (M5) | 2752×2064 | App Store Connect iPad 13-inch, landscape | +| `google-play/phone/dark/t3-code/` | Pixel AVD at 420 dpi | 1080×1920 | Google Play phone, portrait 9:16 | +| `google-play/tablet-7/dark/t3-code/` | Pixel AVD at 600dp width | 1080×1920 | Google Play 7-inch tablet, portrait 9:16 | +| `google-play/tablet-10/dark/t3-code/` | Pixel AVD at 800dp width | 1440×2560 | Google Play 10-inch tablet, portrait 9:16 | + +Each target captures thread, terminal, review, thread list, and environments. Each palette folder's +five screenshots satisfy the configured Apple limit of 1–10, Google phone requirement of 2–8, and Google tablet recommendation/slot minimum of 4 with a maximum of 8. +Every palette gets its own leaf folder so one upload slot never mixes themes and each folder keeps a +store-legal screenshot count. The generated tree is deliberately aligned with the store upload fields: artifacts/app-store/screenshots/ ├── apple/ - │ ├── iphone-6.9/dark/{thread,terminal,review,threads,environments}.png - │ ├── iphone-6.5/dark/{thread,terminal,review,threads,environments}.png - │ └── ipad-13/dark/{thread,terminal,review,threads,environments}.png + │ ├── iphone-6.9/dark/t3-code/{thread,terminal,review,threads,environments}.png + │ ├── iphone-6.5/dark/t3-code/{thread,terminal,review,threads,environments}.png + │ └── ipad-13/dark/t3-code/{thread,terminal,review,threads,environments}.png └── google-play/ - ├── phone/dark/{thread,terminal,review,threads,environments}.png - ├── tablet-7/dark/{thread,terminal,review,threads,environments}.png - └── tablet-10/dark/{thread,terminal,review,threads,environments}.png + ├── phone/dark/t3-code/{thread,terminal,review,threads,environments}.png + ├── tablet-7/dark/t3-code/{thread,terminal,review,threads,environments}.png + └── tablet-10/dark/t3-code/{thread,terminal,review,threads,environments}.png A light-only run writes the same tree under `light/`; `--appearance both` writes both appearance -folders. +folders, and each requested theme adds a sibling folder next to `t3-code/`. Edit [mobile-showcase.config.ts](../../scripts/mobile-showcase.config.ts) to change simulator or AVD -names, light/dark appearance, iOS orientation, scenes, output directory, capture delay, Android ABI, -or viewport. +names, light/dark appearance, default palette, iOS orientation, scenes, output directory, capture +delay, Android ABI, or viewport. The selectable palette ids come from `MOBILE_THEME_IDS` in +[themePalettes.ts](../../packages/shared/src/themePalettes.ts), so the harness and the app's +appearance settings can never drift apart. ## Capture in GitHub Actions Run the `Mobile Showcase Screenshots` workflow from GitHub's Actions tab, choose `all`, `ios`, or -`android`, and select `light`, `dark`, or `both`. The default dispatch captures both appearances and -runs iOS and Android concurrently: iPhone and iPad capture on a +`android`, select `light`, `dark`, or `both`, and pick a palette (or `all`, which raises each job's +timeout from 60 to 300 minutes). The default dispatch captures both appearances of the `t3-code` +palette and runs iOS and Android concurrently: iPhone and iPad capture on a 12-vCPU Blacksmith macOS runner, while Android phone, 7-inch tablet, and 10-inch tablet capture on a 16-vCPU Blacksmith Linux runner with a KVM-accelerated x86_64 emulator. @@ -120,6 +131,12 @@ Override the configured appearance or capture both variants: pnpm screenshots:mobile --appearance dark pnpm screenshots:mobile --appearance both +Capture other palettes: + + pnpm screenshots:mobile --device iphone-6.9 --theme ocean + pnpm screenshots:mobile --device iphone-6.9 --theme ocean --theme ember + pnpm screenshots:mobile --device iphone-6.9 --theme all + Reuse the native build and retain the disposable environment: pnpm screenshots:mobile --device ipad-13 --skip-build --keep-running diff --git a/docs/user/mobile-appearance.md b/docs/user/mobile-appearance.md new file mode 100644 index 000000000..d64a47e39 --- /dev/null +++ b/docs/user/mobile-appearance.md @@ -0,0 +1,15 @@ +# Mobile appearance + +Pylon Mobile includes the Pylon, T3 Chat, Grove, Ocean, Ember, and Iris themes. Each theme has +light and dark colors that apply throughout the app, including code reviews, file previews, the +terminal, native headers, and sheets. + +To change themes: + +1. Open **Settings**. +2. Select **Appearance**. +3. Choose a theme. +4. Select **System**, **Light**, or **Dark**. + +**System** follows the device appearance automatically. Theme, text, code, and terminal appearance +preferences are stored on the device. diff --git a/packages/shared/package.json b/packages/shared/package.json index f669bd0a4..a797e97b6 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,6 +3,14 @@ "private": true, "type": "module", "exports": { + "./themePalettes": { + "types": "./src/themePalettes.ts", + "import": "./src/themePalettes.ts" + }, + "./themePreview": { + "types": "./src/themePreview.ts", + "import": "./src/themePreview.ts" + }, "./projectFavicon": { "types": "./src/projectFavicon.ts", "import": "./src/projectFavicon.ts" diff --git a/packages/shared/src/themePalettes.ts b/packages/shared/src/themePalettes.ts new file mode 100644 index 000000000..919a3bfaf --- /dev/null +++ b/packages/shared/src/themePalettes.ts @@ -0,0 +1,748 @@ +export const BUILT_IN_THEME_IDS = ["t3-chat", "grove", "ocean", "ember", "iris"] as const; + +/** The mobile app's own hand-tuned palette, which is not part of the built-in library. */ +export const MOBILE_DEFAULT_THEME_ID = "t3-code"; + +/** + * Every palette the mobile app can render. Declared here so host-side tooling + * (the app-store screenshot harness) can validate a requested theme without + * importing React Native application code. + */ +export const MOBILE_THEME_IDS = [MOBILE_DEFAULT_THEME_ID, ...BUILT_IN_THEME_IDS] as const; + +export type BuiltInThemeId = (typeof BUILT_IN_THEME_IDS)[number]; +export type MobileThemeId = (typeof MOBILE_THEME_IDS)[number]; +export type ThemeAppearance = "light" | "dark"; + +/** Product roles shared by web CSS, React Native tokens, and native surfaces. */ +export const THEME_COLOR_ROLES = [ + "canvas", + "chrome", + "toolbar", + "toolbarForeground", + "toolbarBorder", + "toolbarControl", + "toolbarControlForeground", + "toolbarControlHover", + "surface", + "surfaceRaised", + "surfaceOverlay", + "text", + "textMuted", + "border", + "input", + "focus", + "accent", + "accentForeground", + "secondary", + "secondaryForeground", + "muted", + "mutedForeground", + "placeholder", + "secondaryLabel", + "iconMuted", + "error", + "errorForeground", + "errorSurface", + "warning", + "warningForeground", + "warningSurface", + "update", + "updateForeground", + "updateSurface", + "accentSurface", + "accentSurfaceForeground", + "messageSurface", + "messageForeground", + "messageAction", + "messageActionForeground", + "messageActionHover", + "codeBackground", + "codeForeground", + "sidebar", + "sidebarForeground", + "sidebarMutedForeground", + "sidebarControlSurface", + "sidebarRowHover", + "sidebarRowActive", + "sidebarRowSelected", + "sidebarBorder", + "terminalBackground", + "terminalForeground", + "terminalCursor", + "terminalSelection", + "terminalScrollbar", + "terminalScrollbarHover", +] as const; + +export type ThemeColorRole = (typeof THEME_COLOR_ROLES)[number]; +export type ThemeColors = Readonly>; +export type ThemeVariants = Readonly>>; +export type ThemeDefinition = Readonly<{ + id: string; + label: string; + appearance: ThemeAppearance; + colors: ThemeColors; + variants?: ThemeVariants; + /** Groups related imported variants into one library card. */ + collection?: Readonly<{ id: string; label: string }>; + /** Allows reviewed built-ins to render product artwork over their sidebar. */ + sidebarArtwork?: boolean; + /** Generated from the guided editor's canvas and accent roles. */ + managed?: boolean; +}>; + +export const T3_CHAT_THEME: ThemeDefinition = { + id: "t3-chat", + label: "T3 Chat", + appearance: "light", + colors: { + canvas: "oklch(0.982446 0.010114 325.653)", + chrome: "oklch(0.982446 0.010114 325.653)", + toolbar: "oklch(0.982446 0.010114 325.653)", + toolbarForeground: "oklch(0.325698 0.116116 325.037)", + toolbarBorder: "oklch(0.856784 0.082879 328.911)", + toolbarControl: "oklch(0.939552 0.024286 321.664)", + toolbarControlForeground: "oklch(0.325698 0.116116 325.037)", + toolbarControlHover: "oklch(0.884525 0.041658 337.177)", + surface: "oklch(0.971835 0.012884 321.894)", + surfaceRaised: "oklch(0.988235 0.005049 325.615)", + surfaceOverlay: "oklch(1 0 0)", + text: "oklch(0.325698 0.116116 325.037)", + textMuted: "oklch(0.494754 0.190937 354.544)", + border: "oklch(0.923531 0.021247 328.096)", + input: "oklch(0.851713 0.055822 336.6)", + focus: "oklch(0.591646 0.217985 0.584)", + accent: "oklch(0.591646 0.217985 0.584)", + accentForeground: "oklch(1 0 0)", + secondary: "oklch(0.869588 0.06751 334.899)", + secondaryForeground: "oklch(0.444777 0.134061 324.799)", + muted: "oklch(0.802407 0.090963 345.892)", + mutedForeground: "oklch(0.428932 0.163929 354.332)", + placeholder: "oklch(0.549927 0.090215 323.149)", + secondaryLabel: "oklch(0.494754 0.190937 354.544)", + iconMuted: "oklch(0.494754 0.190937 354.544)", + error: "oklch(0.627117 0.248974 7.734)", + errorForeground: "oklch(0.458704 0.169677 3.815)", + errorSurface: "oklch(0.942787 0.032076 344.963)", + warning: "oklch(0.76859 0.164659 70.08)", + warningForeground: "oklch(0.54612 0.143036 48.949)", + warningSurface: "oklch(0.962901 0.015297 48.56)", + update: "oklch(0.591646 0.217985 0.584)", + updateForeground: "oklch(0.494754 0.190937 354.544)", + updateSurface: "oklch(0.930264 0.036194 341.45)", + accentSurface: "oklch(0.939552 0.024286 321.664)", + accentSurfaceForeground: "oklch(0.396296 0.025134 285.196)", + messageSurface: "oklch(0.926746 0.037898 332.6)", + messageForeground: "oklch(0.354591 0.093575 307.568)", + messageAction: "oklch(0.591646 0.217985 0.584)", + messageActionForeground: "oklch(1 0 0)", + messageActionHover: "oklch(0.539042 0.197866 0.305)", + codeBackground: "oklch(0.953855 0.019695 315.668)", + codeForeground: "oklch(0.445128 0.13005 307.026)", + sidebar: "oklch(0.928886 0.031178 322.592)", + sidebarForeground: "oklch(0.396296 0.025134 285.196)", + sidebarMutedForeground: "oklch(0.494754 0.190937 354.544)", + sidebarControlSurface: "oklch(0.978851 0.001321 106.424)", + sidebarRowHover: "oklch(0.978851 0.001321 106.424)", + sidebarRowActive: "oklch(0.978851 0.001321 106.424)", + sidebarRowSelected: "oklch(0.978851 0.001321 106.424)", + sidebarBorder: "oklch(0.938313 0.002552 48.717)", + terminalBackground: "oklch(0.982446 0.010114 325.653)", + terminalForeground: "oklch(0.325698 0.116116 325.037)", + terminalCursor: "oklch(0.591646 0.217985 0.584)", + terminalSelection: "oklch(0.869588 0.06751 334.899)", + terminalScrollbar: "oklch(0.851713 0.055822 336.6)", + terminalScrollbarHover: "oklch(0.802407 0.090963 345.892)", + }, + variants: { + dark: { + canvas: "oklch(0.22813 0.020366 307.469)", + chrome: "oklch(0.22813 0.020366 307.469)", + toolbar: "oklch(0.22813 0.020366 307.469)", + toolbarForeground: "oklch(0.980735 0.004092 301.426)", + toolbarBorder: "oklch(0.266943 0.015262 302.425)", + toolbarControl: "oklch(0.313674 0.030572 310.061)", + toolbarControlForeground: "oklch(0.848252 0.038248 307.961)", + toolbarControlHover: "oklch(0.364912 0.050794 308.491)", + surface: "oklch(0.267101 0.02016 311.799)", + surfaceRaised: "oklch(0.279864 0.021572 309.532)", + surfaceOverlay: "oklch(0.154761 0.01316 338.901)", + text: "oklch(0.980735 0.004092 301.426)", + textMuted: "oklch(0.880303 0.03077 342.696)", + border: "oklch(0.266943 0.015262 302.425)", + input: "oklch(0.266817 0.02897 344.461)", + focus: "oklch(0.591646 0.217985 0.584)", + accent: "oklch(0.460685 0.185347 4.099)", + accentForeground: "oklch(0.901233 0.057189 343.694)", + secondary: "oklch(0.313674 0.030572 310.061)", + secondaryForeground: "oklch(0.848252 0.038248 307.961)", + muted: "oklch(0.360924 0.021469 316.83)", + mutedForeground: "oklch(0.880303 0.03077 342.696)", + placeholder: "oklch(0.657087 0.028226 307.985)", + secondaryLabel: "oklch(0.880303 0.03077 342.696)", + iconMuted: "oklch(0.848252 0.038248 307.961)", + error: "oklch(0.458704 0.169677 3.815)", + errorForeground: "oklch(0.901233 0.057189 343.694)", + errorSurface: "oklch(0.259022 0.04799 340.062)", + warning: "oklch(0.76859 0.164659 70.08)", + warningForeground: "oklch(0.836861 0.164422 84.429)", + warningSurface: "oklch(0.321706 0.036256 60.806)", + update: "oklch(0.460685 0.185347 4.099)", + updateForeground: "oklch(0.901233 0.057189 343.694)", + updateSurface: "oklch(0.256077 0.063004 342.914)", + accentSurface: "oklch(0.364912 0.050794 308.491)", + accentSurfaceForeground: "oklch(0.964695 0.009139 341.803)", + messageSurface: "oklch(0.273791 0.025541 309.079)", + messageForeground: "oklch(0.949872 0.021269 306.838)", + messageAction: "oklch(0.460685 0.185347 4.099)", + messageActionForeground: "oklch(0.901233 0.057189 343.694)", + messageActionHover: "oklch(0.458754 0.184639 3.857)", + codeBackground: "oklch(0.22813 0.020366 307.469)", + codeForeground: "oklch(0.848703 0.064239 306.645)", + sidebar: "oklch(0.185778 0.019368 322.159)", + sidebarForeground: "oklch(0.967434 0.001326 286.375)", + sidebarMutedForeground: "oklch(0.880303 0.03077 342.696)", + sidebarControlSurface: "oklch(0.23366 0.026081 338.196)", + sidebarRowHover: "oklch(0.23366 0.026081 338.196)", + sidebarRowActive: "oklch(0.23366 0.026081 338.196)", + sidebarRowSelected: "oklch(0.23366 0.026081 338.196)", + sidebarBorder: "oklch(0.269132 0.030766 351.067)", + terminalBackground: "oklch(0.22813 0.020366 307.469)", + terminalForeground: "oklch(0.980735 0.004092 301.426)", + terminalCursor: "oklch(0.591646 0.217985 0.584)", + terminalSelection: "oklch(0.313674 0.030572 310.061)", + terminalScrollbar: "oklch(0.266817 0.02897 344.461)", + terminalScrollbarHover: "oklch(0.360924 0.021469 316.83)", + }, + }, + sidebarArtwork: true, +}; + +export const GROVE_THEME: ThemeDefinition = { + id: "grove", + label: "Grove", + appearance: "light", + colors: { + canvas: "oklch(0.972369 0.005497 157.15)", + chrome: "oklch(0.972369 0.005497 157.15)", + toolbar: "oklch(0.972369 0.005497 157.15)", + toolbarForeground: "oklch(0.222003 0.03479 328.979)", + toolbarBorder: "oklch(0.909438 0.021521 164.612)", + toolbarControl: "oklch(0.936464 0.014601 163.554)", + toolbarControlForeground: "oklch(0.222003 0.03479 328.979)", + toolbarControlHover: "oklch(0.909438 0.021521 164.612)", + surface: "oklch(0.972369 0.005497 157.15)", + surfaceRaised: "oklch(0.949276 0.004496 159.002)", + surfaceOverlay: "oklch(0.932695 0.003778 160.944)", + text: "oklch(0.222003 0.03479 328.979)", + textMuted: "oklch(0.540472 0.014944 326.176)", + border: "oklch(0.864831 0.01312 167.255)", + input: "oklch(0.829746 0.016084 168.234)", + focus: "oklch(0.523295 0.112292 158.089)", + accent: "oklch(0.523295 0.112292 158.089)", + accentForeground: "oklch(0.990339 0.008411 325.64)", + secondary: "oklch(0.936464 0.014601 163.554)", + secondaryForeground: "oklch(0.222003 0.03479 328.979)", + muted: "oklch(0.945455 0.012308 162.879)", + mutedForeground: "oklch(0.527266 0.012309 320.683)", + placeholder: "oklch(0.529681 0.01551 326.299)", + secondaryLabel: "oklch(0.540472 0.014944 326.176)", + iconMuted: "oklch(0.540472 0.014944 326.176)", + error: "oklch(0.637823 0.237287 25.436)", + errorForeground: "oklch(0.509494 0.208583 28.513)", + errorSurface: "oklch(0.936968 0.014243 26.295)", + warning: "oklch(0.772406 0.172798 65.367)", + warningForeground: "oklch(0.545036 0.155019 45.359)", + warningSurface: "oklch(0.953175 0.02009 93.379)", + update: "oklch(0.523295 0.112292 158.089)", + updateForeground: "oklch(0.388012 0.080082 158.768)", + updateSurface: "oklch(0.900411 0.02384 164.795)", + accentSurface: "oklch(0.909438 0.021521 164.612)", + accentSurfaceForeground: "oklch(0.222003 0.03479 328.979)", + messageSurface: "oklch(0.891377 0.026164 164.929)", + messageForeground: "oklch(0.222003 0.03479 328.979)", + messageAction: "oklch(0.535028 0.106403 77.549)", + messageActionForeground: "oklch(0.990339 0.008411 325.64)", + messageActionHover: "oklch(0.488753 0.096536 77.829)", + codeBackground: "oklch(0.955888 0.004783 158.391)", + codeForeground: "oklch(0.222003 0.03479 328.979)", + sidebar: "oklch(0.936464 0.014601 163.554)", + sidebarForeground: "oklch(0.222003 0.03479 328.979)", + sidebarMutedForeground: "oklch(0.515606 0.011938 318.897)", + sidebarControlSurface: "oklch(0.88585 0.011734 166.331)", + sidebarRowHover: "oklch(0.886676 0.027374 164.983)", + sidebarRowActive: "oklch(0.85335 0.03597 165.158)", + sidebarRowSelected: "oklch(0.836654 0.040284 165.149)", + sidebarBorder: "oklch(0.860274 0.010287 168.339)", + terminalBackground: "oklch(0.972369 0.005497 157.15)", + terminalForeground: "oklch(0.222003 0.03479 328.979)", + terminalCursor: "oklch(0.523295 0.112292 158.089)", + terminalSelection: "oklch(0.891377 0.026164 164.929)", + terminalScrollbar: "oklch(0.824752 0.001392 294.641)", + terminalScrollbarHover: "oklch(0.755495 0.004415 318.776)", + }, + variants: { + dark: { + canvas: "oklch(0.260865 0.02152 162.75)", + chrome: "oklch(0.260865 0.02152 162.75)", + toolbar: "oklch(0.260865 0.02152 162.75)", + toolbarForeground: "oklch(0.990339 0.008411 325.64)", + toolbarBorder: "oklch(0.464636 0.066083 158.72)", + toolbarControl: "oklch(0.380487 0.048313 159.608)", + toolbarControlForeground: "oklch(0.990339 0.008411 325.64)", + toolbarControlHover: "oklch(0.437021 0.060312 158.962)", + surface: "oklch(0.260865 0.02152 162.75)", + surfaceRaised: "oklch(0.363192 0.016572 165.32)", + surfaceOverlay: "oklch(0.411828 0.014378 166.627)", + text: "oklch(0.990339 0.008411 325.64)", + textMuted: "oklch(0.666747 0.004239 187.292)", + border: "oklch(0.457475 0.044046 160.971)", + input: "oklch(0.519849 0.049896 160.863)", + focus: "oklch(0.796228 0.133058 157.319)", + accent: "oklch(0.796228 0.133058 157.319)", + accentForeground: "oklch(0.222003 0.03479 328.979)", + secondary: "oklch(0.380487 0.048313 159.608)", + secondaryForeground: "oklch(0.990339 0.008411 325.64)", + muted: "oklch(0.339728 0.039456 160.274)", + mutedForeground: "oklch(0.715427 0.010896 171.428)", + placeholder: "oklch(0.739243 0.002222 223.225)", + secondaryLabel: "oklch(0.666747 0.004239 187.292)", + iconMuted: "oklch(0.666747 0.004239 187.292)", + error: "oklch(0.655108 0.221148 23.473)", + errorForeground: "oklch(0.704237 0.187511 22.228)", + errorSurface: "oklch(0.312773 0.02923 32.121)", + warning: "oklch(0.772406 0.172798 65.367)", + warningForeground: "oklch(0.829017 0.171221 81.038)", + warningSurface: "oklch(0.345524 0.046882 99.736)", + update: "oklch(0.796228 0.133058 157.319)", + updateForeground: "oklch(0.86276 0.089288 159.704)", + updateSurface: "oklch(0.448116 0.062637 158.86)", + accentSurface: "oklch(0.437021 0.060312 158.962)", + accentSurfaceForeground: "oklch(0.990339 0.008411 325.64)", + messageSurface: "oklch(0.470111 0.067221 158.676)", + messageForeground: "oklch(0.990339 0.008411 325.64)", + messageAction: "oklch(0.791603 0.129713 83.299)", + messageActionForeground: "oklch(0.222003 0.03479 328.979)", + messageActionHover: "oklch(0.815227 0.117902 84.21)", + codeBackground: "oklch(0.312979 0.018942 164.082)", + codeForeground: "oklch(0.990339 0.008411 325.64)", + sidebar: "oklch(0.309925 0.032827 160.944)", + sidebarForeground: "oklch(0.990339 0.008411 325.64)", + sidebarMutedForeground: "oklch(0.711387 0.007643 175.89)", + sidebarControlSurface: "oklch(0.432727 0.024549 163.654)", + sidebarRowHover: "oklch(0.374959 0.047124 159.686)", + sidebarRowActive: "oklch(0.41688 0.056069 159.165)", + sidebarRowSelected: "oklch(0.437466 0.060406 158.958)", + sidebarBorder: "oklch(0.569253 0.015933 167.062)", + terminalBackground: "oklch(0.260865 0.02152 162.75)", + terminalForeground: "oklch(0.990339 0.008411 325.64)", + terminalCursor: "oklch(0.796228 0.133058 157.319)", + terminalSelection: "oklch(0.464636 0.066083 158.72)", + terminalScrollbar: "oklch(0.594692 0.006862 176.022)", + terminalScrollbarHover: "oklch(0.687968 0.00354 193.55)", + }, + }, + sidebarArtwork: true, +}; + +export const OCEAN_THEME: ThemeDefinition = { + id: "ocean", + label: "Ocean", + appearance: "light", + colors: { + canvas: "oklch(0.974199 0.002856 241.597)", + chrome: "oklch(0.974199 0.002856 241.597)", + toolbar: "oklch(0.974199 0.002856 241.597)", + toolbarForeground: "oklch(0.222003 0.03479 328.979)", + toolbarBorder: "oklch(0.91295 0.018827 241.836)", + toolbarControl: "oklch(0.939254 0.01193 241.729)", + toolbarControlForeground: "oklch(0.222003 0.03479 328.979)", + toolbarControlHover: "oklch(0.91295 0.018827 241.836)", + surface: "oklch(0.974199 0.002856 241.597)", + surfaceRaised: "oklch(0.951058 0.002962 258.339)", + surfaceOverlay: "oklch(0.934442 0.003181 269.1)", + text: "oklch(0.222003 0.03479 328.979)", + textMuted: "oklch(0.541555 0.017468 323.531)", + border: "oklch(0.867646 0.013482 252.362)", + input: "oklch(0.832939 0.017389 252.598)", + focus: "oklch(0.536684 0.120219 247.01)", + accent: "oklch(0.536684 0.120219 247.01)", + accentForeground: "oklch(0.990339 0.008411 325.64)", + secondary: "oklch(0.939254 0.01193 241.729)", + secondaryForeground: "oklch(0.222003 0.03479 328.979)", + muted: "oklch(0.948004 0.009649 241.695)", + mutedForeground: "oklch(0.528741 0.01828 313.823)", + placeholder: "oklch(0.530733 0.01795 323.79)", + secondaryLabel: "oklch(0.541555 0.017468 323.531)", + iconMuted: "oklch(0.541555 0.017468 323.531)", + error: "oklch(0.637823 0.237287 25.436)", + errorForeground: "oklch(0.509494 0.208583 28.513)", + errorSurface: "oklch(0.938747 0.016377 7.186)", + warning: "oklch(0.772406 0.172798 65.367)", + warningForeground: "oklch(0.546927 0.155556 45.359)", + warningSurface: "oklch(0.954846 0.016009 81.731)", + update: "oklch(0.536684 0.120219 247.01)", + updateForeground: "oklch(0.397497 0.084999 246.523)", + updateSurface: "oklch(0.904165 0.021144 241.874)", + accentSurface: "oklch(0.91295 0.018827 241.836)", + accentSurfaceForeground: "oklch(0.222003 0.03479 328.979)", + messageSurface: "oklch(0.895373 0.023469 241.913)", + messageForeground: "oklch(0.222003 0.03479 328.979)", + messageAction: "oklch(0.493961 0.08175 201.584)", + messageActionForeground: "oklch(0.990339 0.008411 325.64)", + messageActionHover: "oklch(0.45151 0.074407 201.516)", + codeBackground: "oklch(0.957684 0.002906 253.68)", + codeForeground: "oklch(0.222003 0.03479 328.979)", + sidebar: "oklch(0.939254 0.01193 241.729)", + sidebarForeground: "oklch(0.222003 0.03479 328.979)", + sidebarMutedForeground: "oklch(0.517366 0.018944 311.433)", + sidebarControlSurface: "oklch(0.888479 0.011475 251.638)", + sidebarRowHover: "oklch(0.890798 0.024681 241.933)", + sidebarRowActive: "oklch(0.858363 0.033325 242.089)", + sidebarRowSelected: "oklch(0.842113 0.037689 242.174)", + sidebarBorder: "oklch(0.862823 0.011384 256.926)", + terminalBackground: "oklch(0.974199 0.002856 241.597)", + terminalForeground: "oklch(0.222003 0.03479 328.979)", + terminalCursor: "oklch(0.536684 0.120219 247.01)", + terminalSelection: "oklch(0.895373 0.023469 241.913)", + terminalScrollbar: "oklch(0.826271 0.006191 305.456)", + terminalScrollbarHover: "oklch(0.756866 0.008685 313.721)", + }, + variants: { + dark: { + canvas: "oklch(0.242641 0.024125 250.573)", + chrome: "oklch(0.242641 0.024125 250.573)", + toolbar: "oklch(0.242641 0.024125 250.573)", + toolbarForeground: "oklch(0.990339 0.008411 325.64)", + toolbarBorder: "oklch(0.439946 0.0561 243.479)", + toolbarControl: "oklch(0.358725 0.043145 244.911)", + toolbarControlForeground: "oklch(0.990339 0.008411 325.64)", + toolbarControlHover: "oklch(0.413315 0.051874 243.855)", + surface: "oklch(0.242641 0.024125 250.573)", + surfaceRaised: "oklch(0.348439 0.019942 253.696)", + surfaceOverlay: "oklch(0.398517 0.018232 255.72)", + text: "oklch(0.990339 0.008411 325.64)", + textMuted: "oklch(0.652227 0.01149 273.31)", + border: "oklch(0.438653 0.039496 245.44)", + input: "oklch(0.500905 0.043574 244.781)", + focus: "oklch(0.758933 0.105833 241.548)", + accent: "oklch(0.758933 0.105833 241.548)", + accentForeground: "oklch(0.222003 0.03479 328.979)", + secondary: "oklch(0.358725 0.043145 244.911)", + secondaryForeground: "oklch(0.990339 0.008411 325.64)", + muted: "oklch(0.319287 0.036766 246.065)", + mutedForeground: "oklch(0.691936 0.016294 261.588)", + placeholder: "oklch(0.721641 0.010192 281.271)", + secondaryLabel: "oklch(0.652227 0.01149 273.31)", + iconMuted: "oklch(0.652227 0.01149 273.31)", + error: "oklch(0.655108 0.221148 23.473)", + errorForeground: "oklch(0.702184 0.189226 22.228)", + errorSurface: "oklch(0.298933 0.036443 350.094)", + warning: "oklch(0.772406 0.172798 65.367)", + warningForeground: "oklch(0.829017 0.171221 81.038)", + warningSurface: "oklch(0.329449 0.028712 84.495)", + update: "oklch(0.758933 0.105833 241.548)", + updateForeground: "oklch(0.840844 0.069217 240.151)", + updateSurface: "oklch(0.424017 0.053575 243.695)", + accentSurface: "oklch(0.413315 0.051874 243.855)", + accentSurfaceForeground: "oklch(0.990339 0.008411 325.64)", + messageSurface: "oklch(0.445224 0.056936 243.413)", + messageForeground: "oklch(0.990339 0.008411 325.64)", + messageAction: "oklch(0.793363 0.105022 199.893)", + messageActionForeground: "oklch(0.222003 0.03479 328.979)", + messageActionHover: "oklch(0.815308 0.096174 199.862)", + codeBackground: "oklch(0.29661 0.021883 251.968)", + codeForeground: "oklch(0.990339 0.008411 325.64)", + sidebar: "oklch(0.290387 0.032043 247.274)", + sidebarForeground: "oklch(0.990339 0.008411 325.64)", + sidebarMutedForeground: "oklch(0.69099 0.01395 266.424)", + sidebarControlSurface: "oklch(0.417822 0.02535 250.162)", + sidebarRowHover: "oklch(0.353381 0.042285 245.043)", + sidebarRowActive: "oklch(0.393878 0.048778 244.179)", + sidebarRowSelected: "oklch(0.413744 0.051943 243.848)", + sidebarBorder: "oklch(0.55859 0.019001 256.223)", + terminalBackground: "oklch(0.242641 0.024125 250.573)", + terminalForeground: "oklch(0.990339 0.008411 325.64)", + terminalCursor: "oklch(0.758933 0.105833 241.548)", + terminalSelection: "oklch(0.439946 0.0561 243.479)", + terminalScrollbar: "oklch(0.58613 0.012959 267.22)", + terminalScrollbarHover: "oklch(0.681569 0.010909 276.465)", + }, + }, + sidebarArtwork: true, +}; + +export const EMBER_THEME: ThemeDefinition = { + id: "ember", + label: "Ember", + appearance: "light", + colors: { + canvas: "oklch(0.976527 0.002685 60.725)", + chrome: "oklch(0.976527 0.002685 60.725)", + toolbar: "oklch(0.976527 0.002685 60.725)", + toolbarForeground: "oklch(0.222003 0.03479 328.979)", + toolbarBorder: "oklch(0.916502 0.01832 49.597)", + toolbarControl: "oklch(0.942267 0.01151 50.785)", + toolbarControlForeground: "oklch(0.222003 0.03479 328.979)", + toolbarControlHover: "oklch(0.916502 0.01832 49.597)", + surface: "oklch(0.976527 0.002685 60.725)", + surfaceRaised: "oklch(0.953321 0.002701 42.266)", + surfaceOverlay: "oklch(0.936659 0.002879 29.96)", + text: "oklch(0.222003 0.03479 328.979)", + textMuted: "oklch(0.543023 0.017316 331.964)", + border: "oklch(0.870631 0.013204 39.431)", + input: "oklch(0.836213 0.017153 38.661)", + focus: "oklch(0.552831 0.129438 44.656)", + accent: "oklch(0.552831 0.129438 44.656)", + accentForeground: "oklch(0.990339 0.008411 325.64)", + secondary: "oklch(0.942267 0.01151 50.785)", + secondaryForeground: "oklch(0.222003 0.03479 328.979)", + muted: "oklch(0.950842 0.009273 51.528)", + mutedForeground: "oklch(0.530413 0.018453 341.181)", + placeholder: "oklch(0.532339 0.017796 331.748)", + secondaryLabel: "oklch(0.543023 0.017316 331.964)", + iconMuted: "oklch(0.543023 0.017316 331.964)", + error: "oklch(0.637823 0.237287 25.436)", + errorForeground: "oklch(0.509494 0.208583 28.513)", + errorSurface: "oklch(0.941094 0.019938 19.375)", + warning: "oklch(0.772406 0.172798 65.367)", + warningForeground: "oklch(0.549154 0.156188 45.359)", + warningSurface: "oklch(0.957148 0.020843 76.702)", + update: "oklch(0.552831 0.129438 44.656)", + updateForeground: "oklch(0.408647 0.091207 45.037)", + updateSurface: "oklch(0.907902 0.020621 49.36)", + accentSurface: "oklch(0.916502 0.01832 49.597)", + accentSurfaceForeground: "oklch(0.222003 0.03479 328.979)", + messageSurface: "oklch(0.899296 0.022939 49.163)", + messageForeground: "oklch(0.222003 0.03479 328.979)", + messageAction: "oklch(0.516323 0.161628 24.82)", + messageActionForeground: "oklch(0.990339 0.008411 325.64)", + messageActionHover: "oklch(0.471223 0.145843 24.688)", + codeBackground: "oklch(0.959965 0.002668 47.512)", + codeForeground: "oklch(0.222003 0.03479 328.979)", + sidebar: "oklch(0.942267 0.01151 50.785)", + sidebarForeground: "oklch(0.222003 0.03479 328.979)", + sidebarMutedForeground: "oklch(0.519146 0.019214 343.427)", + sidebarControlSurface: "oklch(0.891332 0.011179 40.596)", + sidebarRowHover: "oklch(0.894819 0.024151 49.073)", + sidebarRowActive: "oklch(0.863104 0.032855 48.586)", + sidebarRowSelected: "oklch(0.84723 0.037292 48.403)", + sidebarBorder: "oklch(0.865593 0.011154 35.246)", + terminalBackground: "oklch(0.976527 0.002685 60.725)", + terminalForeground: "oklch(0.222003 0.03479 328.979)", + terminalCursor: "oklch(0.552831 0.129438 44.656)", + terminalSelection: "oklch(0.899296 0.022939 49.163)", + terminalScrollbar: "oklch(0.828185 0.005884 349.533)", + terminalScrollbarHover: "oklch(0.758584 0.008423 341.16)", + }, + variants: { + dark: { + canvas: "oklch(0.245899 0.019144 42.044)", + chrome: "oklch(0.245899 0.019144 42.044)", + toolbar: "oklch(0.245899 0.019144 42.044)", + toolbarForeground: "oklch(0.990339 0.008411 325.64)", + toolbarBorder: "oklch(0.442681 0.0608 50.795)", + toolbarControl: "oklch(0.361499 0.044052 49.515)", + toolbarControlForeground: "oklch(0.990339 0.008411 325.64)", + toolbarControlHover: "oklch(0.416048 0.055354 50.484)", + surface: "oklch(0.245899 0.019144 42.044)", + surfaceRaised: "oklch(0.351262 0.01565 37.592)", + surfaceOverlay: "oklch(0.401111 0.014308 34.896)", + text: "oklch(0.990339 0.008411 325.64)", + textMuted: "oklch(0.654017 0.009505 13.287)", + border: "oklch(0.44099 0.040202 48.807)", + input: "oklch(0.503003 0.045721 49.44)", + focus: "oklch(0.762174 0.124117 52.082)", + accent: "oklch(0.762174 0.124117 52.082)", + accentForeground: "oklch(0.222003 0.03479 328.979)", + secondary: "oklch(0.361499 0.044052 49.515)", + secondaryForeground: "oklch(0.990339 0.008411 325.64)", + muted: "oklch(0.322144 0.03574 48.309)", + mutedForeground: "oklch(0.692479 0.015227 30.963)", + placeholder: "oklch(0.723533 0.008741 4.515)", + secondaryLabel: "oklch(0.654017 0.009505 13.287)", + iconMuted: "oklch(0.654017 0.009505 13.287)", + error: "oklch(0.655108 0.221148 23.473)", + errorForeground: "oklch(0.702184 0.189226 22.228)", + errorSurface: "oklch(0.310955 0.059624 24.334)", + warning: "oklch(0.772406 0.172798 65.367)", + warningForeground: "oklch(0.829017 0.171221 81.038)", + warningSurface: "oklch(0.339137 0.055638 66.911)", + update: "oklch(0.762174 0.124117 52.082)", + updateForeground: "oklch(0.841456 0.079585 53.521)", + updateSurface: "oklch(0.426749 0.057547 50.618)", + accentSurface: "oklch(0.416048 0.055354 50.484)", + accentSurfaceForeground: "oklch(0.990339 0.008411 325.64)", + messageSurface: "oklch(0.447961 0.061874 50.849)", + messageForeground: "oklch(0.990339 0.008411 325.64)", + messageAction: "oklch(0.747955 0.135578 29.432)", + messageActionForeground: "oklch(0.222003 0.03479 328.979)", + messageActionHover: "oklch(0.775116 0.117953 29.014)", + codeBackground: "oklch(0.299662 0.017229 39.973)", + codeForeground: "oklch(0.990339 0.008411 325.64)", + sidebar: "oklch(0.293349 0.029554 46.882)", + sidebarForeground: "oklch(0.990339 0.008411 325.64)", + sidebarMutedForeground: "oklch(0.691874 0.012538 24.638)", + sidebarControlSurface: "oklch(0.420227 0.022893 43.226)", + sidebarRowHover: "oklch(0.356163 0.042933 49.385)", + sidebarRowActive: "oklch(0.396617 0.051353 50.201)", + sidebarRowSelected: "oklch(0.416477 0.055442 50.489)", + sidebarBorder: "oklch(0.560372 0.016998 36.179)", + terminalBackground: "oklch(0.245899 0.019144 42.044)", + terminalForeground: "oklch(0.990339 0.008411 325.64)", + terminalCursor: "oklch(0.762174 0.124117 52.082)", + terminalSelection: "oklch(0.442681 0.0608 50.795)", + terminalScrollbar: "oklch(0.587861 0.010463 20.444)", + terminalScrollbarHover: "oklch(0.682876 0.009156 9.796)", + }, + }, + sidebarArtwork: true, +}; + +export const IRIS_THEME: ThemeDefinition = { + id: "iris", + label: "Iris", + appearance: "light", + colors: { + canvas: "oklch(0.976531 0.003855 303.226)", + chrome: "oklch(0.976531 0.003855 303.226)", + toolbar: "oklch(0.976531 0.003855 303.226)", + toolbarForeground: "oklch(0.222003 0.03479 328.979)", + toolbarBorder: "oklch(0.914882 0.022965 299.986)", + toolbarControl: "oklch(0.941387 0.014687 300.474)", + toolbarControlForeground: "oklch(0.222003 0.03479 328.979)", + toolbarControlHover: "oklch(0.914882 0.022965 299.986)", + surface: "oklch(0.976531 0.003855 303.226)", + surfaceRaised: "oklch(0.953326 0.004536 307.676)", + surfaceOverlay: "oklch(0.936665 0.005041 310.132)", + text: "oklch(0.222003 0.03479 328.979)", + textMuted: "oklch(0.543042 0.018894 325.652)", + border: "oklch(0.869608 0.018226 303.859)", + input: "oklch(0.834773 0.023405 303.676)", + focus: "oklch(0.525348 0.15373 294.176)", + accent: "oklch(0.525348 0.15373 294.176)", + accentForeground: "oklch(0.990339 0.008411 325.64)", + secondary: "oklch(0.941387 0.014687 300.474)", + secondaryForeground: "oklch(0.222003 0.03479 328.979)", + muted: "oklch(0.950194 0.011956 300.733)", + mutedForeground: "oklch(0.529955 0.022319 321.556)", + placeholder: "oklch(0.532177 0.019333 325.784)", + secondaryLabel: "oklch(0.543042 0.018894 325.652)", + iconMuted: "oklch(0.543042 0.018894 325.652)", + error: "oklch(0.637823 0.237287 25.436)", + errorForeground: "oklch(0.509494 0.208583 28.513)", + errorSurface: "oklch(0.941043 0.019582 4.235)", + warning: "oklch(0.772406 0.172798 65.367)", + warningForeground: "oklch(0.549154 0.156188 45.359)", + warningSurface: "oklch(0.957054 0.016197 69.932)", + update: "oklch(0.525348 0.15373 294.176)", + updateForeground: "oklch(0.389926 0.10825 294.547)", + updateSurface: "oklch(0.90602 0.025754 299.867)", + accentSurface: "oklch(0.914882 0.022965 299.986)", + accentSurfaceForeground: "oklch(0.222003 0.03479 328.979)", + messageSurface: "oklch(0.897143 0.028558 299.758)", + messageForeground: "oklch(0.222003 0.03479 328.979)", + messageAction: "oklch(0.516084 0.185229 340.776)", + messageActionForeground: "oklch(0.990339 0.008411 325.64)", + messageActionHover: "oklch(0.471003 0.16748 340.687)", + codeBackground: "oklch(0.95997 0.004338 306.542)", + codeForeground: "oklch(0.222003 0.03479 328.979)", + sidebar: "oklch(0.941387 0.014687 300.474)", + sidebarForeground: "oklch(0.222003 0.03479 328.979)", + sidebarMutedForeground: "oklch(0.518417 0.023683 320.681)", + sidebarControlSurface: "oklch(0.890512 0.0155 303.803)", + sidebarRowHover: "oklch(0.892522 0.030022 299.704)", + sidebarRowActive: "oklch(0.85971 0.040501 299.36)", + sidebarRowSelected: "oklch(0.843236 0.045818 299.198)", + sidebarBorder: "oklch(0.864805 0.015938 305.371)", + terminalBackground: "oklch(0.976531 0.003855 303.226)", + terminalForeground: "oklch(0.222003 0.03479 328.979)", + terminalCursor: "oklch(0.525348 0.15373 294.176)", + terminalSelection: "oklch(0.897143 0.028558 299.758)", + terminalScrollbar: "oklch(0.828195 0.008526 318.858)", + terminalScrollbarHover: "oklch(0.758596 0.010892 321.538)", + }, + variants: { + dark: { + canvas: "oklch(0.225975 0.031062 293.741)", + chrome: "oklch(0.225975 0.031062 293.741)", + toolbar: "oklch(0.225975 0.031062 293.741)", + toolbarForeground: "oklch(0.990339 0.008411 325.64)", + toolbarBorder: "oklch(0.395417 0.085554 294.182)", + toolbarControl: "oklch(0.325405 0.063614 294.23)", + toolbarControlForeground: "oklch(0.990339 0.008411 325.64)", + toolbarControlHover: "oklch(0.372436 0.07841 294.204)", + surface: "oklch(0.225975 0.031062 293.741)", + surfaceRaised: "oklch(0.335291 0.026008 296.394)", + surfaceOverlay: "oklch(0.386739 0.024023 297.509)", + text: "oklch(0.990339 0.008411 325.64)", + textMuted: "oklch(0.640465 0.016197 304.171)", + border: "oklch(0.40874 0.058536 295.893)", + input: "oklch(0.46756 0.065775 296.265)", + focus: "oklch(0.671712 0.169136 293.929)", + accent: "oklch(0.671712 0.169136 293.929)", + accentForeground: "oklch(0.222003 0.03479 328.979)", + secondary: "oklch(0.325405 0.063614 294.23)", + secondaryForeground: "oklch(0.990339 0.008411 325.64)", + muted: "oklch(0.291515 0.05276 294.209)", + mutedForeground: "oklch(0.663321 0.025932 301.862)", + placeholder: "oklch(0.706249 0.014508 306.607)", + secondaryLabel: "oklch(0.640465 0.016197 304.171)", + iconMuted: "oklch(0.640465 0.016197 304.171)", + error: "oklch(0.655108 0.221148 23.473)", + errorForeground: "oklch(0.702184 0.189226 22.228)", + errorSurface: "oklch(0.291658 0.054707 352.238)", + warning: "oklch(0.772406 0.172798 65.367)", + warningForeground: "oklch(0.829017 0.171221 81.038)", + warningSurface: "oklch(0.318952 0.033845 51.646)", + update: "oklch(0.671712 0.169136 293.929)", + updateForeground: "oklch(0.785032 0.108439 296.344)", + updateSurface: "oklch(0.381668 0.081286 294.195)", + accentSurface: "oklch(0.372436 0.07841 294.204)", + accentSurfaceForeground: "oklch(0.990339 0.008411 325.64)", + messageSurface: "oklch(0.399975 0.086965 294.177)", + messageForeground: "oklch(0.990339 0.008411 325.64)", + messageAction: "oklch(0.789904 0.130063 337.621)", + messageActionForeground: "oklch(0.222003 0.03479 328.979)", + messageActionHover: "oklch(0.813537 0.114101 337.23)", + codeBackground: "oklch(0.281873 0.028308 295.193)", + codeForeground: "oklch(0.990339 0.008411 325.64)", + sidebar: "oklch(0.266743 0.044689 294.138)", + sidebarForeground: "oklch(0.990339 0.008411 325.64)", + sidebarMutedForeground: "oklch(0.668773 0.021522 302.949)", + sidebarControlSurface: "oklch(0.399977 0.035678 297.031)", + sidebarRowHover: "oklch(0.320808 0.062152 294.23)", + sidebarRowActive: "oklch(0.355677 0.073167 294.217)", + sidebarRowSelected: "oklch(0.372806 0.078525 294.203)", + sidebarBorder: "oklch(0.545895 0.027522 299.871)", + terminalBackground: "oklch(0.225975 0.031062 293.741)", + terminalForeground: "oklch(0.990339 0.008411 325.64)", + terminalCursor: "oklch(0.671712 0.169136 293.929)", + terminalSelection: "oklch(0.395417 0.085554 294.182)", + terminalScrollbar: "oklch(0.578663 0.017888 302.229)", + terminalScrollbarHover: "oklch(0.676012 0.015271 305.433)", + }, + }, + sidebarArtwork: true, +}; + +export const BUILT_IN_THEMES: ReadonlyArray = [ + T3_CHAT_THEME, + GROVE_THEME, + OCEAN_THEME, + EMBER_THEME, + IRIS_THEME, +]; + +export function getBuiltInTheme(id: string): ThemeDefinition | null { + return BUILT_IN_THEMES.find((theme) => theme.id === id) ?? null; +} + +export function getThemeColorsForAppearance( + theme: ThemeDefinition, + appearance: ThemeAppearance, +): ThemeColors | null { + if (theme.appearance === appearance) return theme.colors; + return theme.variants?.[appearance] ?? null; +} diff --git a/packages/shared/src/themePreview.test.ts b/packages/shared/src/themePreview.test.ts new file mode 100644 index 000000000..f1cc02e10 --- /dev/null +++ b/packages/shared/src/themePreview.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + mixThemePreviewBase, + STANDARD_THEME_PREVIEW_COLORS, + THEME_PREVIEW_RENDER_SPECS, +} from "./themePreview.js"; + +describe("theme preview", () => { + it("keeps the desktop preview geometry stable across clients", () => { + expect(THEME_PREVIEW_RENDER_SPECS.light!.accent.center).toEqual([0.72, 0.22]); + expect(THEME_PREVIEW_RENDER_SPECS.dark!.accent.middleOpacity).toBe(0.62); + expect(THEME_PREVIEW_RENDER_SPECS.dark!.action.center).toEqual([0.82, 0.18]); + }); + + it("mixes the standard canvas bases in OKLab", () => { + expect(mixThemePreviewBase(STANDARD_THEME_PREVIEW_COLORS.light!, "light")).toBe("#fdfdfd"); + expect(mixThemePreviewBase(STANDARD_THEME_PREVIEW_COLORS.dark!, "dark")).toBe("#0a0a0a"); + }); +}); diff --git a/packages/shared/src/themePreview.ts b/packages/shared/src/themePreview.ts new file mode 100644 index 000000000..edacc15db --- /dev/null +++ b/packages/shared/src/themePreview.ts @@ -0,0 +1,145 @@ +import type { ThemeAppearance } from "./themePalettes.js"; + +export type ThemePreviewColors = Readonly<{ + canvas: string; + accent: string; + messageAction: string; +}>; + +/** The standard Pylon artwork is not a built-in theme, so its preview colors live here. */ +export const STANDARD_THEME_PREVIEW_COLORS: Readonly> = + { + light: { + canvas: "#fcfcfc", + accent: "#f4f4f5", + messageAction: "#4f46e5", + }, + dark: { + canvas: "#0a0a0a", + accent: "#1c1c1f", + messageAction: "#8b9cff", + }, + }; + +export type ThemePreviewRenderSpec = Readonly<{ + baseTarget: string; + baseWeight: number; + accent: Readonly<{ + center: readonly [x: number, y: number]; + middleOffset: number; + middleOpacity: number; + endOffset: number; + }>; + action: Readonly<{ + center: readonly [x: number, y: number]; + startOpacity: number; + endOffset: number; + }>; + scale: number; + blurAt56Px: number; +}>; + +/** Shared geometry and falloff for the web and native theme preview orbs. */ +export const THEME_PREVIEW_RENDER_SPECS: Readonly> = + { + light: { + baseTarget: "#ffffff", + baseWeight: 0.8, + accent: { + center: [0.72, 0.22], + middleOffset: 0.28, + middleOpacity: 0.72, + endOffset: 0.58, + }, + action: { + center: [0.18, 0.82], + startOpacity: 0.45, + endOffset: 0.55, + }, + scale: 1.1, + blurAt56Px: 3, + }, + dark: { + baseTarget: "#09090b", + baseWeight: 0.8, + accent: { + center: [0.28, 0.78], + middleOffset: 0.28, + middleOpacity: 0.62, + endOffset: 0.58, + }, + action: { + center: [0.82, 0.18], + startOpacity: 0.45, + endOffset: 0.55, + }, + scale: 1.1, + blurAt56Px: 3, + }, + }; + +type Oklab = Readonly<{ l: number; a: number; b: number }>; + +const OKLCH_PATTERN = /^oklch\(\s*([\d.]+)\s+([\d.]+)\s+(-?[\d.]+)/; +const HEX_PATTERN = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i; + +function srgbToLinear(value: number): number { + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; +} + +function linearToSrgb(value: number): number { + const converted = value <= 0.0031308 ? 12.92 * value : 1.055 * value ** (1 / 2.4) - 0.055; + return Math.round(Math.min(1, Math.max(0, converted)) * 255); +} + +function parseOklab(value: string): Oklab | null { + const oklch = OKLCH_PATTERN.exec(value); + if (oklch) { + const lightness = Number(oklch[1]); + const chroma = Number(oklch[2]); + const hue = (Number(oklch[3]) * Math.PI) / 180; + return { l: lightness, a: chroma * Math.cos(hue), b: chroma * Math.sin(hue) }; + } + + const hex = HEX_PATTERN.exec(value); + if (!hex) return null; + const red = srgbToLinear(Number.parseInt(hex[1]!, 16) / 255); + const green = srgbToLinear(Number.parseInt(hex[2]!, 16) / 255); + const blue = srgbToLinear(Number.parseInt(hex[3]!, 16) / 255); + const lRoot = Math.cbrt(0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue); + const mRoot = Math.cbrt(0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue); + const sRoot = Math.cbrt(0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue); + return { + l: 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot, + a: 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot, + b: 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot, + }; +} + +function oklabToHex(color: Oklab): string { + const lPrime = color.l + 0.3963377774 * color.a + 0.2158037573 * color.b; + const mPrime = color.l - 0.1055613458 * color.a - 0.0638541728 * color.b; + const sPrime = color.l - 0.0894841775 * color.a - 1.291485548 * color.b; + const l = lPrime ** 3; + const m = mPrime ** 3; + const s = sPrime ** 3; + const channels = [ + linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), + linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), + linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s), + ]; + return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; +} + +export function mixThemePreviewBase(colors: ThemePreviewColors, mode: ThemeAppearance): string { + const spec = THEME_PREVIEW_RENDER_SPECS[mode]!; + const canvas = parseOklab(colors.canvas); + const target = parseOklab(spec.baseTarget); + if (!canvas || !target) return colors.canvas; + const targetWeight = 1 - spec.baseWeight; + return oklabToHex({ + l: canvas.l * spec.baseWeight + target.l * targetWeight, + a: canvas.a * spec.baseWeight + target.a * targetWeight, + b: canvas.b * spec.baseWeight + target.b * targetWeight, + }); +} diff --git a/scripts/mobile-showcase.config.ts b/scripts/mobile-showcase.config.ts index 7f1123968..45a9f474f 100644 --- a/scripts/mobile-showcase.config.ts +++ b/scripts/mobile-showcase.config.ts @@ -1,3 +1,9 @@ +import { + MOBILE_DEFAULT_THEME_ID, + MOBILE_THEME_IDS, + type MobileThemeId, +} from "@t3tools/shared/themePalettes"; + import { SHOWCASE_SCENES, type ShowcaseScene } from "./mobile-showcase-environment.ts"; export { SHOWCASE_SCENES }; @@ -5,6 +11,11 @@ export type { ShowcaseScene }; export type ShowcaseAppearance = "light" | "dark"; +/** Every palette the mobile appearance settings can select. */ +export const SHOWCASE_THEMES = MOBILE_THEME_IDS; +export const DEFAULT_SHOWCASE_THEME = MOBILE_DEFAULT_THEME_ID; +export type ShowcaseTheme = MobileThemeId; + export interface ShowcaseStoreAssetSpec { readonly store: "apple" | "google-play"; /** Device directory relative to ShowcaseConfig.outputDirectory. */ @@ -25,6 +36,8 @@ export interface ShowcaseIosDevice { readonly simulatorDeviceType?: string; /** Appearance used when the CLI does not pass --appearance. */ readonly appearance: ShowcaseAppearance; + /** Palette used when the CLI does not pass --theme. */ + readonly theme: ShowcaseTheme; /** Orientation applied by the capture harness. Defaults to portrait. */ readonly orientation?: "portrait" | "landscape"; readonly scenes: ReadonlyArray; @@ -38,6 +51,8 @@ export interface ShowcaseAndroidDevice { readonly avd: string; /** Appearance used when the CLI does not pass --appearance. */ readonly appearance: ShowcaseAppearance; + /** Palette used when the CLI does not pass --theme. */ + readonly theme: ShowcaseTheme; /** Native ABI used by the AVD, from its config.ini `abi.type`. */ readonly abi?: "arm64-v8a" | "x86_64" | "x86" | "armeabi-v7a"; readonly scenes: ReadonlyArray; @@ -92,6 +107,7 @@ const config: ShowcaseConfig = { simulator: "iPhone 17 Pro Max", simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max", appearance: "dark", + theme: DEFAULT_SHOWCASE_THEME, scenes: ["thread", "terminal", "review", "threads", "environments"], storeAsset: { store: "apple", @@ -108,6 +124,7 @@ const config: ShowcaseConfig = { simulator: "T3 Showcase iPhone 14 Plus", simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-14-Plus", appearance: "dark", + theme: DEFAULT_SHOWCASE_THEME, scenes: ["thread", "terminal", "review", "threads", "environments"], storeAsset: { store: "apple", @@ -124,6 +141,7 @@ const config: ShowcaseConfig = { simulator: "iPad Pro 13-inch (M5)", simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M5-16GB", appearance: "dark", + theme: DEFAULT_SHOWCASE_THEME, orientation: "landscape", scenes: ["thread", "terminal", "review", "threads", "environments"], storeAsset: { @@ -143,6 +161,7 @@ const config: ShowcaseConfig = { // Blacksmith Linux runner can use KVM acceleration. abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI), appearance: "dark", + theme: DEFAULT_SHOWCASE_THEME, viewport: { width: 1080, height: 1920, @@ -165,6 +184,7 @@ const config: ShowcaseConfig = { avd: "Pixel_10_Pro", abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI), appearance: "dark", + theme: DEFAULT_SHOWCASE_THEME, viewport: { width: 1080, height: 1920, @@ -187,6 +207,7 @@ const config: ShowcaseConfig = { avd: "Pixel_10_Pro", abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI), appearance: "dark", + theme: DEFAULT_SHOWCASE_THEME, viewport: { width: 1440, height: 2560, diff --git a/scripts/mobile-showcase.test.ts b/scripts/mobile-showcase.test.ts index 0ce66d617..2fd4c1dd8 100644 --- a/scripts/mobile-showcase.test.ts +++ b/scripts/mobile-showcase.test.ts @@ -2,7 +2,9 @@ import { assert, it } from "@effect/vitest"; import { PNG } from "pngjs"; import showcaseConfig, { + DEFAULT_SHOWCASE_THEME, resolveShowcaseAndroidAbi, + SHOWCASE_THEMES, type ShowcaseConfig, type ShowcaseStoreAssetSpec, } from "./mobile-showcase.config.ts"; @@ -55,6 +57,7 @@ const config: ShowcaseConfig = { platform: "ios", simulator: "iPhone Test", appearance: "dark", + theme: "t3-code", scenes: ["thread", "review"], storeAsset: appleSpec, }, @@ -63,6 +66,7 @@ const config: ShowcaseConfig = { platform: "android", avd: "Pixel_Test", appearance: "light", + theme: "t3-code", scenes: ["thread", "terminal"], storeAsset: googleSpec, }, @@ -95,6 +99,23 @@ it("rejects unsupported system appearances", () => { ); }); +it("parses repeatable and expanded theme filters", () => { + assert.deepStrictEqual( + [...parseShowcaseCliArgs(["--theme", "ocean", "--theme", "ember"]).themes], + ["ocean", "ember"], + ); + assert.deepStrictEqual( + [...parseShowcaseCliArgs(["--theme", "all"]).themes], + [...SHOWCASE_THEMES], + ); +}); + +// The app normalizes an unknown id back to its default palette, so a typo here +// would otherwise produce screenshots labeled with a theme they do not show. +it("rejects unsupported themes instead of capturing the default palette", () => { + assert.throws(() => parseShowcaseCliArgs(["--theme", "sunset"]), /Unsupported theme 'sunset'/u); +}); + it("parses validation-only mode", () => { assert.equal(parseShowcaseCliArgs(["--validate-only"]).validateOnly, true); }); @@ -146,8 +167,35 @@ it("expands both appearances into independent upload-ready directories", () => { directory: showcaseCaptureDirectory("/captures", capture), })), [ - { appearance: "light", directory: "/captures/apple/iphone-test/light" }, - { appearance: "dark", directory: "/captures/apple/iphone-test/dark" }, + { appearance: "light", directory: "/captures/apple/iphone-test/light/t3-code" }, + { appearance: "dark", directory: "/captures/apple/iphone-test/dark/t3-code" }, + ], + ); +}); + +// Every palette needs its own leaf folder: one directory holding several themes +// would mix upload slots and break the per-store screenshot count limits. +it("expands themes into independent upload-ready directories per appearance", () => { + const options = parseShowcaseCliArgs([ + "--device", + "phone", + "--appearance", + "both", + "--theme", + "ocean", + "--theme", + "ember", + ]); + + assert.deepStrictEqual( + planShowcaseCaptures(config, options).map((capture) => + showcaseCaptureDirectory("/captures", capture), + ), + [ + "/captures/apple/iphone-test/light/ocean", + "/captures/apple/iphone-test/light/ember", + "/captures/apple/iphone-test/dark/ocean", + "/captures/apple/iphone-test/dark/ember", ], ); }); @@ -211,6 +259,14 @@ it("enforces store screenshot count limits", () => { assert.throws(() => validateStoreAssetCount(googleSpec, 9, false), /allows at most 8/u); }); +it("defaults every device to the app's own palette", () => { + assert.equal(DEFAULT_SHOWCASE_THEME, "t3-code"); + assert.equal( + showcaseConfig.devices.every((device) => device.theme === DEFAULT_SHOWCASE_THEME), + true, + ); +}); + it("configures every default device with an exact upload-ready store target", () => { assert.deepStrictEqual( showcaseConfig.devices.map((device) => [ diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index d9991f423..8f987b584 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -20,6 +20,8 @@ import showcaseConfig, { type ShowcaseStoreAssetSpec, SHOWCASE_SCENES, type ShowcaseScene, + SHOWCASE_THEMES, + type ShowcaseTheme, } from "./mobile-showcase.config.ts"; import { SHOWCASE_ENVIRONMENTS, @@ -76,6 +78,7 @@ interface CliOptions { readonly deviceIds: ReadonlySet; readonly scenes: ReadonlySet; readonly appearances: ReadonlySet; + readonly themes: ReadonlySet; readonly skipBuild: boolean; readonly skipMetro: boolean; readonly keepRunning: boolean; @@ -87,6 +90,7 @@ export interface ShowcaseCapture { readonly device: ShowcaseDevice; readonly scenes: ReadonlyArray; readonly appearance: ShowcaseAppearance; + readonly theme: ShowcaseTheme; } interface IosCaptureCleanup { @@ -223,9 +227,16 @@ export function validateStoreAssetCount( export function showcaseCaptureDirectory( outputDirectory: string, - capture: Pick, + capture: Pick, ): string { - return NodePath.join(outputDirectory, capture.device.storeAsset.directory, capture.appearance); + // Each palette owns a leaf folder so one upload slot never mixes themes and + // every folder keeps a store-legal screenshot count of its own. + return NodePath.join( + outputDirectory, + capture.device.storeAsset.directory, + capture.appearance, + capture.theme, + ); } async function finalizeCapture(destination: string, device: ShowcaseDevice): Promise { @@ -276,6 +287,7 @@ export function parseShowcaseCliArgs(args: ReadonlyArray): CliOptions { const deviceIds = new Set(); const scenes = new Set(); const appearances = new Set(); + const themes = new Set(); let skipBuild = false; let skipMetro = false; let keepRunning = false; @@ -318,6 +330,18 @@ export function parseShowcaseCliArgs(args: ReadonlyArray): CliOptions { appearances.add(value); } index += 1; + } else if (argument === "--theme") { + const value = argumentValue(args, index, argument); + if (value === "all") { + for (const theme of SHOWCASE_THEMES) themes.add(theme); + } else if (SHOWCASE_THEMES.some((theme) => theme === value)) { + themes.add(value as ShowcaseTheme); + } else { + // The app silently falls back to its default palette for an unknown id, + // so reject it here rather than shipping a mislabeled screenshot. + throw new Error(`Unsupported theme '${value}'. Use ${SHOWCASE_THEMES.join(", ")}, or all.`); + } + index += 1; } else if (argument === "--skip-build") { skipBuild = true; } else if (argument === "--skip-metro") { @@ -340,6 +364,7 @@ export function parseShowcaseCliArgs(args: ReadonlyArray): CliOptions { deviceIds, scenes, appearances, + themes, skipBuild, skipMetro, keepRunning, @@ -350,7 +375,7 @@ export function parseShowcaseCliArgs(args: ReadonlyArray): CliOptions { export function planShowcaseCaptures( config: ShowcaseConfig, - options: Pick, + options: Pick, ): ReadonlyArray { const captures = config.devices .filter((device) => options.platforms.size === 0 || options.platforms.has(device.platform)) @@ -358,14 +383,18 @@ export function planShowcaseCaptures( .flatMap((device) => { const appearances = options.appearances.size === 0 ? [device.appearance] : options.appearances; - return [...appearances].map((appearance) => ({ - device, - appearance, - scenes: - options.scenes.size === 0 - ? device.scenes - : device.scenes.filter((scene) => options.scenes.has(scene)), - })); + const themes = options.themes.size === 0 ? [device.theme] : options.themes; + return [...appearances].flatMap((appearance) => + [...themes].map((theme) => ({ + device, + appearance, + theme, + scenes: + options.scenes.size === 0 + ? device.scenes + : device.scenes.filter((scene) => options.scenes.has(scene)), + })), + ); }) .filter((capture) => capture.scenes.length > 0); @@ -393,6 +422,7 @@ Options: --scene Capture one scene (repeatable) --appearance light|dark|both Override the configured appearance + --theme |all Override the configured palette (repeatable) --skip-build Reuse the existing simulator app / debug APK --skip-metro Reuse an already running showcase Metro server --keep-running Leave devices and Metro running after capture @@ -400,12 +430,13 @@ Options: --list Print this help and the configured matrix Scenes: ${SHOWCASE_SCENES.join(", ")} +Themes: ${SHOWCASE_THEMES.join(", ")} Configured devices: ${config.devices .map((device) => { const target = device.platform === "ios" ? device.simulator : device.avd; - return ` ${device.id.padEnd(18)} ${device.platform.padEnd(8)} ${target} -> ${device.storeAsset.directory}/{light|dark} (${device.storeAsset.width}×${device.storeAsset.height}, default ${device.appearance}) [${device.scenes.join(", ")}]`; + return ` ${device.id.padEnd(18)} ${device.platform.padEnd(8)} ${target} -> ${device.storeAsset.directory}/{light|dark}/ (${device.storeAsset.width}×${device.storeAsset.height}, default ${device.appearance} ${device.theme}) [${device.scenes.join(", ")}]`; }) .join("\n")} `); @@ -946,6 +977,8 @@ async function captureIos( JSON.stringify(pairingUrls), "--showcaseScene", firstScene, + "--showcaseTheme", + capture.theme, // The app rotates itself; Simulator menu UI scripting needs macOS // Accessibility permission that CI runners do not grant to osascript. "--showcaseOrientation", @@ -1204,6 +1237,9 @@ async function captureAndroid( "--es", "showcaseScene", firstScene, + "--es", + "showcaseTheme", + capture.theme, ANDROID_PACKAGE, ]); for (const [sceneIndex, scene] of capture.scenes.entries()) { From 33173950b08e3ca4bd0f0816bffa024e197d330b Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:18:38 +0530 Subject: [PATCH 07/14] docs: point CLAUDE.md at AGENTS.md with an @import instead of a symlink (#7171) (cherry picked from commit 4cb676cc1612e6220246cd5f8abdd2bc284e1a97) --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) mode change 120000 => 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index 47dc3e3d8..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..43c994c2d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md From 8477ddb704fb2a3c462ce061a1a06241102c7887 Mon Sep 17 00:00:00 2001 From: Francisco Arredondo <95440147+frarredondo@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:02:05 -0700 Subject: [PATCH 08/14] fix(web): show filenames when commit dialog paths overflow (#6392) Co-authored-by: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> (cherry picked from commit 4c1d99d7f3e722c5e88c0dda6a96f9de8f296115) --- apps/web/src/components/GitActionsControl.tsx | 12 +++---- .../components/StartTruncatedPath.test.tsx | 31 +++++++++++++++++++ .../web/src/components/StartTruncatedPath.tsx | 25 +++++++++++++++ 3 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/StartTruncatedPath.test.tsx create mode 100644 apps/web/src/components/StartTruncatedPath.tsx diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 7b824370b..d448a720e 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -51,6 +51,7 @@ import { resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; import { AnimatedHeight } from "./AnimatedHeight"; +import { StartTruncatedPath } from "./StartTruncatedPath"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; import { @@ -1923,14 +1924,13 @@ export default function GitActionsControl({ )}