diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index d2431cf71..7f8feb89b 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -1,8 +1,8 @@ --- remote: t3code-upstream branch: main -reviewed-through: "a5e29edeec34fdfab1d44e643b0d12bb924fd261" -reviewed-through-date: "2026-08-15" +reviewed-through: "bab4b6f02b8bdaf15fd32636a97f69ff657cec50" +reviewed-through-date: "2026-08-16" --- # T3 upstream review log @@ -1232,10 +1232,209 @@ Two test notes for whoever runs these next: touches the composer, sidebar, theme, DMG chrome, and the PWA manifest, so a web pass is the obvious next step. +## 2026-08-16 — `a5e29edeec34fdfab1d44e643b0d12bb924fd261..bab4b6f02b8bdaf15fd32636a97f69ff657cec50` + +Ten upstream commits, ten change sets. **Nine adopted** onto `upstream/2026-08-16-batch`, +**one skipped**. A quiet day after the 100-commit sweep: two real features, four small +fixes, two test-hygiene sweeps, one repo-plumbing change. `git cherry` reported all ten +absent from Pylon, so nothing was patch-equivalent. + +**N1 closes the watch item** left open at the end of the 2026-08-15 batch. Still no Pylon +code path was found that sets `data-theme-id` to the empty string, so it remains +drift-closing rather than a fix for a live defect. + +**N3 is the substantial one.** Mobile gains the built-in theme library and System/Light/Dark +selection, and the canonical palettes move out of `apps/web/src/themePalette.ts` into +`packages/shared/src/themePalettes.ts` + `themePreview.ts` so web and mobile cannot drift. +Upstream deliberately excluded theme import, creation, and editing on mobile. The +`packages/shared/package.json` change is subpath exports only — no dependency change, so +**`pnpm-lock.yaml` is untouched by this batch** (checked explicitly, per the 2026-08-15 +lockfile near-miss). + +Branding pass on N3, following the F10 precedent that **theme ids are compatibility +identifiers and labels are not**: `MOBILE_DEFAULT_THEME_ID` stays `t3-code` so saved mobile +preferences keep resolving, while its visible label became "Pylon", the new +`docs/user/mobile-appearance.md` was rewritten to Pylon voice, and two doc comments in +`mobileDefaultTheme.ts` and `themePreview.ts` were rebranded. Pre-existing "T3 Code" strings +in `docs/README.md`, `docs/operations/mobile-app-store-screenshots.md`, and +`scripts/mobile-showcase.test.ts` were confirmed present on `origin/pylon` and left alone as +branding debt tracked separately. + +Three conflicts, all resolved Pylon-first: + +- **`ProviderIcon.tsx`** (N3) — both-sides-add on imports. Kept Pylon's `Circle` and + `providerIconKind` (the distinct provider marks from B1) and took upstream's + `useAppearancePreferences`, dropping the now-unused `useColorScheme`. +- **`ThreadComposer.tsx`** (N3) — both-sides-add on imports; both kept. +- **`themePalette.ts`** (N3) — upstream deletes the T3 Chat palettes as they move to the + shared package, and Pylon had rebranded four comments inside those deleted blocks. + Resolved by taking upstream's file wholesale, then restoring the **three** Pylon rebrands + that survive the deletion. Verified `T3_CHAT_LIGHT_COLORS`/`T3_CHAT_DARK_COLORS` have no + remaining web references. +- **`ProviderRegistry.test.ts`** (N5) — Pylon's own "projects pushed rate-limit state onto + the instance snapshot" test sits immediately before the test upstream deletes. Kept + Pylon's, deleted upstream's. + +**Verified upstream's claim rather than trusting it** for N5: `mergeProviderSnapshots` and +`selectProvidersByKind` have exactly one hit each outside tests in Pylon — their own +`export` — so the "no production callers" premise holds here too. + +| Change set | Upstream | Decision | Pylon reference | Rationale or revisit condition | +| ---------- | --------------------- | -------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| N1 | `e58cbb9e7` / `#6663` | adopted | `c8eca3857` | Theme selector tightened to `html[data-theme-id]:not([data-theme-id=""])` for specificity without raw `.dark` selectors. Closes the watch item from the 2026-08-15 batch. | +| N2 | `2f486ab80` / `#7107` | adopted | `72cd53a41` | The Advanced theme editor groups ~60 raw roles into named color families; `updateThemeColorFamily()` derives paired foregrounds only for the family touched, leaving imported palettes otherwise unnormalized. | +| N3 | `d23b181da` / `#6619` | adopted | `4838326f1` | Mobile built-in themes plus System/Light/Dark, with palettes and preview generation extracted to `packages/shared`. See the branding and conflict notes above. **Not yet verified on a device or simulator.** | +| N4 | `d484735c6` / `#7132` | adopted | `bd922a271` | A keyboard-highlighted command menu item no longer scrolls to rest under the scroll-fade mask. Reaches every `ScrollArea` using `scrollFade`. | +| N5 | `277322933` / `#6267` | adopted | `c3ad0b60d` | Removes 875 lines of duplicate and stale tests across server, web, desktop, mobile, relay, and scripts. Ten of eleven files were byte-identical to upstream; only `ProviderRegistry.test.ts` conflicted. | +| N6 | `3583cd27d` / `#7157` | adopted | `af01aaf9a` | Drops four test-only exports and the tests asserting on them, moving coverage to public behavior. | +| N7 | `4cb676cc1` / `#7171` | adopted | `33173950b` | `CLAUDE.md` becomes a regular file containing `@AGENTS.md` instead of a symlink, because Windows checkouts with `core.symlinks=false` materialize it as a plain file that `vp fmt --check` flags and contributors commit back, corrupting the target. **Tradeoff accepted:** a symlink resolves for any reader, while `@AGENTS.md` only resolves for harnesses that honor @imports. Supersedes G4. | +| N8 | `4c1d99d7f` / `#6392` | adopted | `8477ddb70` | Long paths in the commit dialog truncated from the end, hiding the filename. New `StartTruncatedPath` truncates from the start via `dir="rtl"` + ``, with the full path in a tooltip. | +| N9 | `89c52a331` / `#6635` | adopted | `204671979` | Two `ThreadSettingsSheet` full-screen routes used the `embedded` Android header, which skips the status-bar inset, so their actions sat under the status bar. **Open question:** five other call sites still use `AndroidSheetHeader` (`GitCommitSheet`, `GitOverviewSheet`, `GitConfirmSheet`, `GitBranchesSheet`, `ConnectOnboardingRouteScreen`); their presentation was not checked. | +| N10 | `bab4b6f02` / `#7208` | skipped | `—` | Removes the Windows-only silent-install warning from the update confirmation so all platforms share the short copy. Upstream's reason is "install times have improved" — a claim about T3's pipeline, not Pylon's Windows builds. Pylon ships Windows and had already rebranded this copy; the warning describes real behavior (no installer window appears) and without it the app reads as hung. Revisit if Pylon measures its own Windows install times and finds the warning stale. | + +### Inherited defects found by review, fixed Pylon-first + +An `xhigh` review of the integration branch surfaced three regressions that arrived **with** +the adopted commits rather than from any conflict resolution — `ThemeEditorPanel.tsx` is +byte-identical to `#7107` apart from one rebranded comment, and the mobile files auto-merged. +All are worth reporting upstream. + +- **Advanced editor: an Inspect pick could silently no-op.** `selectThemeRole` resolves the + picked role to its family representative _before_ testing `THEME_EDITOR_SIMPLE_ROLES`, which + is only `["canvas", "accent"]`. So `chrome`, `toolbar`, `focus`, `update*`, and + `terminalCursor` all resolve to a "simple" role and skip both `setIsAdvanced(true)` **and** + `setRoleQuery("")`. Already in Advanced with a filter typed, the role is selected but its + field stays filtered out of the DOM, so the `scrollIntoView` reveal finds nothing. Fixed by + clearing the query unconditionally; the query only affects the Advanced list, so clearing it + on a guided-role pick is inert. +- **Advanced hex fields rewrote themselves mid-keystroke.** `ThemeColorField` fires `onChange` + per keystroke and `updateThemeColorFamily` canonicalizes to OKLCH whenever the value parses. + Verified against culori directly: `#ff0` parses as yellow and `#ff00` parses as yellow with + **alpha 0**, so typing `#ff0000` snapped to `#ffff00` at three characters and the next + keystroke appended to _that_. Before `#7107` this reached only the two guided roles; it now + reaches ~22 Advanced families. Fixed by keeping the typed string for the edited role while + still deriving its family companions — `decodeThemeColors` canonicalizes every role at save, + which is how Advanced already behaved before this commit. +- **Mobile dark-mode pill contrast** — see the open item below. + +Two further findings were confirmed and deliberately **not** fixed: `mergeProviderSnapshots`, +`selectProvidersByKind`, `requireNonNegativeInteger`, and `showcaseSceneUrl` now have zero +callers and zero coverage, because `#6267` deleted their tests and kept the exports. Removing +them is upstream's call to make; deleting them here buys nothing and costs divergence. + +The review cleared the parts most at risk: the T3 Chat palette converts to OKLCH with exact +fidelity against the old hex literals, every `useAppearancePreferences` consumer sits inside +its provider, and the `useColorScheme` migration is complete. + +**The mobile contrast finding was mostly a false positive — resolved, no change made.** The +review flagged `sidebar-header-actions.tsx`, `sidebar-filter-button.tsx`, and +`ThreadNavigationSidebar.tsx` for swapping a hardcoded idle fill for `--color-glass-surface`, +which in dark mode goes from `rgba(118,118,128,0.24)` (a grey _lift_ above the drawer) to +`rgba(23,23,23,0.78)` (near-black). Checking the call sites settles it: + +- `SidebarHeaderActions` and `SidebarFilterButton` each have **exactly one** call site, both + passing `grouped`. That branch renders `backgroundColor: "transparent"` with `borderWidth: 0`, + so `idleBackgroundColor` is computed and **never applied**. Two of the three files are dead + code for this purpose. +- Only `SidebarHeaderButtonGroup`'s `fallbackBackground` is live, and only when + `isLiquidGlassSupported` is false — Android and iOS < 26. + +Confirmed on device: swapping the old literal back in and letting fast refresh apply it produced +**pixel-identical** output on iOS 26.3 — pill fill `rgb(21,21,21)`, background `rgb(10,10,10)`, +contrast 1.084 either way. Changing a design token on arithmetic alone, for a path that cannot +be observed on the platform available here, would be worse than leaving it. **Revisit only with +an Android or iOS < 26 pass**, where the fallback actually renders. + +**Verification.** Typecheck clean across web, mobile, shared, `t3`, and desktop — server and +desktop emit only `TS377xxx` _suggestions_, none in files this batch touched. `vp fmt --check` +clean over 96 files. Tests: mobile **790 in 126 files, all passing**; web **2,738 of 2,741 in +280 files**; shared 10; server `ProviderRegistry` 52; scripts `mobile-showcase` 22. + +**The three web failures are a local-environment artifact, not batch fallout.** +`apps/web/src/cloud/connectCliAuth.test.ts` assumes `VITE_CLERK_CLI_OAUTH_CLIENT_ID` is unset +unless a test stubs it, and this checkout's `.env` sets `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID`, which +`loadRepoEnv` maps into the `VITE_` name. `apps/web/vite.config.ts` then `Object.assign`s that map +into `process.env` at module scope, and Vite exposes every `VITE_` key from `process.env` on +`import.meta.env` — so the test reads the developer's real Connect configuration and the "not +configured" assertions cannot hold. The test and its entire import graph are untouched by this +batch, it fails **in isolation**, and it fails **identically on a clean `origin/pylon` +checkout**, so the deleted test files in N5 did not perturb it by ordering either. CI has no such +`.env` and is unaffected. + +> **Correction.** An earlier revision of this entry blamed `define:` in `apps/web/vite.config.ts`, +> reasoning that a textual substitution cannot be reached by `vi.stubEnv`. That is wrong. +> Instrumenting the config showed `mode: "test"` is detected and scoping `define` out of test mode +> left the tests failing exactly as before, which rules it out. The `process.env` assignment above +> is the actual mechanism. Fixed separately on `fix/agent-docs-and-test-isolation` by blanking +> those keys on the web unit-test project. + +Two toolchain notes for whoever runs these next: + +- **`vp lint` could not run at all in this environment**, on this branch _or_ on untouched + files: oxlint fails to load `./oxlint-plugin-t3code/index.ts` with + `ERR_UNKNOWN_FILE_EXTENSION`, under both Node 22.15.1 and Node 24.13.1. This branch touches + neither the plugin nor the lint config, so it is pre-existing environment breakage — but it + means **lint coverage for this batch is unproven locally** and CI owns it. +- Running `vitest run scripts/mobile-showcase.test.ts` from the repository root matched the + same filename inside sibling worktrees under `.prime/worktrees` and reported 5 failed files + that are not this checkout's. Scope it with `--dir scripts --exclude '**/.prime/**'`. This + is the same substring-matching trap recorded in the 2026-08-07 batch, with a new directory. + +### Integration passes in real clients + +**iOS Simulator (iPhone 17 Pro, iOS 26.3), against a copy of `~/.pylon-code` — 4 projects, +25 threads, 593 turns.** No native rebuild was needed: the only new native API is +`getShowcaseTheme`, consumed solely by the screenshot harness through optional chaining, so the +installed dev client was reused. + +- Settings → Appearance lists **Pylon**, T3 Chat, Grove, Ocean, Ember, Iris. The default label + reads **"Pylon"**, confirming the branding pass at both the accessibility layer and on screen. +- Color scheme offers System / Light / Dark. Flipping the simulator to dark with the app on + **System** repainted the whole sheet, so the system path works. +- Selecting **Ember** repainted the Appearance sheet, thread list, thread-route chrome, and + primary action buttons; the blue "Working" status label correctly stayed independent of the + palette. + +Not covered on mobile, with the reason: **review sheets, file previews, and the terminal** were +unreachable because thread rows expose no tappable accessibility role (a known limitation the +skill documents) and the thread deep link needs a client-side environment id that is not in the +database. **`#6635` is Android-only** (`Platform.OS === "android"`) and cannot be observed on +iOS at all. The showcase harness's new native path was not exercised, since the old dev client +was reused deliberately. + +**Web, via Playwright against system Chrome.** Both fixes in `fe31bc6d7` were proven with a +negative control — the component was reverted to its pre-fix state, re-tested, then restored: + +| | typing `#ff0000` into `#canvas-hex` | Inspect pick with `terminal` filter active | +| -------- | ------------------------------------------------------------ | --------------------------------------------------- | +| pre-fix | `# → #f → #ff → #ffff00 → #ffff000 → #ffff0000 → #ffff00000` | filter stays `"terminal"`, canvas field count **0** | +| with fix | `# → #f → #ff → #ff0 → #ff00 → #ff000 → #ff0000` | filter clears to `""`, canvas field count **1** | + +The pre-fix run is worse than predicted: after snapping at `#ff0` it never recovers, ending at +`#ffff00000`. Advanced mode was confirmed to expose 20 hex fields against guided mode's 2. + +**N4 (`#7132`) verified live**: the command list carries `not-empty:py-3` computing to +`padding: 12px`, and the scroll viewport reports `scroll-padding: 24px` — both halves of the fix +applied. + +**N8 (`#6392`) was not exercised in the live commit dialog.** Reaching it triggered a real +"Generating commit message" agent run, so that path was abandoned rather than driven further; it +also staged the two untracked directories, which was reverted with `git restore --staged`. +Coverage rests on its own unit tests, which assert the `dir="rtl"` + `` markup and the +tooltip. + +**Fixture note, fixed separately on `fix/agent-docs-and-test-isolation`.** `AGENTS.md`'s "Test +data" section pointed at `~/.t3/userdata`, which for Pylon is the **wrong** database: it is T3 +Code's, carrying upstream's migration numbering, and a Pylon server started against a copy of it +dies with `no such column: continued_from_thread_id`. Pylon's own runtime home, +`~/.pylon-code/userdata`, holds the correct 37 Pinned / 38 ContinuedFrom / 39 TurnsKeysetIndex / +40 PinOrderKey sequence. A fresh database built by this branch applied 37–44 cleanly with 36 +retired, so the renumbering holds end to end. + ## Deferred register _The register is currently empty. DEF-1 and DEF-2 were adopted on 2026-08-11 -(see the sixth batch above); the eighth through twelfth batches each deferred +(see the sixth batch above); every batch since, through 2026-08-16, deferred nothing new. Entries are removed once adopted or skipped, so an empty register means nothing is waiting._ 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/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 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/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/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/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/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/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/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..ba749069a 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -27,14 +27,15 @@ 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"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; -import { AndroidSheetHeader } from "../../components/AndroidScreenHeader"; +import { AndroidScreenHeader } 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} - {Platform.OS === "android" ? ( - {Platform.OS === "android" ? ( - navigation.goBack()} /> + navigation.goBack()} /> ) : null} navigation.goBack()} /> @@ -1161,9 +1158,8 @@ function ThreadSettingsChoiceScreen() { } function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation) { - const sheetBackground = String(useThemeColor("--color-sheet")); + const solidSheetBackground = String(useThemeColor("--color-sheet-solid")); const foreground = String(useThemeColor("--color-foreground")); - const nativeSheetBackground = NATIVE_SHEET_SURFACE_COLOR ?? sheetBackground; const presentation = useMemo( () => ({ 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/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/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({ )}