From e696792ebb91b7a7971291757bdfd39336950b0f Mon Sep 17 00:00:00 2001 From: aryamthecodebreaker Date: Sun, 13 Sep 2026 15:07:53 +0530 Subject: [PATCH 1/7] feat(ui): backgrounds from your own pictures Extends the wallpaper feature with a 'custom' selection beside the bundled scenes: - A native multi-select picker adds JPG/PNG/WebP/GIF/AVIF files from the user's computer to a library shown in Appearance, capped at 40. - A toggle cycles that library on a user-set interval in minutes. It lives in a headless watcher mounted in AppProviders so rotation keeps running while the user is in a session, not only on the settings screen. - A Background clarity slider sets how plainly the scene reads through the app's glass. 0 is exactly the current look; the top end leaves a trace of tint and no blur. The engine reads the file, never the renderer: backgrounds sit outside every project root, so the path jail does not apply and the user's own saved list is the allowlist instead. wallpaper.read refuses a path that is not in it, re-checks the extension, and caps the file at 24MB because the image is inlined as a data URL. wallpaper.css keeps its present values as fallbacks for the four clarity custom properties, so a build that never sets clarity paints as it does now. Co-Authored-By: Claude Opus 5 --- apps/desktop/src/main/ipc-methods.ts | 2 + apps/desktop/src/main/rpc.ts | 64 ++++- apps/desktop/src/renderer/src/App.test.tsx | 12 + apps/desktop/src/renderer/src/App.tsx | 12 + .../appearance/custom-background.test.ts | 87 ++++++ .../features/appearance/custom-background.ts | 59 ++++ .../appearance/useCustomBackground.ts | 144 ++++++++++ .../settings/AdvancedSettings.test.tsx | 6 +- .../settings/AppearanceSettings.test.tsx | 157 ++++++++++- .../features/settings/AppearanceSettings.tsx | 259 +++++++++++++++++- .../settings/NotificationsSettings.test.tsx | 6 +- .../settings/PermissionsSettings.test.tsx | 6 +- .../settings/SettingsWorkspace.test.tsx | 6 +- .../settings/useEngineSettings.test.ts | 10 +- packages/contracts/src/rpc.ts | 12 + .../contracts/src/settings-wallpaper.test.ts | 69 +++++ packages/contracts/src/settings.ts | 59 +++- packages/ui/package.json | 1 + packages/ui/src/components/Slider.tsx | 60 ++++ packages/ui/src/theme-provider.tsx | 7 + packages/ui/src/wallpaper-css.test.ts | 7 +- packages/ui/src/wallpaper.css | 31 ++- packages/ui/src/wallpapers.ts | 12 +- 23 files changed, 1060 insertions(+), 28 deletions(-) create mode 100644 apps/desktop/src/renderer/src/features/appearance/custom-background.test.ts create mode 100644 apps/desktop/src/renderer/src/features/appearance/custom-background.ts create mode 100644 apps/desktop/src/renderer/src/features/appearance/useCustomBackground.ts create mode 100644 packages/contracts/src/settings-wallpaper.test.ts create mode 100644 packages/ui/src/components/Slider.tsx diff --git a/apps/desktop/src/main/ipc-methods.ts b/apps/desktop/src/main/ipc-methods.ts index f4470ccd..ba8c338c 100644 --- a/apps/desktop/src/main/ipc-methods.ts +++ b/apps/desktop/src/main/ipc-methods.ts @@ -63,6 +63,8 @@ export const IPC_METHODS = [ 'project.remove', 'project.move', 'dialog.pickFolder', + 'dialog.pickImages', + 'wallpaper.read', 'shell.revealPath', 'shell.openUrl', 'files.index', diff --git a/apps/desktop/src/main/rpc.ts b/apps/desktop/src/main/rpc.ts index 0f966ac4..89afc308 100644 --- a/apps/desktop/src/main/rpc.ts +++ b/apps/desktop/src/main/rpc.ts @@ -1,6 +1,6 @@ import { open, readFile, readdir, stat } from 'node:fs/promises' import { homedir } from 'node:os' -import { join, resolve } from 'node:path' +import { extname, join, resolve } from 'node:path' import { app, BrowserWindow, dialog, ipcMain, shell, type WebContents } from 'electron' import type { IPty, IPtyForkOptions } from '@lydell/node-pty' import type { JournalEvent } from '@ari/contracts/events' @@ -99,6 +99,26 @@ import { todoFilenameFor } from '@ari/ari-core/todo' const log = createLogger('desktop:rpc') +/** Image types the custom-background picker offers and `wallpaper.read` serves. */ +const WALLPAPER_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'avif'] as const + +const WALLPAPER_MIME_TYPES: Readonly> = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.webp': 'image/webp', + '.gif': 'image/gif', + '.avif': 'image/avif', +} + +/** Ceiling on a background file; it is inlined into the renderer as a data URL. */ +const MAX_WALLPAPER_BYTES = 24 * 1024 * 1024 + +/** Data-URL mime for a background path, or null when it is not a served type. */ +function wallpaperMimeType(path: string): string | null { + return WALLPAPER_MIME_TYPES[extname(path).toLowerCase()] ?? null +} + /** * Kinds whose ACP server is probed for the agent's own model list. Adapters * resolve through npx and are probed with `--no-install` so background @@ -1178,6 +1198,48 @@ export function registerRpc(contents: WebContents, options: RegisterRpcOptions = return { path: result.filePaths[0] ?? null } }) + r.register('dialog.pickImages', async () => { + const parent = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0] ?? null + const options: Electron.OpenDialogOptions = { + properties: ['openFile', 'multiSelections'], + filters: [{ name: 'Images', extensions: [...WALLPAPER_EXTENSIONS] }], + } + const result = parent + ? await dialog.showOpenDialog(parent, options) + : await dialog.showOpenDialog(options) + if (result.canceled) return { paths: [] } + // The dialog filter is a hint the user can defeat by typing a filename, so + // the extension is checked again: `wallpaper.read` only serves the types in + // this map, and a path that can never load has no business being saved. + return { paths: result.filePaths.filter((path) => wallpaperMimeType(path) !== null) } + }) + + // Custom backgrounds live outside every project root, so the path jail does + // not apply here. The stored list is the allowlist instead: a path the user + // has not picked through the dialog above is never opened, so this cannot + // become a "read any file on disk" primitive for the renderer. + r.register('wallpaper.read', async (params) => { + const mimeType = wallpaperMimeType(params.path) + if (mimeType === null) return { dataUrl: null } + const store = getSettingsStore() + await store.load() + if (!store.current.appearance.customWallpapers.includes(params.path)) { + log.warn('refused a background read outside the saved list') + return { dataUrl: null } + } + try { + const info = await stat(params.path) + // A data URL costs roughly 4/3 the file size in renderer memory, so an + // enormous image is refused rather than silently wedging the window. + if (!info.isFile() || info.size > MAX_WALLPAPER_BYTES) return { dataUrl: null } + const bytes = await readFile(params.path) + return { dataUrl: `data:${mimeType};base64,${bytes.toString('base64')}` } + } catch { + // Deleted, renamed, or unreadable: the renderer falls back to the theme. + return { dataUrl: null } + } + }) + r.register('shell.revealPath', async (params) => { shell.showItemInFolder(await jailPath(params.path)) return { revealed: true } diff --git a/apps/desktop/src/renderer/src/App.test.tsx b/apps/desktop/src/renderer/src/App.test.tsx index e9446cf2..a0b11d23 100644 --- a/apps/desktop/src/renderer/src/App.test.tsx +++ b/apps/desktop/src/renderer/src/App.test.tsx @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Mock } from 'vitest' import { fireEvent } from '@testing-library/react' import { useToast } from '@ari/ui/toast' +import { defaultSettings } from '@ari/contracts/settings' import { AppProviders, App } from './App' import { BRANCH_POLL_MS, SessionBranchChip } from './features/session/SessionBranchChip' import { splitLayoutActions, splitLayoutSnapshot } from './features/split/use-split-layout' @@ -19,6 +20,17 @@ function ToastProbe() { } describe('AppProviders', () => { + beforeEach(() => { + // AppProviders mounts headless watchers that read the engine: the bare + // `vi.fn()` below answers every method with undefined, which no caller + // can await. + invokeMock.mockReset() + invokeMock.mockImplementation(async (method) => { + if (method === 'settings.get') return { appearance: defaultSettings.appearance } + return undefined + }) + }) + it('lets useToast consumers fire without a wrapping gallery', async () => { const user = userEvent.setup() render( diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 68f95a1f..4a161f08 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -62,6 +62,7 @@ import { useSidebarWidth, } from './shell/use-pane-width' import { WelcomePanel } from './features/welcome' +import { useCustomBackground } from './features/appearance/useCustomBackground' import './features/transcript/transcript.css' type InspectorId = Exclude @@ -1064,6 +1065,7 @@ export function AppProviders({ children }: { children: React.ReactNode }) { + {children} @@ -1078,6 +1080,16 @@ function UpdateToastWatcher() { return null } +/** + * Headless: paints the user's own background and rotates it. Mounted here + * rather than in the settings screen because rotation has to keep running + * while the user is working in a session. + */ +function BackgroundWatcher() { + useCustomBackground() + return null +} + const log = createLogger('app:shell') /** diff --git a/apps/desktop/src/renderer/src/features/appearance/custom-background.test.ts b/apps/desktop/src/renderer/src/features/appearance/custom-background.test.ts new file mode 100644 index 00000000..d7e62b27 --- /dev/null +++ b/apps/desktop/src/renderer/src/features/appearance/custom-background.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { + CLARITY_VAR_NAMES, + MS_PER_MINUTE, + clampRotationIndex, + clarityVars, + nextRotationIndex, + rotationIntervalMs, +} from './custom-background' + +/** Percentage/pixel number out of a `34%` / `6px` custom-property value. */ +function amount(value: string): number { + return Number(value.replace(/[^0-9.]/g, '')) +} + +describe('clarityVars', () => { + it('leaves the original frosted look at clarity 0', () => { + const vars = clarityVars(0) + expect(vars['--ari-wallpaper-tint']).toBe('72%') + expect(vars['--ari-wallpaper-tint-overlay']).toBe('82%') + expect(vars['--ari-wallpaper-tint-input']).toBe('85%') + // The same values wallpaper.css falls back to, so 0 is a no-op. + expect(vars['--ari-wallpaper-blur']).toBe('28px') + }) + + it('thins tint and blur as clarity rises', () => { + const dim = clarityVars(0) + const mid = clarityVars(0.5) + const clear = clarityVars(1) + for (const name of CLARITY_VAR_NAMES) { + expect(amount(mid[name] ?? '')).toBeLessThan(amount(dim[name] ?? '')) + expect(amount(clear[name] ?? '')).toBeLessThan(amount(mid[name] ?? '')) + } + }) + + it('shows the picture essentially bare at the clearest setting', () => { + const clear = clarityVars(1) + // No blur at all up here, and only a trace of tint — enough that panes + // still read as panes, not enough to wash the picture out. + expect(amount(clear['--ari-wallpaper-blur'] ?? '')).toBe(0) + expect(amount(clear['--ari-wallpaper-tint'] ?? '')).toBeGreaterThan(0) + expect(amount(clear['--ari-wallpaper-tint'] ?? '')).toBeLessThan(10) + }) + + it('clamps out-of-range and non-finite input instead of emitting junk CSS', () => { + expect(clarityVars(5)).toEqual(clarityVars(1)) + expect(clarityVars(-2)).toEqual(clarityVars(0)) + expect(clarityVars(Number.NaN)).toEqual(clarityVars(0)) + }) +}) + +describe('rotationIntervalMs', () => { + it('converts minutes to milliseconds', () => { + expect(rotationIntervalMs(10)).toBe(10 * MS_PER_MINUTE) + }) + + it('never schedules a timer below one minute', () => { + // A zero here would spin the interval as fast as the event loop allows. + expect(rotationIntervalMs(0)).toBe(MS_PER_MINUTE) + expect(rotationIntervalMs(-5)).toBe(MS_PER_MINUTE) + expect(rotationIntervalMs(Number.NaN)).toBe(MS_PER_MINUTE) + }) +}) + +describe('nextRotationIndex', () => { + it('advances and wraps', () => { + expect(nextRotationIndex(0, 3)).toBe(1) + expect(nextRotationIndex(2, 3)).toBe(0) + }) + + it('answers 0 with no images rather than dividing by zero', () => { + expect(nextRotationIndex(3, 0)).toBe(0) + }) +}) + +describe('clampRotationIndex', () => { + it('leaves an index that still points at an image', () => { + // Adding a picture must not move the one on screen. + expect(clampRotationIndex(2, 5)).toBe(2) + }) + + it('resets an index that fell off the end', () => { + expect(clampRotationIndex(4, 2)).toBe(0) + expect(clampRotationIndex(0, 0)).toBe(0) + expect(clampRotationIndex(-1, 3)).toBe(0) + }) +}) diff --git a/apps/desktop/src/renderer/src/features/appearance/custom-background.ts b/apps/desktop/src/renderer/src/features/appearance/custom-background.ts new file mode 100644 index 00000000..7768d925 --- /dev/null +++ b/apps/desktop/src/renderer/src/features/appearance/custom-background.ts @@ -0,0 +1,59 @@ +/** + * Custom background math, kept out of the hook so it can be tested without a + * DOM: how clear the scene reads, and which image rotation is due. + */ + +/** The frosted end of the clarity range — exactly wallpaper.css's fallbacks. */ +const OPAQUE = { tint: 72, overlay: 82, input: 85, blur: 28 } + +/** + * The clear end: the picture essentially as it is, with no blur and only a + * trace of tint left to keep panes distinguishable from each other. Text over + * a busy photo is genuinely harder to read up here, which is the point of the + * setting — the user decides how far to push it, and the range below the top + * end stays comfortable. + */ +const CLEAR = { tint: 6, overlay: 30, input: 38, blur: 0 } + +/** CSS custom properties wallpaper.css reads, for a clarity in [0, 1]. */ +export function clarityVars(clarity: number): Record { + const t = Math.min(1, Math.max(0, Number.isFinite(clarity) ? clarity : 0)) + const between = (from: number, to: number): number => from + (to - from) * t + return { + '--ari-wallpaper-tint': `${String(Math.round(between(OPAQUE.tint, CLEAR.tint)))}%`, + '--ari-wallpaper-tint-overlay': `${String(Math.round(between(OPAQUE.overlay, CLEAR.overlay)))}%`, + '--ari-wallpaper-tint-input': `${String(Math.round(between(OPAQUE.input, CLEAR.input)))}%`, + '--ari-wallpaper-blur': `${String(Math.round(between(OPAQUE.blur, CLEAR.blur)))}px`, + } +} + +/** Every property `clarityVars` sets, for clearing them again. */ +export const CLARITY_VAR_NAMES = Object.keys(clarityVars(0)) + +export const MS_PER_MINUTE = 60_000 + +/** Rotation period in ms; at least one minute however the setting is stored. */ +export function rotationIntervalMs(minutes: number): number { + const safe = Number.isFinite(minutes) ? Math.floor(minutes) : 1 + return Math.max(1, safe) * MS_PER_MINUTE +} + +/** + * Index of the image to show next. Wraps, and answers 0 for an empty library + * so a caller that just lost its last image still has a defined index. + */ +export function nextRotationIndex(current: number, count: number): number { + if (count <= 0) return 0 + return (current + 1) % count +} + +/** + * Keeps an index pointing at a real image after the library changes. Picking + * images is not rotation, so the shown image should not jump when the user + * adds one — only an index that fell off the end moves. + */ +export function clampRotationIndex(index: number, count: number): number { + if (count <= 0) return 0 + if (!Number.isFinite(index) || index < 0) return 0 + return index >= count ? 0 : Math.floor(index) +} diff --git a/apps/desktop/src/renderer/src/features/appearance/useCustomBackground.ts b/apps/desktop/src/renderer/src/features/appearance/useCustomBackground.ts new file mode 100644 index 00000000..3e72eac8 --- /dev/null +++ b/apps/desktop/src/renderer/src/features/appearance/useCustomBackground.ts @@ -0,0 +1,144 @@ +import { useEffect, useRef, useState } from 'react' +import { useTheme } from '@ari/ui/theme-provider' +import type { Settings } from '@ari/contracts/settings' +import { createLogger } from '@ari/shared/logger' +import { rpc } from '../../lib/rpc' +import { + CLARITY_VAR_NAMES, + clampRotationIndex, + clarityVars, + nextRotationIndex, + rotationIntervalMs, +} from './custom-background' + +const log = createLogger('appearance:background') + +/** + * Fired by the settings screen after it persists a background change. The + * watcher below runs app-wide (rotation has to keep going while the user is in + * a session), so it needs a nudge to re-read settings it did not write itself. + */ +export const APPEARANCE_CHANGED_EVENT = 'ari:appearance-changed' + +export function announceAppearanceChange(): void { + window.dispatchEvent(new CustomEvent(APPEARANCE_CHANGED_EVENT)) +} + +type Appearance = Settings['appearance'] + +/** Data URLs already read this session, keyed by path; images rarely change. */ +const imageCache = new Map>() + +function readImage(path: string): Promise { + const cached = imageCache.get(path) + if (cached) return cached + const pending = rpc + .invoke('wallpaper.read', { path }) + .then((result) => result.dataUrl) + .catch(() => null) + imageCache.set(path, pending) + return pending +} + +/** Drops cached images that are no longer in the user's library. */ +function pruneCache(paths: readonly string[]): void { + const keep = new Set(paths) + for (const path of imageCache.keys()) { + if (!keep.has(path)) imageCache.delete(path) + } +} + +/** + * Paints the user's own background and keeps the clarity variables in sync. + * + * Headless: mount once, high in the tree. The scene URL cannot come from the + * ThemeProvider because only the main process can read a file off disk, so + * this owns `--ari-wallpaper-image` whenever the selection is 'custom' (see + * applyWallpaperAttr, which deliberately leaves it alone for that case). + */ +export function useCustomBackground(): void { + const { wallpaper } = useTheme() + const [appearance, setAppearance] = useState(null) + const [index, setIndex] = useState(0) + // Rotation advances off a timer, so the effect that schedules it must not + // also depend on the index it sets — that would restart the clock every tick. + const indexRef = useRef(0) + indexRef.current = index + + useEffect(() => { + let cancelled = false + const load = (): void => { + void rpc.invoke('settings.get').then( + (settings) => { + if (!cancelled) setAppearance(settings.appearance) + }, + (error: unknown) => log.warn('settings.get failed; background unchanged', { error }), + ) + } + load() + window.addEventListener(APPEARANCE_CHANGED_EVENT, load) + return () => { + cancelled = true + window.removeEventListener(APPEARANCE_CHANGED_EVENT, load) + } + }, []) + + const images = appearance?.customWallpapers ?? [] + const rotating = (appearance?.wallpaperRotation ?? false) && images.length > 1 + const rotationMinutes = appearance?.wallpaperRotationMinutes ?? 10 + const clarity = appearance?.wallpaperClarity ?? 0 + // Joined, not the array: a fresh array identity on every settings read would + // otherwise restart rotation and re-run the paint effect each time. + const imageKey = JSON.stringify(images) + + useEffect(() => { + pruneCache(images) + setIndex((current) => clampRotationIndex(current, images.length)) + // `images` is covered by imageKey; the array identity itself is not stable. + }, [imageKey]) + + useEffect(() => { + if (!rotating || wallpaper !== 'custom') return + const timer = setInterval(() => { + setIndex((current) => nextRotationIndex(current, images.length)) + }, rotationIntervalMs(rotationMinutes)) + return () => clearInterval(timer) + }, [rotating, rotationMinutes, imageKey, wallpaper]) + + // Clarity applies to bundled scenes too — it describes the glass over any + // wallpaper — but means nothing with no wallpaper at all. + useEffect(() => { + const root = document.documentElement + if (wallpaper === 'none') { + for (const name of CLARITY_VAR_NAMES) root.style.removeProperty(name) + return + } + for (const [name, value] of Object.entries(clarityVars(clarity))) { + root.style.setProperty(name, value) + } + }, [clarity, wallpaper]) + + useEffect(() => { + const root = document.documentElement + if (wallpaper !== 'custom') return + const path = images[clampRotationIndex(index, images.length)] + if (path === undefined) { + // 'custom' with an empty library: no scene to paint, and the plate alone + // over the theme background is the honest result. + root.style.removeProperty('--ari-wallpaper-image') + return + } + let cancelled = false + void readImage(path).then((dataUrl) => { + if (cancelled) return + if (dataUrl === null) { + root.style.removeProperty('--ari-wallpaper-image') + return + } + root.style.setProperty('--ari-wallpaper-image', `url("${dataUrl}")`) + }) + return () => { + cancelled = true + } + }, [wallpaper, index, imageKey]) +} diff --git a/apps/desktop/src/renderer/src/features/settings/AdvancedSettings.test.tsx b/apps/desktop/src/renderer/src/features/settings/AdvancedSettings.test.tsx index d860a01b..86544c2f 100644 --- a/apps/desktop/src/renderer/src/features/settings/AdvancedSettings.test.tsx +++ b/apps/desktop/src/renderer/src/features/settings/AdvancedSettings.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { MockInstance } from 'vitest' @@ -32,6 +32,10 @@ const engineSettings: Settings = { glass: true, reducedMotion: false, wallpaper: 'none', + customWallpapers: [], + wallpaperRotation: false, + wallpaperRotationMinutes: 10, + wallpaperClarity: 0, }, sessions: { defaultDriverKind: null, defaultPermissionMode: 'ask' }, notifications: { settleSound: true }, diff --git a/apps/desktop/src/renderer/src/features/settings/AppearanceSettings.test.tsx b/apps/desktop/src/renderer/src/features/settings/AppearanceSettings.test.tsx index cb48431b..f4a26cef 100644 --- a/apps/desktop/src/renderer/src/features/settings/AppearanceSettings.test.tsx +++ b/apps/desktop/src/renderer/src/features/settings/AppearanceSettings.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Settings } from '@ari/contracts/settings' @@ -15,6 +15,10 @@ vi.mock('./useEngineSettings', () => ({ useEngineSettings: () => ({ settings: mocks.holder.settings, update: mocks.update }), })) +const rpcMocks = vi.hoisted(() => ({ invoke: vi.fn(), subscribe: vi.fn(() => () => undefined) })) + +vi.mock('../../lib/rpc', () => ({ rpc: rpcMocks })) + const engineSettings: Settings = { version: 1, delegation: delegationSettingsSchema.parse({}), @@ -24,6 +28,10 @@ const engineSettings: Settings = { glass: true, reducedMotion: false, wallpaper: 'none', + customWallpapers: [], + wallpaperRotation: false, + wallpaperRotationMinutes: 10, + wallpaperClarity: 0, }, sessions: { defaultDriverKind: null, defaultPermissionMode: 'ask' }, notifications: { settleSound: true }, @@ -135,6 +143,10 @@ describe('AppearanceSettings', () => { glass: true, reducedMotion: true, wallpaper: 'none', + customWallpapers: [], + wallpaperRotation: false, + wallpaperRotationMinutes: 10, + wallpaperClarity: 0, }, } renderPage() @@ -148,3 +160,146 @@ describe('AppearanceSettings', () => { ) }) }) + +describe('AppearanceSettings custom backgrounds', () => { + /** Engine settings carrying a picked-image library. */ + function withImages(images: string[], overrides: Partial = {}): Settings { + return { + ...engineSettings, + appearance: { + ...engineSettings.appearance, + wallpaper: 'custom', + customWallpapers: images, + ...overrides, + }, + } + } + + beforeEach(() => { + mocks.update.mockReset() + mocks.update.mockResolvedValue(engineSettings) + mocks.holder.settings = engineSettings + rpcMocks.invoke.mockReset() + localStorage.clear() + document.documentElement.removeAttribute('style') + }) + + it('offers the user library as a wallpaper choice', () => { + renderPage() + expect(screen.getByRole('radio', { name: /My images/ })).toBeInTheDocument() + }) + + it('hides the picker until the user library is the selection', async () => { + renderPage() + expect(screen.queryByRole('button', { name: /Add images/ })).not.toBeInTheDocument() + + const user = userEvent.setup() + await user.click(screen.getByRole('radio', { name: /My images/ })) + expect(await screen.findByRole('button', { name: /Add images/ })).toBeInTheDocument() + }) + + it('saves what the native picker returns and selects it', async () => { + rpcMocks.invoke.mockResolvedValue({ paths: ['C:\\pics\\one.jpg', 'C:\\pics\\two.png'] }) + renderPage() + const user = userEvent.setup() + await user.click(screen.getByRole('radio', { name: /My images/ })) + await user.click(await screen.findByRole('button', { name: /Add images/ })) + + await waitFor(() => + expect(mocks.update).toHaveBeenCalledWith({ + appearance: { customWallpapers: ['C:\\pics\\one.jpg', 'C:\\pics\\two.png'] }, + }), + ) + expect(rpcMocks.invoke).toHaveBeenCalledWith('dialog.pickImages') + await waitFor(() => { + expect(document.documentElement.dataset['ariWallpaper']).toBe('custom') + }) + }) + + /** + * Renders with a stored library and selects it. The panel follows the live + * ThemeProvider selection, not the stored one — in the app the provider + * hydrates that from the same settings, but a bare provider starts at 'none'. + */ + async function renderLibrary( + images: string[], + overrides: Partial = {}, + ): Promise { + mocks.holder.settings = withImages(images, overrides) + renderPage() + await userEvent.setup().click(screen.getByRole('radio', { name: /My images/ })) + } + + it('leaves the library alone when the picker is cancelled', async () => { + rpcMocks.invoke.mockResolvedValue({ paths: [] }) + await renderLibrary(['C:\\pics\\one.jpg']) + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: /Add images/ })) + + await waitFor(() => expect(rpcMocks.invoke).toHaveBeenCalledWith('dialog.pickImages')) + expect(mocks.update).not.toHaveBeenCalled() + }) + + it('lists picked images by filename and removes one', async () => { + await renderLibrary(['C:\\pics\\one.jpg', 'C:\\pics\\two.png']) + expect(await screen.findByText('one.jpg')).toBeInTheDocument() + + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: 'Remove one.jpg' })) + await waitFor(() => + expect(mocks.update).toHaveBeenCalledWith({ + appearance: { customWallpapers: ['C:\\pics\\two.png'] }, + }), + ) + }) + + it('cannot rotate a library with nothing to rotate through', async () => { + await renderLibrary(['C:\\pics\\one.jpg']) + expect(await screen.findByRole('switch', { name: 'Change automatically' })).toBeDisabled() + }) + + it('rotates once there is a second image', async () => { + await renderLibrary(['C:\\pics\\one.jpg', 'C:\\pics\\two.png']) + const toggle = await screen.findByRole('switch', { name: 'Change automatically' }) + expect(toggle).not.toBeDisabled() + + await userEvent.setup().click(toggle) + await waitFor(() => + expect(mocks.update).toHaveBeenCalledWith({ appearance: { wallpaperRotation: true } }), + ) + }) + + it('persists the rotation interval the user types', async () => { + await renderLibrary(['C:\\pics\\one.jpg', 'C:\\pics\\two.png'], { wallpaperRotation: true }) + const minutes = await screen.findByRole('spinbutton', { name: 'Minutes between changes' }) + + fireEvent.change(minutes, { target: { value: '25' } }) + + await waitFor(() => + expect(mocks.update).toHaveBeenCalledWith({ + appearance: { wallpaperRotationMinutes: 25 }, + }), + ) + }) + + it('shows the clarity slider only when a wallpaper is up, and applies it live', async () => { + renderPage() + expect(screen.queryByRole('slider', { name: 'Background clarity' })).not.toBeInTheDocument() + + const user = userEvent.setup() + await user.click(screen.getByRole('radio', { name: /Anime City/ })) + const slider = await screen.findByRole('slider', { name: 'Background clarity' }) + + // A debounce sits between the drag and the store, but the paint must not + // wait for it — the tint lands on immediately. + fireEvent.change(slider, { target: { value: '100' } }) + await waitFor(() => { + expect(document.documentElement.style.getPropertyValue('--ari-wallpaper-tint')).toBe('6%') + }) + await waitFor( + () => + expect(mocks.update).toHaveBeenCalledWith({ appearance: { wallpaperClarity: 1 } }), + { timeout: 2000 }, + ) + }) +}) diff --git a/apps/desktop/src/renderer/src/features/settings/AppearanceSettings.tsx b/apps/desktop/src/renderer/src/features/settings/AppearanceSettings.tsx index 6235925c..c3425a1c 100644 --- a/apps/desktop/src/renderer/src/features/settings/AppearanceSettings.tsx +++ b/apps/desktop/src/renderer/src/features/settings/AppearanceSettings.tsx @@ -1,17 +1,31 @@ -import { Check, Monitor } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { Check, ImagePlus, Images, Monitor, X } from 'lucide-react' import { createLogger } from '@ari/shared/logger' +import { Slider } from '@ari/ui/slider' import { Switch } from '@ari/ui/switch' import { useTheme } from '@ari/ui/theme-provider' import { themeList } from '@ari/ui/themes' import type { Theme, ThemeId } from '@ari/ui/themes' import { wallpapers } from '@ari/ui/wallpapers' import type { Wallpaper } from '@ari/ui/wallpapers' +import { + MAX_CUSTOM_WALLPAPERS, + MAX_WALLPAPER_ROTATION_MINUTES, + MIN_WALLPAPER_ROTATION_MINUTES, +} from '@ari/contracts/settings' +import type { WallpaperSetting } from '@ari/contracts/settings' +import { rpc } from '../../lib/rpc' +import { clarityVars } from '../appearance/custom-background' +import { announceAppearanceChange } from '../appearance/useCustomBackground' import { SettingsPage } from './SettingsPage' import { SettingsRow } from './SettingsRow' import { useEngineSettings } from './useEngineSettings' const log = createLogger('settings:appearance') +/** Dragging the slider must feel live, so the write is debounced behind it. */ +const CLARITY_COMMIT_MS = 300 + /** * Miniature window painted from the registry palette: a sidebar with one * active row, a transcript with an accent reply. Shows how the theme actually @@ -139,11 +153,13 @@ function WallpaperThumb({ wallpaper, theme }: { wallpaper: Wallpaper | null; the function WallpaperGroup({ theme, selected, + customCount, onSelect, }: { theme: Theme selected: string - onSelect: (wallpaper: 'none' | Wallpaper['id']) => void + customCount: number + onSelect: (wallpaper: WallpaperSetting) => void }) { return (
@@ -166,6 +182,159 @@ function WallpaperGroup({ onSelect={() => onSelect(wallpaper.id)} /> ))} +
+ ) +} + +/** Trailing path segment — the filename is what identifies a picture here. */ +function fileNameOf(path: string): string { + const parts = path.split(/[\\/]/) + return parts[parts.length - 1] ?? path +} + +/** + * The user's own background library: which pictures, and whether Ari cycles + * through them. Only rendered while "My images" is the selection, since none + * of it has an effect otherwise. + */ +function CustomBackgroundPanel({ + images, + rotation, + rotationMinutes, + onImagesChange, + onRotationChange, + onRotationMinutesChange, +}: { + images: readonly string[] + rotation: boolean + rotationMinutes: number + onImagesChange: (next: string[]) => void + onRotationChange: (next: boolean) => void + onRotationMinutesChange: (next: number) => void +}) { + const full = images.length >= MAX_CUSTOM_WALLPAPERS + + const addImages = (): void => { + void rpc.invoke('dialog.pickImages').then( + ({ paths }) => { + if (paths.length === 0) return // cancelled picker is a no-op + const merged = [...images] + for (const path of paths) { + if (!merged.includes(path)) merged.push(path) + } + onImagesChange(merged.slice(0, MAX_CUSTOM_WALLPAPERS)) + }, + (error: unknown) => log.warn('image picker failed', { error }), + ) + } + + return ( +
+
+

My images

+ +
+ + {images.length === 0 ? ( +

+ No pictures yet. Add JPG, PNG, WebP, GIF, or AVIF files from this computer. +

+ ) : ( +
    + {images.map((path) => ( +
  • + + {fileNameOf(path)} + + +
  • + ))} +
+ )} + {full ? ( +

+ That is the limit of {MAX_CUSTOM_WALLPAPERS} pictures. Remove one to add another. +

+ ) : null} + +
+ 1 + ? 'Cycle through your pictures while Ari is open.' + : 'Add a second picture to cycle between them.' + } + > + + + {rotation ? ( + + + { + const next = Number(event.target.value) + if (!Number.isFinite(next)) return + onRotationMinutesChange( + Math.min( + MAX_WALLPAPER_ROTATION_MINUTES, + Math.max(MIN_WALLPAPER_ROTATION_MINUTES, Math.floor(next)), + ), + ) + }} + className="h-7 w-20 rounded-md border border-border bg-glass-input px-2 text-xs text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring" + /> + min + + + ) : null}
) @@ -173,15 +342,33 @@ function WallpaperGroup({ /** * Appearance settings: theme picker (Light/Dark groups plus "Follow system"), - * the wallpaper picker, the glass opt-in for glass-capable themes, and reduced - * motion. Theme and wallpaper state live in the ThemeProvider, which persists - * through the engine settings store; reduced motion is written here directly. + * the wallpaper picker including the user's own pictures, the glass opt-in for + * glass-capable themes, and reduced motion. Theme and wallpaper selection live + * in the ThemeProvider, which persists through the engine settings store; the + * background library, rotation, clarity and reduced motion are written here. */ export function AppearanceSettings() { const { settings, update } = useEngineSettings() const { mode, setMode, theme, glassPreference, glassEnabled, setGlass, wallpaper, setWallpaper } = useTheme() const reducedMotion = settings?.appearance.reducedMotion ?? false + const images = settings?.appearance.customWallpapers ?? [] + const rotation = settings?.appearance.wallpaperRotation ?? false + const rotationMinutes = settings?.appearance.wallpaperRotationMinutes ?? 10 + const storedClarity = settings?.appearance.wallpaperClarity ?? 0 + + // The slider tracks the pointer locally; the store catches up behind it. + const [clarity, setClarity] = useState(storedClarity) + const draggingRef = useRef(false) + useEffect(() => { + if (!draggingRef.current) setClarity(storedClarity) + }, [storedClarity]) + + const persist = (patch: Parameters[0]): void => { + void update(patch).then(announceAppearanceChange, (error: unknown) => { + log.warn('failed to persist appearance', { error }) + }) + } const handleReducedMotionChange = (checked: boolean) => { void update({ appearance: { reducedMotion: checked } }).catch((error: unknown) => { @@ -189,6 +376,36 @@ export function AppearanceSettings() { }) } + const handleImagesChange = (next: string[]): void => { + persist({ appearance: { customWallpapers: next } }) + // Picking pictures is how a user asks for them, so the first add selects + // them too; losing the last one falls back to the plain theme rather than + // leaving "My images" selected with nothing to show. + if (next.length > 0 && wallpaper !== 'custom') setWallpaper('custom') + if (next.length === 0 && wallpaper === 'custom') setWallpaper('none') + } + + const commitTimer = useRef | null>(null) + useEffect(() => () => { + if (commitTimer.current !== null) clearTimeout(commitTimer.current) + }, []) + + const handleClarityChange = (value: number): void => { + const next = Math.min(1, Math.max(0, value / 100)) + draggingRef.current = true + setClarity(next) + // Paint immediately: the watcher only re-reads on a persisted change, and + // a slider that lags the pointer by a debounce feels broken. + for (const [name, cssValue] of Object.entries(clarityVars(next))) { + document.documentElement.style.setProperty(name, cssValue) + } + if (commitTimer.current !== null) clearTimeout(commitTimer.current) + commitTimer.current = setTimeout(() => { + draggingRef.current = false + persist({ appearance: { wallpaperClarity: next } }) + }, CLARITY_COMMIT_MS) + } + const dark = themeList.filter((t) => t.scheme === 'dark') const light = themeList.filter((t) => t.scheme === 'light') @@ -214,9 +431,39 @@ export function AppearanceSettings() { - + + + {wallpaper === 'custom' ? ( + persist({ appearance: { wallpaperRotation: next } })} + onRotationMinutesChange={(next) => + persist({ appearance: { wallpaperRotationMinutes: next } }) + } + /> + ) : null}
+ {wallpaper === 'none' ? null : ( + + + + )} {theme.glass ? ( { glass: true, reducedMotion: true, wallpaper: 'none', + customWallpapers: [], + wallpaperRotation: false, + wallpaperRotationMinutes: 10, + wallpaperClarity: 0, }, } const { result } = renderHook(() => useEngineSettings()) diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index e9961e38..1b53b3c3 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -464,6 +464,14 @@ export const rpcParams = { /** Slots a project ahead of `beforeId` in the sidebar (null = last). */ 'project.move': z.object({ id: z.string().min(1), beforeId: z.string().min(1).nullable() }), 'dialog.pickFolder': z.object({ defaultPath: z.string().min(1).optional() }), + /** Native multi-select image picker for custom backgrounds. */ + 'dialog.pickImages': z.undefined(), + /** + * Reads one custom background as a data URL. The path must already be in + * `appearance.customWallpapers`, so the renderer can only re-read images the + * user picked through the dialog above — never an arbitrary file. + */ + 'wallpaper.read': z.object({ path: z.string().min(1) }), 'shell.revealPath': z.object({ path: z.string().min(1) }), /** Opens a URL in the OS browser; only http/https/mailto reach openExternal. */ 'shell.openUrl': z.object({ url: z.string().min(1).max(2048) }), @@ -738,6 +746,10 @@ export interface RpcResults { 'project.move': Project | null /** Native folder picker; `path` is null when the user cancels (clean no-op). */ 'dialog.pickFolder': { path: string | null } + /** Native image picker; an empty array means the user cancelled. */ + 'dialog.pickImages': { paths: string[] } + /** `dataUrl` is null when the file is missing, too large, or not an image. */ + 'wallpaper.read': { dataUrl: string | null } 'shell.revealPath': { revealed: boolean } 'shell.openUrl': { opened: boolean } 'files.index': { paths: string[] } diff --git a/packages/contracts/src/settings-wallpaper.test.ts b/packages/contracts/src/settings-wallpaper.test.ts new file mode 100644 index 00000000..6d161f97 --- /dev/null +++ b/packages/contracts/src/settings-wallpaper.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_CUSTOM_WALLPAPERS, + MAX_WALLPAPER_ROTATION_MINUTES, + settingsSchema, + settingsUpdateSchema, + wallpaperSchema, +} from './settings' + +/** Settings as they parse from a file that predates custom backgrounds. */ +const legacy = { + version: 1, + appearance: { themeId: 'obsidian', mode: 'system', glass: true, wallpaper: 'anime-city' }, +} + +describe('custom background settings', () => { + it('accepts the user-picked selection alongside the bundled scenes', () => { + expect(wallpaperSchema.parse('custom')).toBe('custom') + expect(wallpaperSchema.parse('none')).toBe('none') + expect(wallpaperSchema.parse('anime-city')).toBe('anime-city') + expect(wallpaperSchema.safeParse('not-a-wallpaper').success).toBe(false) + }) + + it('defaults an older settings file to no images and no rotation', () => { + const parsed = settingsSchema.parse(legacy) + expect(parsed.appearance.wallpaper).toBe('anime-city') + expect(parsed.appearance.customWallpapers).toEqual([]) + expect(parsed.appearance.wallpaperRotation).toBe(false) + expect(parsed.appearance.wallpaperRotationMinutes).toBe(10) + // Clarity 0 is the pre-existing look, so an upgrade changes nothing. + expect(parsed.appearance.wallpaperClarity).toBe(0) + }) + + it('gives each parse its own image list', () => { + const first = settingsSchema.parse({ version: 1 }) + const second = settingsSchema.parse({ version: 1 }) + first.appearance.customWallpapers.push('C:\\pictures\\one.jpg') + expect(second.appearance.customWallpapers).toEqual([]) + }) + + it('refuses a library larger than the cap', () => { + const paths = Array.from({ length: MAX_CUSTOM_WALLPAPERS + 1 }, (_, i) => `C:\\pic\\${i}.jpg`) + expect(settingsUpdateSchema.safeParse({ appearance: { customWallpapers: paths } }).success).toBe( + false, + ) + expect( + settingsUpdateSchema.safeParse({ appearance: { customWallpapers: paths.slice(1) } }).success, + ).toBe(true) + }) + + it('bounds the rotation interval and clarity', () => { + const bad = [ + { wallpaperRotationMinutes: 0 }, + { wallpaperRotationMinutes: MAX_WALLPAPER_ROTATION_MINUTES + 1 }, + { wallpaperRotationMinutes: 2.5 }, + { wallpaperClarity: -0.1 }, + { wallpaperClarity: 1.1 }, + ] + for (const appearance of bad) { + expect(settingsUpdateSchema.safeParse({ appearance }).success).toBe(false) + } + expect( + settingsUpdateSchema.safeParse({ appearance: { wallpaperRotationMinutes: 1440 } }).success, + ).toBe(true) + expect(settingsUpdateSchema.safeParse({ appearance: { wallpaperClarity: 1 } }).success).toBe( + true, + ) + }) +}) diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 1a836649..9e5d183d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -25,16 +25,33 @@ export type ThemeMode = z.infer export const wallpaperIdSchema = z.enum(['anime-city', 'moon-landscape', 'moon-landscape-2']) export type WallpaperIdSetting = z.infer -/** A wallpaper selection: a bundled scene, or 'none' for the plain theme. */ -export const wallpaperSchema = z.union([z.literal('none'), wallpaperIdSchema]) +/** + * A wallpaper selection: a bundled scene, 'custom' for the user's own picked + * images, or 'none' for the plain theme. + */ +export const wallpaperSchema = z.union([ + z.literal('none'), + z.literal('custom'), + wallpaperIdSchema, +]) export type WallpaperSetting = z.infer +/** Upper bound on the picked-image library; keeps the settings file small. */ +export const MAX_CUSTOM_WALLPAPERS = 40 + +/** Rotation bounds, in minutes: one minute to one day. */ +export const MIN_WALLPAPER_ROTATION_MINUTES = 1 +export const MAX_WALLPAPER_ROTATION_MINUTES = 1440 + const defaultAppearance = { themeId: 'obsidian', mode: 'system', glass: true, reducedMotion: false, wallpaper: 'none', + wallpaperRotation: false, + wallpaperRotationMinutes: 10, + wallpaperClarity: 0, } as const /** Persisted application settings. Versioned for forward migration. */ @@ -58,8 +75,34 @@ export const settingsSchema = z.object({ reducedMotion: z.boolean().default(defaultAppearance.reducedMotion), /** Bundled background scene composited under the themed UI, or 'none'. */ wallpaper: wallpaperSchema.default(defaultAppearance.wallpaper), + /** + * Absolute paths to the user's own background images. Also the allowlist + * the engine checks before reading any file for the renderer, so a path + * that is not in here is never opened. + */ + customWallpapers: z + .array(z.string().min(1)) + .max(MAX_CUSTOM_WALLPAPERS) + .default(() => []), + /** Cycle through `customWallpapers` instead of holding one image. */ + wallpaperRotation: z.boolean().default(defaultAppearance.wallpaperRotation), + /** Minutes each image is shown while rotation is on. */ + wallpaperRotationMinutes: z + .number() + .int() + .min(MIN_WALLPAPER_ROTATION_MINUTES) + .max(MAX_WALLPAPER_ROTATION_MINUTES) + .default(defaultAppearance.wallpaperRotationMinutes), + /** + * How plainly the scene reads through the app's glass: 0 keeps the + * original frosted plate, 1 thins the tint and blur to their legible + * floor. Never reaches zero tint — text has to stay readable. + */ + wallpaperClarity: z.number().min(0).max(1).default(defaultAppearance.wallpaperClarity), }) - .default(defaultAppearance), + // A function default: the picked-image list is per-install mutable state, + // so every parse has to get its own array rather than share one literal. + .default(() => ({ ...defaultAppearance, customWallpapers: [] })), sessions: z .object({ defaultDriverKind: driverKindSchema.nullable().default(null), @@ -106,6 +149,14 @@ export const settingsUpdateSchema = z.object({ glass: z.boolean(), reducedMotion: z.boolean(), wallpaper: wallpaperSchema, + customWallpapers: z.array(z.string().min(1)).max(MAX_CUSTOM_WALLPAPERS), + wallpaperRotation: z.boolean(), + wallpaperRotationMinutes: z + .number() + .int() + .min(MIN_WALLPAPER_ROTATION_MINUTES) + .max(MAX_WALLPAPER_ROTATION_MINUTES), + wallpaperClarity: z.number().min(0).max(1), }) .partial() .optional(), @@ -144,7 +195,7 @@ export type SettingsUpdate = z.input export const defaultSettings: Settings = { delegation: delegationSettingsSchema.parse({}), version: 1, - appearance: { ...defaultAppearance }, + appearance: { ...defaultAppearance, customWallpapers: [] }, sessions: { defaultDriverKind: null, defaultPermissionMode: 'ask' }, notifications: { settleSound: true }, permissions: { allowlist: [] }, diff --git a/packages/ui/package.json b/packages/ui/package.json index 204cb3b6..72fe36a9 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -21,6 +21,7 @@ "./textarea": "./src/components/Textarea.tsx", "./field": "./src/components/Field.tsx", "./switch": "./src/components/Switch.tsx", + "./slider": "./src/components/Slider.tsx", "./checkbox": "./src/components/Checkbox.tsx", "./badge": "./src/components/Badge.tsx", "./kbd": "./src/components/Kbd.tsx", diff --git a/packages/ui/src/components/Slider.tsx b/packages/ui/src/components/Slider.tsx new file mode 100644 index 00000000..8547cc3f --- /dev/null +++ b/packages/ui/src/components/Slider.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react' +import type { InputHTMLAttributes } from 'react' + +export interface SliderProps + extends Omit, 'value' | 'defaultValue' | 'onChange' | 'type'> { + /** Controlled value; provide together with onValueChange. */ + value?: number + /** Initial value for uncontrolled usage. */ + defaultValue?: number + min?: number + max?: number + step?: number + /** Called with the next value as the user drags or keys through the track. */ + onValueChange?: (value: number) => void +} + +/** + * Range control on the native input, so keyboard, screen readers, and pointer + * behavior come from the platform. Track and thumb take their color from the + * accent token via `accent-color`. + */ +export function Slider({ + value, + defaultValue = 0, + min = 0, + max = 100, + step = 1, + onValueChange, + className, + ...rest +}: SliderProps) { + const [internal, setInternal] = useState(defaultValue) + const isControlled = value !== undefined + const current = isControlled ? value : internal + + const handleChange = (event: React.ChangeEvent) => { + const next = Number(event.target.value) + if (!isControlled) setInternal(next) + onValueChange?.(next) + } + + return ( + + ) +} diff --git a/packages/ui/src/theme-provider.tsx b/packages/ui/src/theme-provider.tsx index cf489b3f..db53b324 100644 --- a/packages/ui/src/theme-provider.tsx +++ b/packages/ui/src/theme-provider.tsx @@ -115,6 +115,13 @@ function applyWallpaperAttr(root: HTMLElement, wallpaper: WallpaperSetting): voi root.style.removeProperty('--ari-wallpaper-image') return } + // 'custom' images live on the user's disk, which only the main process can + // read, so the app owns `--ari-wallpaper-image` here. Writing it would race + // the app's own effect and blank the scene. + if (wallpaper === 'custom') { + root.dataset['ariWallpaper'] = wallpaper + return + } root.dataset['ariWallpaper'] = wallpaper const src = wallpapers.find((w) => w.id === wallpaper)?.src if (src === undefined) root.style.removeProperty('--ari-wallpaper-image') diff --git a/packages/ui/src/wallpaper-css.test.ts b/packages/ui/src/wallpaper-css.test.ts index b41bdbda..9b6484e7 100644 --- a/packages/ui/src/wallpaper-css.test.ts +++ b/packages/ui/src/wallpaper-css.test.ts @@ -14,7 +14,12 @@ describe('wallpaper.css', () => { it('paints one continuous plate carrying the glass recipe', () => { expect(css).toContain('[data-ari-wallpaper] .ari-glass-pane {') expect(css).toMatch(/\.ari-glass-pane \{[^}]*background: var\(--ari-glass-scrim\)/) - expect(css).toMatch(/\.ari-glass-pane \{[^}]*backdrop-filter: blur\(28px\) saturate\(1\.35\)/) + // The radius is a variable so Background clarity can thin it, but the + // fallback is the original 28px: a build with no clarity set paints + // exactly as it did before the setting existed. + expect(css).toMatch( + /\.ari-glass-pane \{[^}]*backdrop-filter: blur\(var\(--ari-wallpaper-blur, 28px\)\) saturate\(1\.35\)/, + ) }) it('neutralizes nested chrome and pane fills so no surface double-tints', () => { diff --git a/packages/ui/src/wallpaper.css b/packages/ui/src/wallpaper.css index 33507420..a87a9dd1 100644 --- a/packages/ui/src/wallpaper.css +++ b/packages/ui/src/wallpaper.css @@ -31,10 +31,29 @@ [data-ari-wallpaper] { /* Glass planes rest on the wallpaper instead of the desktop, so they take - theme-derived translucency in every theme, not just glass-capable ones. */ - --ari-glass-scrim: color-mix(in oklab, var(--ari-surface-0) 72%, transparent); - --ari-glass-overlay: color-mix(in oklab, var(--ari-surface-2) 82%, transparent); - --ari-glass-input: color-mix(in oklab, var(--ari-surface-1) 85%, transparent); + theme-derived translucency in every theme, not just glass-capable ones. + + The four percentages and the blur radius are the *opaque* end of the + clarity setting. The app overrides these four custom properties on + as the user drags Background clarity; the fallbacks here are the original + frosted look, so a build with no clarity setting paints exactly as before. + The clarity floor lives in the app (see background-clarity.ts) because + legibility is a product decision, not a cascade one. */ + --ari-glass-scrim: color-mix( + in oklab, + var(--ari-surface-0) var(--ari-wallpaper-tint, 72%), + transparent + ); + --ari-glass-overlay: color-mix( + in oklab, + var(--ari-surface-2) var(--ari-wallpaper-tint-overlay, 82%), + transparent + ); + --ari-glass-input: color-mix( + in oklab, + var(--ari-surface-1) var(--ari-wallpaper-tint-input, 85%), + transparent + ); } /* The scene. Negative z-index paints it above the canvas background but below @@ -63,8 +82,8 @@ /* The single plate: one continuous frosted surface for the whole window. */ [data-ari-wallpaper] .ari-glass-pane { background: var(--ari-glass-scrim); - -webkit-backdrop-filter: blur(28px) saturate(1.35); - backdrop-filter: blur(28px) saturate(1.35); + -webkit-backdrop-filter: blur(var(--ari-wallpaper-blur, 28px)) saturate(1.35); + backdrop-filter: blur(var(--ari-wallpaper-blur, 28px)) saturate(1.35); } /* Nothing inside the plate re-tints it: structural chrome and pane fills go diff --git a/packages/ui/src/wallpapers.ts b/packages/ui/src/wallpapers.ts index 7fb2560c..d39743ff 100644 --- a/packages/ui/src/wallpapers.ts +++ b/packages/ui/src/wallpapers.ts @@ -16,8 +16,13 @@ import moonLandscape2 from './assets/wallpapers/moon-landscape-2.jpg' export const wallpaperIds = ['anime-city', 'moon-landscape', 'moon-landscape-2'] as const export type WallpaperId = (typeof wallpaperIds)[number] -/** A wallpaper preference: a bundled scene, or 'none' for the plain theme. */ -export type WallpaperSetting = 'none' | WallpaperId +/** + * A wallpaper preference: a bundled scene, 'custom' for the user's own picked + * images, or 'none' for the plain theme. The scene URL for 'custom' is not + * known to this package — the app supplies it, since only the main process can + * read a file off the user's disk. + */ +export type WallpaperSetting = 'none' | 'custom' | WallpaperId export interface Wallpaper { id: WallpaperId @@ -55,6 +60,5 @@ export function isWallpaperId(value: unknown): value is WallpaperId { } export function isWallpaperSetting(value: unknown): value is WallpaperSetting { - return value === 'none' || isWallpaperId(value) + return value === 'none' || value === 'custom' || isWallpaperId(value) } - From 0dce87f9df277e87dcbd11d1e896b5520b270a84 Mon Sep 17 00:00:00 2001 From: aryamthecodebreaker Date: Sun, 13 Sep 2026 15:15:20 +0530 Subject: [PATCH 2/7] feat(ui): hold text legible as background clarity rises The clarity slider thins the plate that keeps text readable, so past the middle of the range text starts competing with the picture behind it. A halo fades in behind the text as that happens, drawn from the theme's own background color: dark behind light text in dark themes, light behind dark text in light ones, so both schemes gain contrast instead of one losing it. Fully transparent at clarity 0, so the default frosted look carries no shadow. Co-Authored-By: Claude Opus 5 --- .../appearance/custom-background.test.ts | 23 ++++++++++++++++++- .../features/appearance/custom-background.ts | 12 ++++++++++ packages/ui/src/wallpaper-css.test.ts | 9 ++++++++ packages/ui/src/wallpaper.css | 7 ++++++ 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/src/features/appearance/custom-background.test.ts b/apps/desktop/src/renderer/src/features/appearance/custom-background.test.ts index d7e62b27..67428068 100644 --- a/apps/desktop/src/renderer/src/features/appearance/custom-background.test.ts +++ b/apps/desktop/src/renderer/src/features/appearance/custom-background.test.ts @@ -14,6 +14,9 @@ function amount(value: string): number { } describe('clarityVars', () => { + /** The plate properties, which thin out as clarity rises. */ + const PLATE_VARS = CLARITY_VAR_NAMES.filter((name) => !name.includes('halo')) + it('leaves the original frosted look at clarity 0', () => { const vars = clarityVars(0) expect(vars['--ari-wallpaper-tint']).toBe('72%') @@ -21,18 +24,36 @@ describe('clarityVars', () => { expect(vars['--ari-wallpaper-tint-input']).toBe('85%') // The same values wallpaper.css falls back to, so 0 is a no-op. expect(vars['--ari-wallpaper-blur']).toBe('28px') + // The plate alone keeps text legible here, so no halo behind it. + expect(vars['--ari-wallpaper-halo']).toBe('0%') + expect(vars['--ari-wallpaper-halo-blur']).toBe('0px') }) it('thins tint and blur as clarity rises', () => { const dim = clarityVars(0) const mid = clarityVars(0.5) const clear = clarityVars(1) - for (const name of CLARITY_VAR_NAMES) { + for (const name of PLATE_VARS) { expect(amount(mid[name] ?? '')).toBeLessThan(amount(dim[name] ?? '')) expect(amount(clear[name] ?? '')).toBeLessThan(amount(mid[name] ?? '')) } }) + it('strengthens the text halo as the plate thins, to keep text readable', () => { + // The halo runs opposite the plate: whatever legibility the tint and blur + // stop providing, it has to take over. + const dim = clarityVars(0) + const mid = clarityVars(0.5) + const clear = clarityVars(1) + expect(amount(mid['--ari-wallpaper-halo'] ?? '')).toBeGreaterThan( + amount(dim['--ari-wallpaper-halo'] ?? ''), + ) + expect(amount(clear['--ari-wallpaper-halo'] ?? '')).toBeGreaterThan( + amount(mid['--ari-wallpaper-halo'] ?? ''), + ) + expect(amount(clear['--ari-wallpaper-halo-blur'] ?? '')).toBeGreaterThan(0) + }) + it('shows the picture essentially bare at the clearest setting', () => { const clear = clarityVars(1) // No blur at all up here, and only a trace of tint — enough that panes diff --git a/apps/desktop/src/renderer/src/features/appearance/custom-background.ts b/apps/desktop/src/renderer/src/features/appearance/custom-background.ts index 7768d925..ec0852a8 100644 --- a/apps/desktop/src/renderer/src/features/appearance/custom-background.ts +++ b/apps/desktop/src/renderer/src/features/appearance/custom-background.ts @@ -15,6 +15,16 @@ const OPAQUE = { tint: 72, overlay: 82, input: 85, blur: 28 } */ const CLEAR = { tint: 6, overlay: 30, input: 38, blur: 0 } +/** + * Text halo at each end. The plate is what keeps text legible at clarity 0, so + * there is nothing to add there; as the plate thins out, a halo in the theme's + * own background color fades in behind the text. Background, not a fixed + * black: that darkens text in light themes and lightens it in dark ones, which + * is the direction legibility actually needs in each. + */ +const HALO = { opaque: 0, clear: 88 } +const HALO_BLUR = { opaque: 0, clear: 4 } + /** CSS custom properties wallpaper.css reads, for a clarity in [0, 1]. */ export function clarityVars(clarity: number): Record { const t = Math.min(1, Math.max(0, Number.isFinite(clarity) ? clarity : 0)) @@ -24,6 +34,8 @@ export function clarityVars(clarity: number): Record { '--ari-wallpaper-tint-overlay': `${String(Math.round(between(OPAQUE.overlay, CLEAR.overlay)))}%`, '--ari-wallpaper-tint-input': `${String(Math.round(between(OPAQUE.input, CLEAR.input)))}%`, '--ari-wallpaper-blur': `${String(Math.round(between(OPAQUE.blur, CLEAR.blur)))}px`, + '--ari-wallpaper-halo': `${String(Math.round(between(HALO.opaque, HALO.clear)))}%`, + '--ari-wallpaper-halo-blur': `${String(Math.round(between(HALO_BLUR.opaque, HALO_BLUR.clear)))}px`, } } diff --git a/packages/ui/src/wallpaper-css.test.ts b/packages/ui/src/wallpaper-css.test.ts index 9b6484e7..5ed25f83 100644 --- a/packages/ui/src/wallpaper-css.test.ts +++ b/packages/ui/src/wallpaper-css.test.ts @@ -22,6 +22,15 @@ describe('wallpaper.css', () => { ) }) + it('hangs the text halo off the clarity variables, inert by default', () => { + // 0% of the background color is transparent, so the frosted default paints + // no shadow at all; the app raises it as it thins the plate. + expect(css).toMatch(/text-shadow:[^;]*var\(--ari-wallpaper-halo-blur, 0\)/) + expect(css).toMatch(/text-shadow:[^;]*var\(--ari-wallpaper-halo, 0%\)/) + // Theme background, not a fixed dark: light themes need the opposite. + expect(css).toMatch(/text-shadow:[^;]*var\(--ari-bg\)/) + }) + it('neutralizes nested chrome and pane fills so no surface double-tints', () => { expect(css).toContain('[data-ari-wallpaper] .ari-glass-pane .ari-glass,') expect(css).toContain('[data-ari-wallpaper] .ari-glass-pane .bg-bg {') diff --git a/packages/ui/src/wallpaper.css b/packages/ui/src/wallpaper.css index a87a9dd1..1aa6a225 100644 --- a/packages/ui/src/wallpaper.css +++ b/packages/ui/src/wallpaper.css @@ -84,6 +84,13 @@ background: var(--ari-glass-scrim); -webkit-backdrop-filter: blur(var(--ari-wallpaper-blur, 28px)) saturate(1.35); backdrop-filter: blur(var(--ari-wallpaper-blur, 28px)) saturate(1.35); + /* Legibility as the plate thins: a halo in the theme's own background color, + which is dark behind light text and light behind dark text, so both + schemes gain contrast rather than one of them losing it. Fully transparent + at the default clarity, so the frosted look carries no shadow at all. */ + text-shadow: + 0 0 var(--ari-wallpaper-halo-blur, 0) + color-mix(in oklab, var(--ari-bg) var(--ari-wallpaper-halo, 0%), transparent); } /* Nothing inside the plate re-tints it: structural chrome and pane fills go From a32b32964e25b2eed58b1cf92f2e9f70da3cdfe3 Mon Sep 17 00:00:00 2001 From: aryamthecodebreaker Date: Sun, 13 Sep 2026 15:22:59 +0530 Subject: [PATCH 3/7] fix(ui): keep the composer and text findable at high clarity Two things went wrong once the plate thinned all the way out over a bright picture: - The composer and popovers followed the window plate down to a few percent of tint and dissolved into the scene. They are targets, not backdrop, so their tint now stays high while the pane behind them goes clear; with the blur they already carry from glass.css they read as a dark glossy plate resting on the picture. - One soft text shadow disappeared against a busy, bright photo. The halo is three stacked layers now: a tight opaque pass that holds the glyph edges, and a wider one that pulls the picture down around the text. Co-Authored-By: Claude Opus 5 --- .../appearance/custom-background.test.ts | 9 +++++++++ .../features/appearance/custom-background.ts | 19 +++++++++++-------- packages/ui/src/wallpaper-css.test.ts | 13 ++++++++++--- packages/ui/src/wallpaper.css | 16 +++++++++++++--- 4 files changed, 43 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/renderer/src/features/appearance/custom-background.test.ts b/apps/desktop/src/renderer/src/features/appearance/custom-background.test.ts index 67428068..953bd24c 100644 --- a/apps/desktop/src/renderer/src/features/appearance/custom-background.test.ts +++ b/apps/desktop/src/renderer/src/features/appearance/custom-background.test.ts @@ -54,6 +54,15 @@ describe('clarityVars', () => { expect(amount(clear['--ari-wallpaper-halo-blur'] ?? '')).toBeGreaterThan(0) }) + it('keeps inputs and overlays findable while the pane goes clear', () => { + const clear = clarityVars(1) + const pane = amount(clear['--ari-wallpaper-tint'] ?? '') + // The composer is a thing you aim at: it has to stay a plate on top of the + // picture, not dissolve into it along with the window behind it. + expect(amount(clear['--ari-wallpaper-tint-input'] ?? '')).toBeGreaterThan(pane + 50) + expect(amount(clear['--ari-wallpaper-tint-overlay'] ?? '')).toBeGreaterThan(pane + 50) + }) + it('shows the picture essentially bare at the clearest setting', () => { const clear = clarityVars(1) // No blur at all up here, and only a trace of tint — enough that panes diff --git a/apps/desktop/src/renderer/src/features/appearance/custom-background.ts b/apps/desktop/src/renderer/src/features/appearance/custom-background.ts index ec0852a8..eb20d0b4 100644 --- a/apps/desktop/src/renderer/src/features/appearance/custom-background.ts +++ b/apps/desktop/src/renderer/src/features/appearance/custom-background.ts @@ -7,13 +7,16 @@ const OPAQUE = { tint: 72, overlay: 82, input: 85, blur: 28 } /** - * The clear end: the picture essentially as it is, with no blur and only a - * trace of tint left to keep panes distinguishable from each other. Text over - * a busy photo is genuinely harder to read up here, which is the point of the - * setting — the user decides how far to push it, and the range below the top - * end stays comfortable. + * The clear end. The window plate goes to a trace of tint and no blur at all, + * so the picture reads as itself. + * + * Inputs and floating surfaces deliberately do not follow it down. The + * composer, popovers and menus are things you aim at: they have to stay + * findable against any picture, and they keep their own blur from glass.css, + * so holding their tint high reads as a dark glossy plate resting on the + * scene rather than a pane that dissolved into it. */ -const CLEAR = { tint: 6, overlay: 30, input: 38, blur: 0 } +const CLEAR = { tint: 6, overlay: 64, input: 74, blur: 0 } /** * Text halo at each end. The plate is what keeps text legible at clarity 0, so @@ -22,8 +25,8 @@ const CLEAR = { tint: 6, overlay: 30, input: 38, blur: 0 } * black: that darkens text in light themes and lightens it in dark ones, which * is the direction legibility actually needs in each. */ -const HALO = { opaque: 0, clear: 88 } -const HALO_BLUR = { opaque: 0, clear: 4 } +const HALO = { opaque: 0, clear: 100 } +const HALO_BLUR = { opaque: 0, clear: 6 } /** CSS custom properties wallpaper.css reads, for a clarity in [0, 1]. */ export function clarityVars(clarity: number): Record { diff --git a/packages/ui/src/wallpaper-css.test.ts b/packages/ui/src/wallpaper-css.test.ts index 5ed25f83..22125d30 100644 --- a/packages/ui/src/wallpaper-css.test.ts +++ b/packages/ui/src/wallpaper-css.test.ts @@ -25,10 +25,17 @@ describe('wallpaper.css', () => { it('hangs the text halo off the clarity variables, inert by default', () => { // 0% of the background color is transparent, so the frosted default paints // no shadow at all; the app raises it as it thins the plate. - expect(css).toMatch(/text-shadow:[^;]*var\(--ari-wallpaper-halo-blur, 0\)/) - expect(css).toMatch(/text-shadow:[^;]*var\(--ari-wallpaper-halo, 0%\)/) + expect(css).toMatch(/--ari-wallpaper-halo-color:[^;]*var\(--ari-wallpaper-halo, 0%\)/) // Theme background, not a fixed dark: light themes need the opposite. - expect(css).toMatch(/text-shadow:[^;]*var\(--ari-bg\)/) + expect(css).toMatch(/--ari-wallpaper-halo-color:[^;]*var\(--ari-bg\)/) + expect(css).toMatch(/text-shadow:[^;]*var\(--ari-wallpaper-halo-blur, 0px\)/) + }) + + it('stacks the halo so it survives a bright, busy picture', () => { + // One soft shadow vanishes against a photo; the outline needs layering. + const start = css.indexOf('text-shadow:') + const shadow = css.slice(start, css.indexOf(';', start)) + expect(shadow.match(/--ari-wallpaper-halo-color/g)).toHaveLength(3) }) it('neutralizes nested chrome and pane fills so no surface double-tints', () => { diff --git a/packages/ui/src/wallpaper.css b/packages/ui/src/wallpaper.css index 1aa6a225..4e2a186c 100644 --- a/packages/ui/src/wallpaper.css +++ b/packages/ui/src/wallpaper.css @@ -87,10 +87,20 @@ /* Legibility as the plate thins: a halo in the theme's own background color, which is dark behind light text and light behind dark text, so both schemes gain contrast rather than one of them losing it. Fully transparent - at the default clarity, so the frosted look carries no shadow at all. */ + at the default clarity, so the frosted look carries no shadow at all. + + Three stacked layers, not one: a single shadow disappears against a busy, + bright photo. Tight-and-opaque reads as an outline that holds the glyph + edges, and the wide one darkens the picture immediately around the text. */ + --ari-wallpaper-halo-color: color-mix( + in oklab, + var(--ari-bg) var(--ari-wallpaper-halo, 0%), + transparent + ); text-shadow: - 0 0 var(--ari-wallpaper-halo-blur, 0) - color-mix(in oklab, var(--ari-bg) var(--ari-wallpaper-halo, 0%), transparent); + 0 0 var(--ari-wallpaper-halo-blur, 0px) var(--ari-wallpaper-halo-color), + 0 1px calc(var(--ari-wallpaper-halo-blur, 0px) * 1.5) var(--ari-wallpaper-halo-color), + 0 0 calc(var(--ari-wallpaper-halo-blur, 0px) * 3) var(--ari-wallpaper-halo-color); } /* Nothing inside the plate re-tints it: structural chrome and pane fills go From 8dfa854e08a7f7552de633430b3ca2d6a5b0aa2a Mon Sep 17 00:00:00 2001 From: aryamthecodebreaker Date: Mon, 14 Sep 2026 02:54:46 +0530 Subject: [PATCH 4/7] fix(ui): trace text with an outline instead of a glow at high clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The halo was three stacked soft shadows. A blur wide enough to read over a busy photo spreads into the counters and between letters, so text gained contrast and lost its edge — legible, but soft. Four hard one-pixel offsets trace the glyph instead, with one small blur left to pad the contour so it does not look stencilled. Same contrast, sharp edge, and one fewer shadow layer to paint. Reduced transparency now drops the halo outright: that mode restores an almost opaque plate, so the outline compensating for a thin one is noise in the mode asking for less of it. Co-Authored-By: Claude Opus 5 --- .../appearance/custom-background.test.ts | 1 + .../features/appearance/custom-background.ts | 9 +++++++- packages/ui/src/wallpaper-css.test.ts | 17 ++++++++++++--- packages/ui/src/wallpaper.css | 21 +++++++++++++------ 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/renderer/src/features/appearance/custom-background.test.ts b/apps/desktop/src/renderer/src/features/appearance/custom-background.test.ts index 953bd24c..85bf0780 100644 --- a/apps/desktop/src/renderer/src/features/appearance/custom-background.test.ts +++ b/apps/desktop/src/renderer/src/features/appearance/custom-background.test.ts @@ -26,6 +26,7 @@ describe('clarityVars', () => { expect(vars['--ari-wallpaper-blur']).toBe('28px') // The plate alone keeps text legible here, so no halo behind it. expect(vars['--ari-wallpaper-halo']).toBe('0%') + expect(vars['--ari-wallpaper-halo-offset']).toBe('0px') expect(vars['--ari-wallpaper-halo-blur']).toBe('0px') }) diff --git a/apps/desktop/src/renderer/src/features/appearance/custom-background.ts b/apps/desktop/src/renderer/src/features/appearance/custom-background.ts index eb20d0b4..54c1f721 100644 --- a/apps/desktop/src/renderer/src/features/appearance/custom-background.ts +++ b/apps/desktop/src/renderer/src/features/appearance/custom-background.ts @@ -24,9 +24,15 @@ const CLEAR = { tint: 6, overlay: 64, input: 74, blur: 0 } * own background color fades in behind the text. Background, not a fixed * black: that darkens text in light themes and lightens it in dark ones, which * is the direction legibility actually needs in each. + * + * The halo is mostly an *outline*, not a glow. A wide soft shadow spreads out + * from the glyph and the edge goes mushy exactly where it needs to be sharp; + * a one-pixel hard offset on each side reads as a crisp contour instead. The + * small blur that remains only pads the contour so it does not look stencilled. */ const HALO = { opaque: 0, clear: 100 } -const HALO_BLUR = { opaque: 0, clear: 6 } +const HALO_OFFSET = { opaque: 0, clear: 1 } +const HALO_BLUR = { opaque: 0, clear: 3 } /** CSS custom properties wallpaper.css reads, for a clarity in [0, 1]. */ export function clarityVars(clarity: number): Record { @@ -38,6 +44,7 @@ export function clarityVars(clarity: number): Record { '--ari-wallpaper-tint-input': `${String(Math.round(between(OPAQUE.input, CLEAR.input)))}%`, '--ari-wallpaper-blur': `${String(Math.round(between(OPAQUE.blur, CLEAR.blur)))}px`, '--ari-wallpaper-halo': `${String(Math.round(between(HALO.opaque, HALO.clear)))}%`, + '--ari-wallpaper-halo-offset': `${String(Math.round(between(HALO_OFFSET.opaque, HALO_OFFSET.clear)))}px`, '--ari-wallpaper-halo-blur': `${String(Math.round(between(HALO_BLUR.opaque, HALO_BLUR.clear)))}px`, } } diff --git a/packages/ui/src/wallpaper-css.test.ts b/packages/ui/src/wallpaper-css.test.ts index 22125d30..7fa892f7 100644 --- a/packages/ui/src/wallpaper-css.test.ts +++ b/packages/ui/src/wallpaper-css.test.ts @@ -31,11 +31,22 @@ describe('wallpaper.css', () => { expect(css).toMatch(/text-shadow:[^;]*var\(--ari-wallpaper-halo-blur, 0px\)/) }) - it('stacks the halo so it survives a bright, busy picture', () => { - // One soft shadow vanishes against a photo; the outline needs layering. + it('traces the glyph with hard offsets rather than one soft glow', () => { + // A blur wide enough to read over a photo spreads into the counters and + // the text goes soft; four hard offsets keep the contour sharp. const start = css.indexOf('text-shadow:') const shadow = css.slice(start, css.indexOf(';', start)) - expect(shadow.match(/--ari-wallpaper-halo-color/g)).toHaveLength(3) + expect(shadow.match(/--ari-wallpaper-halo-color/g)).toHaveLength(5) + // Right, left, down, up: each a zero-blur offset. + expect(shadow.match(/ 0 var\(--ari-wallpaper-halo-color\)/g)).toHaveLength(4) + expect(css).toMatch(/--ari-wallpaper-halo-inset:[^;]*\* -1\)/) + }) + + it('drops the halo entirely under reduced transparency', () => { + // That mode restores an almost opaque plate, so the outline it compensates + // for is noise in the mode asking for less of it. + const media = css.slice(css.indexOf('@media (prefers-reduced-transparency: reduce)')) + expect(media).toContain('text-shadow: none') }) it('neutralizes nested chrome and pane fills so no surface double-tints', () => { diff --git a/packages/ui/src/wallpaper.css b/packages/ui/src/wallpaper.css index 4e2a186c..069da048 100644 --- a/packages/ui/src/wallpaper.css +++ b/packages/ui/src/wallpaper.css @@ -89,18 +89,23 @@ schemes gain contrast rather than one of them losing it. Fully transparent at the default clarity, so the frosted look carries no shadow at all. - Three stacked layers, not one: a single shadow disappears against a busy, - bright photo. Tight-and-opaque reads as an outline that holds the glyph - edges, and the wide one darkens the picture immediately around the text. */ + An outline, not a glow. Four hard one-pixel offsets trace the glyph and + keep its edge sharp; a blurred shadow wide enough to be visible over a + busy photo spreads into the counters and letter spacing instead, and the + text reads soft. The single small blur below only pads that contour so it + does not look stencilled. */ --ari-wallpaper-halo-color: color-mix( in oklab, var(--ari-bg) var(--ari-wallpaper-halo, 0%), transparent ); + --ari-wallpaper-halo-inset: calc(var(--ari-wallpaper-halo-offset, 0px) * -1); text-shadow: - 0 0 var(--ari-wallpaper-halo-blur, 0px) var(--ari-wallpaper-halo-color), - 0 1px calc(var(--ari-wallpaper-halo-blur, 0px) * 1.5) var(--ari-wallpaper-halo-color), - 0 0 calc(var(--ari-wallpaper-halo-blur, 0px) * 3) var(--ari-wallpaper-halo-color); + var(--ari-wallpaper-halo-offset, 0px) 0 0 var(--ari-wallpaper-halo-color), + var(--ari-wallpaper-halo-inset) 0 0 var(--ari-wallpaper-halo-color), + 0 var(--ari-wallpaper-halo-offset, 0px) 0 var(--ari-wallpaper-halo-color), + 0 var(--ari-wallpaper-halo-inset) 0 var(--ari-wallpaper-halo-color), + 0 0 var(--ari-wallpaper-halo-blur, 0px) var(--ari-wallpaper-halo-color); } /* Nothing inside the plate re-tints it: structural chrome and pane fills go @@ -125,5 +130,9 @@ [data-ari-wallpaper] .ari-glass-pane { -webkit-backdrop-filter: none; backdrop-filter: none; + /* The halo pays for a thin plate. This mode restores an almost opaque one, + so a saved clarity would otherwise leave every text node wearing an + outline it no longer needs — noise in the mode asking for less of it. */ + text-shadow: none; } } From 9654d8d6d78f22e9af5daef61fb71265786a8c74 Mon Sep 17 00:00:00 2001 From: aryamthecodebreaker Date: Mon, 14 Sep 2026 15:32:57 +0530 Subject: [PATCH 5/7] fix(ui): bound the background cache and keep text readable at any clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Readability. The clearest setting took the window plate down to 6% tint and no blur, and over bright or busy photos text was hard to read even with the outline. It now keeps about a third of the plate and a light blur: the tint pulls any photo toward the theme's tone behind the text, and the blur strips the fine detail sitting directly behind glyphs, which is what actually fights reading. The picture stays recognisable; inputs and overlays stay well above the pane so the composer is always findable. Memory. imageCache kept every data URL rotation ever visited. A data URL costs about 4/3 of its file, so a valid 40-image library at the 24 MiB reader limit held over a gigabyte for the whole session. The cache now keeps only the picture on screen and, while rotating, the next one (decoded ahead so the change has no blank frame) — two at most, however large the library. Failed reads were cached as null forever, so one read that failed while a drive slept left that picture blank until restart; they are dropped now. Adding or removing pictures started the library write and the selection write together. Both go through SettingsStore.update, which builds from its in-memory copy and writes through one temp file, so they could overwrite each other or fail a rename. The selection now follows the library write. Leaving Appearance inside the clarity debounce cancelled the only pending write: the preview kept the value and a restart reverted it. The page now flushes it on the way out. The picker saved files over the reader's size limit, which then sat in the library as blank backgrounds; it now drops them at pick time. Also removes an indexRef that was assigned but never read, and mojibake in three comments left by an earlier encoding slip. Co-Authored-By: Claude Opus 5 --- apps/desktop/src/main/rpc.ts | 18 +++++-- .../appearance/custom-background.test.ts | 16 +++--- .../features/appearance/custom-background.ts | 41 ++++++++++++--- .../appearance/retained-paths.test.ts | 42 ++++++++++++++++ .../appearance/useCustomBackground.ts | 50 +++++++++++++------ .../settings/AppearanceSettings.test.tsx | 38 +++++++++++++- .../features/settings/AppearanceSettings.tsx | 43 ++++++++++++---- 7 files changed, 203 insertions(+), 45 deletions(-) create mode 100644 apps/desktop/src/renderer/src/features/appearance/retained-paths.test.ts diff --git a/apps/desktop/src/main/rpc.ts b/apps/desktop/src/main/rpc.ts index 89afc308..023b7121 100644 --- a/apps/desktop/src/main/rpc.ts +++ b/apps/desktop/src/main/rpc.ts @@ -1209,9 +1209,21 @@ export function registerRpc(contents: WebContents, options: RegisterRpcOptions = : await dialog.showOpenDialog(options) if (result.canceled) return { paths: [] } // The dialog filter is a hint the user can defeat by typing a filename, so - // the extension is checked again: `wallpaper.read` only serves the types in - // this map, and a path that can never load has no business being saved. - return { paths: result.filePaths.filter((path) => wallpaperMimeType(path) !== null) } + // the type is checked again, and so is the size: `wallpaper.read` refuses + // anything over MAX_WALLPAPER_BYTES, and a path saved here that can never + // load would sit in the library as a blank background. The reader keeps + // its own checks for files that change after they were picked. + const accepted: string[] = [] + for (const path of result.filePaths) { + if (wallpaperMimeType(path) === null) continue + try { + const info = await stat(path) + if (info.isFile() && info.size <= MAX_WALLPAPER_BYTES) accepted.push(path) + } catch { + // Gone between the dialog closing and now: nothing to save. + } + } + return { paths: accepted } }) // Custom backgrounds live outside every project root, so the path jail does diff --git a/apps/desktop/src/renderer/src/features/appearance/custom-background.test.ts b/apps/desktop/src/renderer/src/features/appearance/custom-background.test.ts index 85bf0780..8c671404 100644 --- a/apps/desktop/src/renderer/src/features/appearance/custom-background.test.ts +++ b/apps/desktop/src/renderer/src/features/appearance/custom-background.test.ts @@ -60,17 +60,17 @@ describe('clarityVars', () => { const pane = amount(clear['--ari-wallpaper-tint'] ?? '') // The composer is a thing you aim at: it has to stay a plate on top of the // picture, not dissolve into it along with the window behind it. - expect(amount(clear['--ari-wallpaper-tint-input'] ?? '')).toBeGreaterThan(pane + 50) - expect(amount(clear['--ari-wallpaper-tint-overlay'] ?? '')).toBeGreaterThan(pane + 50) + expect(amount(clear['--ari-wallpaper-tint-input'] ?? '')).toBeGreaterThan(pane + 30) + expect(amount(clear['--ari-wallpaper-tint-overlay'] ?? '')).toBeGreaterThan(pane + 30) }) - it('shows the picture essentially bare at the clearest setting', () => { + it('keeps a readable plate even at the clearest setting', () => { const clear = clarityVars(1) - // No blur at all up here, and only a trace of tint — enough that panes - // still read as panes, not enough to wash the picture out. - expect(amount(clear['--ari-wallpaper-blur'] ?? '')).toBe(0) - expect(amount(clear['--ari-wallpaper-tint'] ?? '')).toBeGreaterThan(0) - expect(amount(clear['--ari-wallpaper-tint'] ?? '')).toBeLessThan(10) + // A trace of tint and no blur failed over bright, busy photos: text was + // hard to read even outlined. The floor keeps real tint behind the text + // and enough blur to strip the fine detail directly behind the glyphs. + expect(amount(clear['--ari-wallpaper-tint'] ?? '')).toBeGreaterThanOrEqual(25) + expect(amount(clear['--ari-wallpaper-blur'] ?? '')).toBeGreaterThan(0) }) it('clamps out-of-range and non-finite input instead of emitting junk CSS', () => { diff --git a/apps/desktop/src/renderer/src/features/appearance/custom-background.ts b/apps/desktop/src/renderer/src/features/appearance/custom-background.ts index 54c1f721..e0027a35 100644 --- a/apps/desktop/src/renderer/src/features/appearance/custom-background.ts +++ b/apps/desktop/src/renderer/src/features/appearance/custom-background.ts @@ -7,16 +7,22 @@ const OPAQUE = { tint: 72, overlay: 82, input: 85, blur: 28 } /** - * The clear end. The window plate goes to a trace of tint and no blur at all, - * so the picture reads as itself. + * The clear end, and a floor on legibility rather than a picture with text + * scattered over it. * - * Inputs and floating surfaces deliberately do not follow it down. The - * composer, popovers and menus are things you aim at: they have to stay - * findable against any picture, and they keep their own blur from glass.css, - * so holding their tint high reads as a dark glossy plate resting on the - * scene rather than a pane that dissolved into it. + * A trace of tint and no blur looked striking and failed at the one job the + * window has: over a bright or busy photo, text was hard to read even with an + * outline. So the clearest setting still keeps about a third of the plate — + * enough to pull any photo toward the theme's own tone behind the text — and a + * light blur, which strips the fine detail sitting directly behind glyphs (the + * thing that actually fights reading) while the picture stays recognisable. + * + * Inputs and floating surfaces deliberately sit well above that. The composer, + * popovers and menus are things you aim at: they have to stay findable against + * any picture, and they keep their own blur from glass.css, so holding their + * tint high reads as a dark glossy plate resting on the scene. */ -const CLEAR = { tint: 6, overlay: 64, input: 74, blur: 0 } +const CLEAR = { tint: 32, overlay: 70, input: 78, blur: 6 } /** * Text halo at each end. The plate is what keeps text legible at clarity 0, so @@ -79,3 +85,22 @@ export function clampRotationIndex(index: number, count: number): number { if (!Number.isFinite(index) || index < 0) return 0 return index >= count ? 0 : Math.floor(index) } + +/** + * The pictures worth holding decoded: the one on screen and, while rotating, + * the one due next, so the change lands without a blank frame. Never more than + * two whatever the library size — memory stays flat as pictures are added, + * where caching every picture visited grew with the library. + */ +export function retainedPaths( + images: readonly string[], + index: number, + rotating: boolean, +): string[] { + const at = clampRotationIndex(index, images.length) + const current = images[at] + if (current === undefined) return [] + if (!rotating) return [current] + const next = images[nextRotationIndex(at, images.length)] + return next === undefined || next === current ? [current] : [current, next] +} diff --git a/apps/desktop/src/renderer/src/features/appearance/retained-paths.test.ts b/apps/desktop/src/renderer/src/features/appearance/retained-paths.test.ts new file mode 100644 index 00000000..86b8bc2d --- /dev/null +++ b/apps/desktop/src/renderer/src/features/appearance/retained-paths.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { retainedPaths } from './custom-background' + +/** + * The background cache holds only what this returns, so its length is the + * memory bound: caching every picture visited grew with the library, and a + * forty-image library at the reader's size limit held over a gigabyte. + */ +describe('retainedPaths', () => { + const library = ['a.jpg', 'b.jpg', 'c.jpg'] + + it('holds nothing for an empty library', () => { + expect(retainedPaths([], 0, true)).toEqual([]) + }) + + it('holds only the picture on screen when not rotating', () => { + expect(retainedPaths(library, 1, false)).toEqual(['b.jpg']) + }) + + it('also holds the next picture while rotating, so the change has no blank frame', () => { + expect(retainedPaths(library, 0, true)).toEqual(['a.jpg', 'b.jpg']) + }) + + it('wraps to the first picture after the last', () => { + expect(retainedPaths(library, 2, true)).toEqual(['c.jpg', 'a.jpg']) + }) + + it('does not count a single picture twice when it is also next', () => { + expect(retainedPaths(['only.jpg'], 0, true)).toEqual(['only.jpg']) + }) + + it('stays at two however large the library grows', () => { + const big = Array.from({ length: 40 }, (_, i) => `${String(i)}.jpg`) + for (const index of [0, 17, 39]) { + expect(retainedPaths(big, index, true).length).toBeLessThanOrEqual(2) + } + }) + + it('recovers when the index fell off a library that shrank', () => { + expect(retainedPaths(library, 9, false)).toEqual(['a.jpg']) + }) +}) diff --git a/apps/desktop/src/renderer/src/features/appearance/useCustomBackground.ts b/apps/desktop/src/renderer/src/features/appearance/useCustomBackground.ts index 3e72eac8..39a71ce8 100644 --- a/apps/desktop/src/renderer/src/features/appearance/useCustomBackground.ts +++ b/apps/desktop/src/renderer/src/features/appearance/useCustomBackground.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import { useTheme } from '@ari/ui/theme-provider' import type { Settings } from '@ari/contracts/settings' import { createLogger } from '@ari/shared/logger' @@ -8,6 +8,7 @@ import { clampRotationIndex, clarityVars, nextRotationIndex, + retainedPaths, rotationIntervalMs, } from './custom-background' @@ -26,7 +27,13 @@ export function announceAppearanceChange(): void { type Appearance = Settings['appearance'] -/** Data URLs already read this session, keyed by path; images rarely change. */ +/** + * Data URLs for the pictures rotation needs right now — the one on screen and + * the one due next — and nothing else. A data URL costs about 4/3 of its file + * in renderer memory, so caching every picture visited would grow with the + * library: forty images at the reader's size limit is over a gigabyte held for + * the whole session. See {@link retainedPaths} for what is kept. + */ const imageCache = new Map>() function readImage(path: string): Promise { @@ -37,11 +44,16 @@ function readImage(path: string): Promise { .then((result) => result.dataUrl) .catch(() => null) imageCache.set(path, pending) + // A failed read is not a fact about the file — it may have been mid-copy or + // on a drive that was asleep — so it must not be remembered as one. + void pending.then((dataUrl) => { + if (dataUrl === null && imageCache.get(path) === pending) imageCache.delete(path) + }) return pending } -/** Drops cached images that are no longer in the user's library. */ -function pruneCache(paths: readonly string[]): void { +/** Releases every cached picture outside `paths`. */ +function retainOnly(paths: readonly string[]): void { const keep = new Set(paths) for (const path of imageCache.keys()) { if (!keep.has(path)) imageCache.delete(path) @@ -60,10 +72,6 @@ export function useCustomBackground(): void { const { wallpaper } = useTheme() const [appearance, setAppearance] = useState(null) const [index, setIndex] = useState(0) - // Rotation advances off a timer, so the effect that schedules it must not - // also depend on the index it sets — that would restart the clock every tick. - const indexRef = useRef(0) - indexRef.current = index useEffect(() => { let cancelled = false @@ -87,16 +95,17 @@ export function useCustomBackground(): void { const rotating = (appearance?.wallpaperRotation ?? false) && images.length > 1 const rotationMinutes = appearance?.wallpaperRotationMinutes ?? 10 const clarity = appearance?.wallpaperClarity ?? 0 - // Joined, not the array: a fresh array identity on every settings read would - // otherwise restart rotation and re-run the paint effect each time. + // A value, not the array: every settings read hands back a fresh array, and + // depending on its identity would restart rotation and repaint each time. const imageKey = JSON.stringify(images) useEffect(() => { - pruneCache(images) setIndex((current) => clampRotationIndex(current, images.length)) // `images` is covered by imageKey; the array identity itself is not stable. }, [imageKey]) + // The interval does not depend on `index`: the functional update advances it, + // so the clock is not restarted by the very tick it produces. useEffect(() => { if (!rotating || wallpaper !== 'custom') return const timer = setInterval(() => { @@ -105,8 +114,8 @@ export function useCustomBackground(): void { return () => clearInterval(timer) }, [rotating, rotationMinutes, imageKey, wallpaper]) - // Clarity applies to bundled scenes too — it describes the glass over any - // wallpaper — but means nothing with no wallpaper at all. + // Clarity applies to bundled scenes too — it describes the glass over any + // wallpaper — but means nothing with no wallpaper at all. useEffect(() => { const root = document.documentElement if (wallpaper === 'none') { @@ -120,8 +129,14 @@ export function useCustomBackground(): void { useEffect(() => { const root = document.documentElement - if (wallpaper !== 'custom') return - const path = images[clampRotationIndex(index, images.length)] + if (wallpaper !== 'custom') { + // Nothing of ours is on screen, so nothing of ours stays in memory. + retainOnly([]) + return + } + const keep = retainedPaths(images, index, rotating) + retainOnly(keep) + const [path, upcoming] = keep if (path === undefined) { // 'custom' with an empty library: no scene to paint, and the plate alone // over the theme background is the honest result. @@ -137,8 +152,11 @@ export function useCustomBackground(): void { } root.style.setProperty('--ari-wallpaper-image', `url("${dataUrl}")`) }) + // Decode the next picture ahead of its turn so the change lands at once + // rather than on a blank frame; it is one of the two the cache may hold. + if (upcoming !== undefined) void readImage(upcoming) return () => { cancelled = true } - }, [wallpaper, index, imageKey]) + }, [wallpaper, index, imageKey, rotating]) } diff --git a/apps/desktop/src/renderer/src/features/settings/AppearanceSettings.test.tsx b/apps/desktop/src/renderer/src/features/settings/AppearanceSettings.test.tsx index f4a26cef..262cbbde 100644 --- a/apps/desktop/src/renderer/src/features/settings/AppearanceSettings.test.tsx +++ b/apps/desktop/src/renderer/src/features/settings/AppearanceSettings.test.tsx @@ -294,7 +294,7 @@ describe('AppearanceSettings custom backgrounds', () => { // wait for it — the tint lands on immediately. fireEvent.change(slider, { target: { value: '100' } }) await waitFor(() => { - expect(document.documentElement.style.getPropertyValue('--ari-wallpaper-tint')).toBe('6%') + expect(document.documentElement.style.getPropertyValue('--ari-wallpaper-tint')).toBe('32%') }) await waitFor( () => @@ -302,4 +302,40 @@ describe('AppearanceSettings custom backgrounds', () => { { timeout: 2000 }, ) }) + + it('saves a clarity change even when the page closes inside the debounce', async () => { + const view = renderPage() + const user = userEvent.setup() + await user.click(screen.getByRole('radio', { name: /Anime City/ })) + const slider = await screen.findByRole('slider', { name: 'Background clarity' }) + + fireEvent.change(slider, { target: { value: '60' } }) + // Leaving before the debounce fires used to cancel the only pending write, + // so the preview showed the value and a restart quietly reverted it. + view.unmount() + + expect(mocks.update).toHaveBeenCalledWith({ appearance: { wallpaperClarity: 0.6 } }) + }) + + it('finishes removing the last picture before dropping back to the plain theme', async () => { + await renderLibrary(['C:\\pics\\one.jpg']) + let release!: (value: Settings) => void + mocks.update.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve + }), + ) + + await userEvent.setup().click(await screen.findByRole('button', { name: 'Remove one.jpg' })) + // While the library write is in flight the selection must not move: both + // go through one settings store, and a second write started alongside the + // first could overwrite it or fail its rename. + expect(document.documentElement.dataset['ariWallpaper']).toBe('custom') + + release(engineSettings) + await waitFor(() => { + expect(document.documentElement.dataset['ariWallpaper']).toBeUndefined() + }) + }) }) diff --git a/apps/desktop/src/renderer/src/features/settings/AppearanceSettings.tsx b/apps/desktop/src/renderer/src/features/settings/AppearanceSettings.tsx index c3425a1c..c8fe9ef6 100644 --- a/apps/desktop/src/renderer/src/features/settings/AppearanceSettings.tsx +++ b/apps/desktop/src/renderer/src/features/settings/AppearanceSettings.tsx @@ -377,22 +377,46 @@ export function AppearanceSettings() { } const handleImagesChange = (next: string[]): void => { - persist({ appearance: { customWallpapers: next } }) - // Picking pictures is how a user asks for them, so the first add selects - // them too; losing the last one falls back to the plain theme rather than - // leaving "My images" selected with nothing to show. - if (next.length > 0 && wallpaper !== 'custom') setWallpaper('custom') - if (next.length === 0 && wallpaper === 'custom') setWallpaper('none') + // The library write lands before the selection changes. Both reach + // SettingsStore.update, which builds from its in-memory copy and writes + // through a single temp file, so starting them together could let one + // overwrite the other or fail the other's rename. + void update({ appearance: { customWallpapers: next } }).then( + () => { + announceAppearanceChange() + // Picking pictures is how a user asks for them, so the first add + // selects them too; losing the last one falls back to the plain theme + // rather than leaving "My images" selected with nothing to show. + if (next.length > 0 && wallpaper !== 'custom') setWallpaper('custom') + if (next.length === 0 && wallpaper === 'custom') setWallpaper('none') + }, + (error: unknown) => log.warn('failed to persist appearance', { error }), + ) } const commitTimer = useRef | null>(null) - useEffect(() => () => { - if (commitTimer.current !== null) clearTimeout(commitTimer.current) - }, []) + /** A clarity the preview already shows but the store has not been told. */ + const pendingClarity = useRef(null) + // Leaving the page inside the debounce must still save the value: the + // preview has already painted it, so a restart that quietly reverted it + // would read as a setting that did not stick. + useEffect( + () => () => { + if (commitTimer.current !== null) clearTimeout(commitTimer.current) + const pending = pendingClarity.current + if (pending === null) return + void update({ appearance: { wallpaperClarity: pending } }).then( + announceAppearanceChange, + (error: unknown) => log.warn('failed to persist clarity on leave', { error }), + ) + }, + [update], + ) const handleClarityChange = (value: number): void => { const next = Math.min(1, Math.max(0, value / 100)) draggingRef.current = true + pendingClarity.current = next setClarity(next) // Paint immediately: the watcher only re-reads on a persisted change, and // a slider that lags the pointer by a debounce feels broken. @@ -402,6 +426,7 @@ export function AppearanceSettings() { if (commitTimer.current !== null) clearTimeout(commitTimer.current) commitTimer.current = setTimeout(() => { draggingRef.current = false + pendingClarity.current = null persist({ appearance: { wallpaperClarity: next } }) }, CLARITY_COMMIT_MS) } From f4235cedec0ae0d3f8650dd59f23c750b9c2a32b Mon Sep 17 00:00:00 2001 From: aryamthecodebreaker Date: Mon, 14 Sep 2026 19:36:35 +0530 Subject: [PATCH 6/7] fix(ui): keep the text outline off filled and opaque surfaces The outline was applied to every text node on the plate. It exists for text sitting directly on the thinned plate, but it also landed on white labels over accent and danger buttons and badges, where a theme-colored contour reads as dirt rather than contrast, and on the composer, inputs, popovers, surface cards and code, which already carry their own contrast. text-shadow inherits, so clearing it on those surfaces clears it for everything inside them. Co-Authored-By: Claude Opus 5 --- packages/ui/src/wallpaper-css.test.ts | 23 +++++++++++++++++++++++ packages/ui/src/wallpaper.css | 20 ++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/packages/ui/src/wallpaper-css.test.ts b/packages/ui/src/wallpaper-css.test.ts index 7fa892f7..0248abc6 100644 --- a/packages/ui/src/wallpaper-css.test.ts +++ b/packages/ui/src/wallpaper-css.test.ts @@ -49,6 +49,29 @@ describe('wallpaper.css', () => { expect(media).toContain('text-shadow: none') }) + it('keeps the outline off text that already sits on a filled or opaque surface', () => { + // A theme-colored outline around a white label on an accent button looks + // dirty; the composer, inputs, popovers, cards and code have their own + // contrast. text-shadow inherits, so clearing it on the surface suffices. + const plain = css.replace(/\/\*[\s\S]*?\*\//g, '') + const start = plain.indexOf('.ari-glass-pane :is(') + expect(start).toBeGreaterThan(-1) + const rule = plain.slice(start, plain.indexOf('}', start)) + for (const surface of [ + '.text-fg-on-accent', + '.ari-composer-shell', + '.ari-glass-input', + '.ari-glass-overlay', + "[class*='bg-surface-']", + 'input', + 'textarea', + 'code', + ]) { + expect(rule).toContain(surface) + } + expect(rule).toContain('text-shadow: none') + }) + it('neutralizes nested chrome and pane fills so no surface double-tints', () => { expect(css).toContain('[data-ari-wallpaper] .ari-glass-pane .ari-glass,') expect(css).toContain('[data-ari-wallpaper] .ari-glass-pane .bg-bg {') diff --git a/packages/ui/src/wallpaper.css b/packages/ui/src/wallpaper.css index 069da048..6bbb16b6 100644 --- a/packages/ui/src/wallpaper.css +++ b/packages/ui/src/wallpaper.css @@ -119,6 +119,26 @@ backdrop-filter: none; } +/* The outline is for text sitting directly on the thinned plate. Text on a + filled or opaque surface already carries its own contrast, and a theme-colored + outline around a white label on an accent button reads as dirt, not clarity. + text-shadow inherits, so clearing it on the surface clears it for everything + inside: buttons and badges, the composer, inputs, popovers, surface cards and + code, whose monospace glyphs are too tight to outline cleanly. */ +[data-ari-wallpaper] .ari-glass-pane :is( + .text-fg-on-accent, + .ari-composer-shell, + .ari-glass-input, + .ari-glass-overlay, + [class*='bg-surface-'], + input, + textarea, + pre, + code + ) { + text-shadow: none; +} + /* Reduced transparency: keep the scene visible (it is an explicit opt-in) but drop the blur and raise the tint so contrast never depends on blur. */ @media (prefers-reduced-transparency: reduce) { From c4a7097f7b16ac11a473e0db83566cb7d9ab1b61 Mon Sep 17 00:00:00 2001 From: aryamthecodebreaker Date: Mon, 14 Sep 2026 19:38:25 +0530 Subject: [PATCH 7/7] docs(ui): point the clarity comment at the file that owns the floor The comment named background-clarity.ts, which never existed; the clarity mapping and its legibility floor live in custom-background.ts. Co-Authored-By: Claude Opus 5 --- packages/ui/src/wallpaper.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/wallpaper.css b/packages/ui/src/wallpaper.css index 6bbb16b6..3bb1e857 100644 --- a/packages/ui/src/wallpaper.css +++ b/packages/ui/src/wallpaper.css @@ -37,7 +37,7 @@ clarity setting. The app overrides these four custom properties on as the user drags Background clarity; the fallbacks here are the original frosted look, so a build with no clarity setting paints exactly as before. - The clarity floor lives in the app (see background-clarity.ts) because + The clarity floor lives in the app (see custom-background.ts) because legibility is a product decision, not a cascade one. */ --ari-glass-scrim: color-mix( in oklab,