diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx index 1015a568ca3..50a381bae28 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx @@ -98,7 +98,7 @@ function resolveHeadingFontSize(textStyle: NativeMarkdownTextStyle, headingLevel } const scale = textStyle.fontSize / DEFAULT_BODY_FONT_SIZE; - return Math.max(12, Math.round(DEFAULT_HEADING_FONT_SIZES[index] * scale)); + return Math.max(12, Math.round((DEFAULT_HEADING_FONT_SIZES[index] ?? 15) * scale)); } function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle { diff --git a/apps/mobile/scripts/generate-uniwind-themes.mts b/apps/mobile/scripts/generate-uniwind-themes.mts index aa3d9b0bfb0..6878dc0ee78 100644 --- a/apps/mobile/scripts/generate-uniwind-themes.mts +++ b/apps/mobile/scripts/generate-uniwind-themes.mts @@ -53,7 +53,7 @@ const color = (family: TailwindColorFamily, shade?: TailwindColorShade, opacity // These replace the remaining dark:* utility pairs. A registered palette theme is // neither literally `light` nor `dark`, so appearance-sensitive values must also be // represented as semantic variables for custom themes. -const ADAPTIVE_COLORS = { +const ADAPTIVE_COLORS: Readonly> = { "--color-adaptive-amber-50-950-a40": [color("amber", 50), color("amber", 950, 0.4)], "--color-adaptive-amber-200-900-a60": [color("amber", 200), color("amber", 900, 0.6)], "--color-adaptive-amber-500-a12-a16": [color("amber", 500, 0.12), color("amber", 500, 0.16)], @@ -143,9 +143,9 @@ export const customThemeNames = BUILT_IN_THEME_IDS.flatMap((themeId) => const adaptiveVariablesFor = (appearance: MobileThemeAppearance) => Object.fromEntries( - Object.entries(ADAPTIVE_COLORS).map(([name, values]) => [ + Object.entries(ADAPTIVE_COLORS).map(([name, [light, dark]]) => [ name, - values[appearance === "light" ? 0 : 1], + appearance === "light" ? light : dark, ]), ); diff --git a/apps/mobile/src/components/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/AndroidAnchoredMenu.tsx index 1a4f11b8c7e..79ec95d0a01 100644 --- a/apps/mobile/src/components/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/AndroidAnchoredMenu.tsx @@ -125,7 +125,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { return () => subscription.remove(); }, [anchor, close, submenuDepth]); - const parent = path.length > 0 ? path[path.length - 1] : null; + const parent = path[path.length - 1] ?? null; const levelActions = (parent?.subactions ?? props.actions).filter( (action) => !(action.attributes?.hidden ?? false), ); diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index fd10f1e1509..d783557f7b6 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -53,7 +53,7 @@ function opaqueNativeHexColor(color: string, background: string): string { 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); + const behind = Number.parseInt(backgroundHex[index] ?? "0", 16); return Math.round(foreground * alpha + behind * (1 - alpha)); }); return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; @@ -358,6 +358,9 @@ function addNativeWordDiffRanges( for (let pairIndex = 0; pairIndex < pairedCount; pairIndex += 1) { const deletedRowIndex = deletedRowIndexes[pairIndex]; const addedRowIndex = addedRowIndexes[pairIndex]; + if (deletedRowIndex === undefined || addedRowIndex === undefined) { + continue; + } const deletedRow = nextRows[deletedRowIndex]; const addedRow = nextRows[addedRowIndex]; if (!deletedRow?.content || !addedRow?.content) { diff --git a/apps/mobile/src/features/review/reviewModel.test.ts b/apps/mobile/src/features/review/reviewModel.test.ts index ee568085f7a..8a9aadd6d26 100644 --- a/apps/mobile/src/features/review/reviewModel.test.ts +++ b/apps/mobile/src/features/review/reviewModel.test.ts @@ -84,7 +84,7 @@ describe("buildReviewSectionItems", () => { }, ]; - const loadedTurnId = getReviewSectionIdForCheckpoint(checkpoints[0]); + const loadedTurnId = getReviewSectionIdForCheckpoint(checkpoints[0]!); const items = buildReviewSectionItems({ checkpoints, gitSections, @@ -92,7 +92,7 @@ describe("buildReviewSectionItems", () => { [loadedTurnId]: "diff --git a/loaded.ts b/loaded.ts", }, loadingTurnIds: { - [getReviewSectionIdForCheckpoint(checkpoints[1])]: true, + [getReviewSectionIdForCheckpoint(checkpoints[1]!)]: true, }, loadingGitSections: false, }); diff --git a/apps/mobile/src/features/terminal/terminalTheme.ts b/apps/mobile/src/features/terminal/terminalTheme.ts index 64a54e8ea3d..f7d96b66861 100644 --- a/apps/mobile/src/features/terminal/terminalTheme.ts +++ b/apps/mobile/src/features/terminal/terminalTheme.ts @@ -1,4 +1,8 @@ -import { BUILT_IN_THEMES, getThemeColorsForAppearance } from "@t3tools/shared/themePalettes"; +import { + BUILT_IN_THEMES, + T3_CHAT_THEME, + getThemeColorsForAppearance, +} from "@t3tools/shared/themePalettes"; import { getMobileThemeVariables, @@ -15,9 +19,29 @@ export interface TerminalTheme { readonly border: string; readonly cursorForeground: string; readonly cursorBackground: string; - readonly palette: readonly string[]; + /** The 16 ANSI colors, in order. A fixed tuple so indexed reads are never undefined. */ + readonly palette: TerminalPalette; } +type TerminalPalette = readonly [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, +]; + const PIERRE_LIGHT_THEME: TerminalTheme = { // Pierre terminal palette with the app's shared screen background. background: "#f2f2f7", @@ -85,7 +109,7 @@ export function getMobileTerminalTheme( const base = getPierreTerminalTheme(scheme); if (themeId === "t3-code" || themeId === "material-you") return base; - const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0]; + const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? T3_CHAT_THEME; const palette = getThemeColorsForAppearance(theme, scheme) ?? theme.colors; const colors = getMobileThemeVariables(themeId, scheme); const background = themeColorToNativeColor(palette.terminalBackground); diff --git a/apps/mobile/src/lib/mobileTheme.test-support.ts b/apps/mobile/src/lib/mobileTheme.test-support.ts index a702bb9afb2..1e47b0f0ed4 100644 --- a/apps/mobile/src/lib/mobileTheme.test-support.ts +++ b/apps/mobile/src/lib/mobileTheme.test-support.ts @@ -14,7 +14,7 @@ export function readDefaultMobileThemeVariables( return Object.fromEntries( Array.from(variant.matchAll(/(--color-[a-z0-9-]+):\s*([^;]+);/gu), ([, name, value]) => [ name, - value.trim(), + (value ?? "").trim(), ]), ) as MobileThemeVariables; } diff --git a/apps/mobile/src/lib/mobileTheme.test.ts b/apps/mobile/src/lib/mobileTheme.test.ts index 0f621f8c01c..64c27e7d870 100644 --- a/apps/mobile/src/lib/mobileTheme.test.ts +++ b/apps/mobile/src/lib/mobileTheme.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { BUILT_IN_THEME_IDS, BUILT_IN_THEMES } from "@t3tools/shared/themePalettes"; +import { BUILT_IN_THEME_IDS, BUILT_IN_THEMES, T3_CHAT_THEME } from "@t3tools/shared/themePalettes"; import { readDefaultMobileThemeVariables } from "./mobileTheme.test-support"; import { @@ -49,7 +49,7 @@ function compositeOver(overlay: string, background: string): string { describe("mobile themes", () => { it("declares every runtime theme variable in the static stylesheet", () => { - const generatedVariables = createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"); + const generatedVariables = createMobileThemeVariables(T3_CHAT_THEME.colors, "light"); expect(Object.keys(readDefaultMobileThemeVariables("light")).sort()).toEqual( Object.keys(generatedVariables).sort(), ); @@ -152,16 +152,16 @@ describe("mobile themes", () => { }); it("maps semantic palette roles onto every mobile color variable", () => { - const variables = createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"); + const variables = createMobileThemeVariables(T3_CHAT_THEME.colors, "light"); expect(Object.keys(variables)).toHaveLength(75); expect(variables["--color-sheet-solid"]).toBe( - themeColorToNativeColor(BUILT_IN_THEMES[0].colors.chrome), + themeColorToNativeColor(T3_CHAT_THEME.colors.chrome), ); expect(variables["--color-warning"]).toBe( - themeColorToNativeColor(BUILT_IN_THEMES[0].colors.warningSurface), + themeColorToNativeColor(T3_CHAT_THEME.colors.warningSurface), ); expect(variables["--color-warning-foreground"]).toBe( - themeColorToNativeColor(BUILT_IN_THEMES[0].colors.warningForeground), + themeColorToNativeColor(T3_CHAT_THEME.colors.warningForeground), ); expect(variables["--color-primary"]).not.toBe(variables["--color-screen"]); expect(variables["--color-primary-shadow"]).toBe("#000000"); diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index 715c6140146..3b22ac8b583 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -1,5 +1,6 @@ import { BUILT_IN_THEMES, + T3_CHAT_THEME, getThemeColorsForAppearance, MOBILE_DEFAULT_THEME_ID, MOBILE_THEME_IDS as SHARED_MOBILE_THEME_IDS, @@ -29,7 +30,9 @@ export const MOBILE_THEME_OPTIONS: ReadonlyArray<{ ...BUILT_IN_THEMES.map((theme) => ({ id: theme.id as MobileThemeId, label: theme.label })), ]; -export type MobileThemeVariable = `--color-${string}`; +// Closed set: every key `createMobileThemeVariables` writes. Reads of a +// misspelled variable then fail to compile instead of yielding undefined. +export type MobileThemeVariable = keyof ReturnType; export type MobileThemeVariables = Readonly>; export function normalizeMobileThemeId(value: unknown): MobileThemeId { @@ -136,9 +139,9 @@ function withAlpha(color: string, alpha: number): string { 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; + if (!match) return null; + const [, red = "0", green = "0", blue = "0"] = match; + return [Number.parseInt(red, 16), Number.parseInt(green, 16), Number.parseInt(blue, 16)]; } /** @@ -216,18 +219,15 @@ function readableMessageAccent(accent: string, surface: string): string { } 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 channels = rgbChannels(color); + if (channels) { + return `rgba(${channels[0]}, ${channels[1]}, ${channels[2]}, ${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 { +export function createMobileThemeVariables(colors: ThemeColors, appearance: MobileThemeAppearance) { const c = nativeColors(colors); return { "--color-screen": c.canvas, @@ -315,7 +315,7 @@ export function createMobileThemeVariables( } export const MOBILE_THEME_VARIABLE_NAMES = Object.keys( - createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"), + createMobileThemeVariables(T3_CHAT_THEME.colors, "light"), ) as ReadonlyArray; export function getMobileThemeVariables( @@ -323,7 +323,7 @@ export function getMobileThemeVariables( appearance: MobileThemeAppearance, overrides: Partial | null = null, ): MobileThemeVariables { - const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0]; + const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? T3_CHAT_THEME; const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors; const baseVariables = createMobileThemeVariables(colors, appearance); @@ -337,7 +337,7 @@ export function getMobileThemePreviewColors( ): ThemePreviewColors { if (themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you") return STANDARD_THEME_PREVIEW_COLORS[appearance]; - const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0]; + const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? T3_CHAT_THEME; const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors; return { canvas: themeColorToNativeColor(colors.canvas), diff --git a/apps/mobile/src/lib/wideMarkdownBlocks.ts b/apps/mobile/src/lib/wideMarkdownBlocks.ts index 57588bab2a2..054c60be087 100644 --- a/apps/mobile/src/lib/wideMarkdownBlocks.ts +++ b/apps/mobile/src/lib/wideMarkdownBlocks.ts @@ -81,7 +81,11 @@ function hasOrderedListItem(text: string): boolean { const nestedMatch = INDENTED_ORDERED_LIST_ITEM.exec(line); const parentMatch = previousNonEmptyLine === null ? null : ANY_LIST_ITEM.exec(previousNonEmptyLine); - if (nestedMatch && parentMatch && parentMatch[1].length < nestedMatch[1].length) { + if ( + nestedMatch?.[1] !== undefined && + parentMatch?.[1] !== undefined && + parentMatch[1].length < nestedMatch[1].length + ) { return true; } diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index f3763c56543..f6634fbc4c9 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -2,6 +2,8 @@ "extends": "expo/tsconfig.base", "compilerOptions": { "allowImportingTsExtensions": true, - "strict": true + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true } }