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..023b7121 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,60 @@ 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 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 + // 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..8c671404 --- /dev/null +++ b/apps/desktop/src/renderer/src/features/appearance/custom-background.test.ts @@ -0,0 +1,118 @@ +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', () => { + /** 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%') + 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') + // 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') + }) + + 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 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('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 + 30) + expect(amount(clear['--ari-wallpaper-tint-overlay'] ?? '')).toBeGreaterThan(pane + 30) + }) + + it('keeps a readable plate even at the clearest setting', () => { + const clear = clarityVars(1) + // 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', () => { + 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..e0027a35 --- /dev/null +++ b/apps/desktop/src/renderer/src/features/appearance/custom-background.ts @@ -0,0 +1,106 @@ +/** + * 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, and a floor on legibility rather than a picture with text + * scattered over 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: 32, overlay: 70, input: 78, blur: 6 } + +/** + * 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. + * + * 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_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 { + 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`, + '--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`, + } +} + +/** 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) +} + +/** + * 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 new file mode 100644 index 00000000..39a71ce8 --- /dev/null +++ b/apps/desktop/src/renderer/src/features/appearance/useCustomBackground.ts @@ -0,0 +1,162 @@ +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' +import { rpc } from '../../lib/rpc' +import { + CLARITY_VAR_NAMES, + clampRotationIndex, + clarityVars, + nextRotationIndex, + retainedPaths, + 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 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 { + 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) + // 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 +} + +/** 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) + } +} + +/** + * 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) + + 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 + // 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(() => { + 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(() => { + 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') { + // 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. + 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}")`) + }) + // 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, rotating]) +} 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..262cbbde 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,182 @@ 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('32%') + }) + await waitFor( + () => + expect(mocks.update).toHaveBeenCalledWith({ appearance: { wallpaperClarity: 1 } }), + { 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 6235925c..c8fe9ef6 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,61 @@ export function AppearanceSettings() { }) } + const handleImagesChange = (next: string[]): void => { + // 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) + /** 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. + 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 + pendingClarity.current = null + 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 +456,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..0248abc6 100644 --- a/packages/ui/src/wallpaper-css.test.ts +++ b/packages/ui/src/wallpaper-css.test.ts @@ -14,7 +14,62 @@ 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('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(/--ari-wallpaper-halo-color:[^;]*var\(--ari-wallpaper-halo, 0%\)/) + // Theme background, not a fixed dark: light themes need the opposite. + expect(css).toMatch(/--ari-wallpaper-halo-color:[^;]*var\(--ari-bg\)/) + expect(css).toMatch(/text-shadow:[^;]*var\(--ari-wallpaper-halo-blur, 0px\)/) + }) + + 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(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('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', () => { diff --git a/packages/ui/src/wallpaper.css b/packages/ui/src/wallpaper.css index 33507420..3bb1e857 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 custom-background.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,30 @@ /* 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); + /* 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. + + 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: + 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 @@ -78,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) { @@ -89,5 +150,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; } } 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) } -