diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3367e20fe..58037d73ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,6 +163,15 @@ jobs: if: steps.plan.outputs.code == 'true' run: npm run typecheck + # Two drift contracts over the shipped app-icon artwork, sitting beside + # the theme drift check for the same reason: the committed bytes are a + # build output that nothing else re-derives, so without this a change to + # the generator, to DEFAULT_APP_ICON, or to the packaging config can go + # green while the artwork it names no longer matches. + - name: App icon artwork drift + if: steps.plan.outputs.code == 'true' + run: node --test scripts/verify-packaged-app-icons.test.mjs scripts/generate-app-icons.test.mjs + - name: Astryx theme drift if: steps.plan.outputs.code == 'true' run: npm run astryx:theme -- --check diff --git a/apps/desktop/assets/app-icons/amber-crt.png b/apps/desktop/assets/app-icons/amber-crt.png new file mode 100644 index 0000000000..fc761be52a Binary files /dev/null and b/apps/desktop/assets/app-icons/amber-crt.png differ diff --git a/apps/desktop/assets/app-icons/amber.png b/apps/desktop/assets/app-icons/amber.png new file mode 100644 index 0000000000..35eeb088ef Binary files /dev/null and b/apps/desktop/assets/app-icons/amber.png differ diff --git a/apps/desktop/assets/app-icons/carbon.png b/apps/desktop/assets/app-icons/carbon.png new file mode 100644 index 0000000000..118a8f031e Binary files /dev/null and b/apps/desktop/assets/app-icons/carbon.png differ diff --git a/apps/desktop/assets/app-icons/chrome.png b/apps/desktop/assets/app-icons/chrome.png new file mode 100644 index 0000000000..bf7cef4e58 Binary files /dev/null and b/apps/desktop/assets/app-icons/chrome.png differ diff --git a/apps/desktop/assets/app-icons/clay.png b/apps/desktop/assets/app-icons/clay.png new file mode 100644 index 0000000000..fb47d8248e Binary files /dev/null and b/apps/desktop/assets/app-icons/clay.png differ diff --git a/apps/desktop/assets/app-icons/desert.png b/apps/desktop/assets/app-icons/desert.png new file mode 100644 index 0000000000..e8b99b49cf Binary files /dev/null and b/apps/desktop/assets/app-icons/desert.png differ diff --git a/apps/desktop/assets/app-icons/dust.png b/apps/desktop/assets/app-icons/dust.png new file mode 100644 index 0000000000..4d7f1359fe Binary files /dev/null and b/apps/desktop/assets/app-icons/dust.png differ diff --git a/apps/desktop/assets/app-icons/fog.png b/apps/desktop/assets/app-icons/fog.png new file mode 100644 index 0000000000..1b08085679 Binary files /dev/null and b/apps/desktop/assets/app-icons/fog.png differ diff --git a/apps/desktop/assets/app-icons/glacier.png b/apps/desktop/assets/app-icons/glacier.png new file mode 100644 index 0000000000..b8cc3cf972 Binary files /dev/null and b/apps/desktop/assets/app-icons/glacier.png differ diff --git a/apps/desktop/assets/app-icons/gold.png b/apps/desktop/assets/app-icons/gold.png new file mode 100644 index 0000000000..23fdf4cd33 Binary files /dev/null and b/apps/desktop/assets/app-icons/gold.png differ diff --git a/apps/desktop/assets/app-icons/hazard.png b/apps/desktop/assets/app-icons/hazard.png new file mode 100644 index 0000000000..2945dec384 Binary files /dev/null and b/apps/desktop/assets/app-icons/hazard.png differ diff --git a/apps/desktop/assets/app-icons/magenta.png b/apps/desktop/assets/app-icons/magenta.png new file mode 100644 index 0000000000..99e9896af6 Binary files /dev/null and b/apps/desktop/assets/app-icons/magenta.png differ diff --git a/apps/desktop/assets/app-icons/matrix.png b/apps/desktop/assets/app-icons/matrix.png new file mode 100644 index 0000000000..26f4ea2e02 Binary files /dev/null and b/apps/desktop/assets/app-icons/matrix.png differ diff --git a/apps/desktop/assets/app-icons/midnight.png b/apps/desktop/assets/app-icons/midnight.png new file mode 100644 index 0000000000..d4cdafa62b Binary files /dev/null and b/apps/desktop/assets/app-icons/midnight.png differ diff --git a/apps/desktop/assets/app-icons/mono-black.png b/apps/desktop/assets/app-icons/mono-black.png new file mode 100644 index 0000000000..f42bf4ca67 Binary files /dev/null and b/apps/desktop/assets/app-icons/mono-black.png differ diff --git a/apps/desktop/assets/app-icons/mono-white.png b/apps/desktop/assets/app-icons/mono-white.png new file mode 100644 index 0000000000..bc572d679d Binary files /dev/null and b/apps/desktop/assets/app-icons/mono-white.png differ diff --git a/apps/desktop/assets/app-icons/moss.png b/apps/desktop/assets/app-icons/moss.png new file mode 100644 index 0000000000..897c9159dd Binary files /dev/null and b/apps/desktop/assets/app-icons/moss.png differ diff --git a/apps/desktop/assets/app-icons/neon-cyan.png b/apps/desktop/assets/app-icons/neon-cyan.png new file mode 100644 index 0000000000..e847e89a7a Binary files /dev/null and b/apps/desktop/assets/app-icons/neon-cyan.png differ diff --git a/apps/desktop/assets/app-icons/obsidian.png b/apps/desktop/assets/app-icons/obsidian.png new file mode 100644 index 0000000000..c087c465bb Binary files /dev/null and b/apps/desktop/assets/app-icons/obsidian.png differ diff --git a/apps/desktop/assets/app-icons/ocean.png b/apps/desktop/assets/app-icons/ocean.png new file mode 100644 index 0000000000..61775aa3f1 Binary files /dev/null and b/apps/desktop/assets/app-icons/ocean.png differ diff --git a/apps/desktop/assets/app-icons/sage.png b/apps/desktop/assets/app-icons/sage.png new file mode 100644 index 0000000000..b0c453d8f8 Binary files /dev/null and b/apps/desktop/assets/app-icons/sage.png differ diff --git a/apps/desktop/assets/app-icons/slate.png b/apps/desktop/assets/app-icons/slate.png new file mode 100644 index 0000000000..12ac8d73aa Binary files /dev/null and b/apps/desktop/assets/app-icons/slate.png differ diff --git a/apps/desktop/assets/app-icons/sunset.png b/apps/desktop/assets/app-icons/sunset.png new file mode 100644 index 0000000000..82ceb8811f Binary files /dev/null and b/apps/desktop/assets/app-icons/sunset.png differ diff --git a/apps/desktop/assets/app-icons/terracotta.png b/apps/desktop/assets/app-icons/terracotta.png new file mode 100644 index 0000000000..d9cd10f8e2 Binary files /dev/null and b/apps/desktop/assets/app-icons/terracotta.png differ diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index 9560169bd2..097effb352 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -174,7 +174,14 @@ export default { { target: 'zip', arch: ['arm64'] }, ], category: 'public.app-category.productivity', - icon: 'assets/icon.png', + // The bundle icon is what Finder, Launchpad and the installer show, and + // none of those run our code — so it cannot follow the user's choice and + // has to be the shipped default. `assets/icon.png` is the original mascot + // mark, which is still selectable as the `default` id but is no longer the + // default; pointing the bundle at it would leave every surface outside the + // running app on the old artwork. Kept in step with `DEFAULT_APP_ICON` by + // a test in scripts/verify-packaged-app-icons.test.mjs. + icon: 'assets/app-icons/sky.png', forceCodeSigning: true, hardenedRuntime: true, notarize: true, @@ -208,7 +215,9 @@ export default { { target: 'zip', arch: ['x64'] }, ], artifactName: 'Maka-${version}-win-${arch}.${ext}', - icon: 'assets/icon.png', + // Same reason as `mac.icon` above: the .exe, the installer and the + // shortcut are drawn by the OS from this file, not by us. + icon: 'assets/app-icons/sky.png', // No Authenticode certificate yet. Being unsigned is the absence of one: // electron-builder skips signing when no certificate is configured, and // `forceCodeSigning` is left off so that skip is not an error. Nothing here diff --git a/apps/desktop/src/main/__tests__/app-icon-ipc.test.ts b/apps/desktop/src/main/__tests__/app-icon-ipc.test.ts index e5f5bce2f6..fabe47bd62 100644 --- a/apps/desktop/src/main/__tests__/app-icon-ipc.test.ts +++ b/apps/desktop/src/main/__tests__/app-icon-ipc.test.ts @@ -23,6 +23,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import type { IpcMainInvokeEvent } from 'electron'; +import { DEFAULT_APP_ICON } from '@maka/core/settings'; import type { AppSettings, UpdateAppSettingsInput } from '@maka/core/settings'; import { registerAppIconIpc } from '../app-icon-ipc.js'; import { customAppIconDirectory, resolveCustomAppIconPath } from '../custom-app-icon-store.js'; @@ -36,13 +37,23 @@ async function harness(selected: string, options: { onCompareAndSet?: () => void; onApply?: () => void; onShowOpenDialog?: () => Promise; + /** Seeds `appearance.appIconDark`; absent means the split is off. */ + dark?: string; + /** Makes the conditional write throw, standing in for a disk failure. */ + failWrite?: boolean; } = {}) { const root = await mkdtemp(join(tmpdir(), 'maka-icon-ipc-')); await mkdir(customAppIconDirectory(root), { recursive: true }); await writeFile(resolveCustomAppIconPath(root, ID), 'x'); const handlers = new Map(); - let settings = { appearance: { theme: 'auto', appIcon: selected } } as unknown as AppSettings; + let settings = { + appearance: { + theme: 'auto', + appIcon: selected, + ...(options.dark === undefined ? {} : { appIconDark: options.dark }), + }, + } as unknown as AppSettings; const applied: AppSettings[] = []; registerAppIconIpc({ @@ -65,15 +76,22 @@ async function harness(selected: string, options: { }, updateIf: async ( predicate: (current: AppSettings) => boolean, - patch: UpdateAppSettingsInput, + patch: UpdateAppSettingsInput | ((current: AppSettings) => UpdateAppSettingsInput), ) => { // The real store evaluates the predicate and writes on one queue. The // hook stands in for whatever else reached that queue first. options.onCompareAndSet?.(); if (!predicate(settings)) return { applied: false, settings }; + // Thrown after the predicate, where the real store would fail: the + // decision to write has been made and the write is what breaks. + if (options.failWrite) throw new Error('disk is full'); + // Spread, like the real `mergeSettings`: an explicit `undefined` in a + // patch overwrites rather than being skipped, which is how a slot is + // cleared. + const resolved = typeof patch === 'function' ? patch(settings) : patch; settings = { ...settings, - appearance: { ...settings.appearance, ...patch.appearance }, + appearance: { ...settings.appearance, ...resolved.appearance }, } as AppSettings; return { applied: true, settings }; }, @@ -91,6 +109,7 @@ async function harness(selected: string, options: { remove: (icon: unknown) => handlers.get('app:removeIcon')!(undefined as unknown as IpcMainInvokeEvent, icon), current: () => settings.appearance.appIcon, + currentDark: () => settings.appearance.appIconDark, select: (icon: unknown) => handlers.get('app:selectIcon')!(undefined as unknown as IpcMainInvokeEvent, icon), importIcon: () => @@ -111,9 +130,10 @@ test('removing the current icon resets the selection before the file goes away', const result = (await h.remove(ICON)) as { ok: boolean; selection?: string }; assert.equal(result.ok, true); - assert.equal(result.selection, 'default'); + // The shipped default, which is no longer the id literally named `default`. + assert.equal(result.selection, DEFAULT_APP_ICON); // Both halves moved, and the setting is the half that moved first. - assert.equal(h.current(), 'default'); + assert.equal(h.current(), DEFAULT_APP_ICON); assert.deepEqual(await readdir(customAppIconDirectory(h.root)), []); // The OS surface was told, so the dock is not still holding the deleted art. assert.equal(h.applied.length, 1); @@ -194,9 +214,11 @@ test('a selection issued mid-removal cannot land between reset, apply and delete // It ran after the removal, not inside it, and the artwork was already gone. assert.equal(selected.ok, false); assert.equal(selected.reason, 'missing_artwork'); - assert.equal(h.current(), 'default'); + assert.equal(h.current(), DEFAULT_APP_ICON); assert.deepEqual(await readdir(customAppIconDirectory(h.root)), []); - // Nothing ran between the reset and the delete. + // Nothing ran between the reset and the delete, and both slots move in a + // single compare-and-set: a second conditional write here would be a window + // where the light slot is already committed and the dark one is not. assert.deepEqual(observed, ['compare-and-set', 'apply']); }); @@ -221,3 +243,60 @@ test('an open file dialog does not hold the queue', async () => { releaseDialog(); await importing; }); + +test('removing an icon used only in dark mode clears the dark slot', async () => { + // The dangling-reference case: the light slot names something else, so the + // light-slot predicate does not match and an earlier version of this handler + // deleted the file while leaving `appIconDark` pointing at it. + const h = await harness('sky', { dark: ICON }); + + const result = (await h.remove(ICON)) as { ok: boolean; darkSelection?: string }; + + assert.equal(result.ok, true); + assert.equal(h.current(), 'sky', 'the light choice is untouched'); + assert.equal(h.currentDark(), undefined, 'the dark slot no longer names deleted artwork'); + assert.equal(result.darkSelection, undefined); + assert.equal(h.applied.length, 1, 'the dock was re-applied for the cleared slot'); +}); + +test('removing an icon used in both slots clears both', async () => { + const h = await harness(ICON, { dark: ICON }); + + const result = (await h.remove(ICON)) as { ok: boolean; selection: string }; + + assert.equal(result.ok, true); + assert.equal(h.current(), 'sky'); + assert.equal(h.currentDark(), undefined); + assert.equal(result.selection, 'sky'); +}); + +test('removing an unrelated icon leaves a dark choice alone', async () => { + const other = `custom:${'d'.repeat(32)}`; + const h = await harness('sky', { dark: 'ink' }); + + await h.remove(other); + + assert.equal(h.current(), 'sky'); + assert.equal(h.currentDark(), 'ink', 'an unrelated removal must not disturb the split'); +}); + +test('a failed reset commits nothing and leaves the dock alone', async () => { + // The partial-commit case: both slots name the icon, so a two-step reset + // would have written the light slot before the second write could fail. + // One queued write means a failure leaves the persisted state untouched, + // which is what makes `reset_failed` an honest answer. + const h = await harness(ICON, { dark: ICON, failWrite: true }); + + const result = (await h.remove(ICON)) as { ok: boolean; reason?: string }; + + assert.equal(result.ok, false); + assert.equal(result.reason, 'reset_failed'); + assert.equal(h.current(), ICON, 'the light slot was not half-reset'); + assert.equal(h.currentDark(), ICON, 'the dark slot was not half-reset'); + assert.equal(h.applied.length, 0, 'nothing was applied to the dock'); + assert.deepEqual( + await readdir(customAppIconDirectory(h.root)), + [`${ID}.png`], + 'the artwork survives a failed reset', + ); +}); diff --git a/apps/desktop/src/main/__tests__/client-settings-effects.test.ts b/apps/desktop/src/main/__tests__/client-settings-effects.test.ts index 5864610ada..45389a22e7 100644 --- a/apps/desktop/src/main/__tests__/client-settings-effects.test.ts +++ b/apps/desktop/src/main/__tests__/client-settings-effects.test.ts @@ -39,6 +39,7 @@ test('applies each client settings snapshot once across local writes and file wa applyAppIcon: async (icon) => { appIcons.push(icon); }, + systemPrefersDark: () => false, observeLocale: () => undefined, emitExternalChanged: () => { rendererEvents += 1; @@ -73,6 +74,7 @@ test('applies a chosen app icon once, and again only when the choice changes', a applyAppIcon: async (icon) => { appIcons.push(icon); }, + systemPrefersDark: () => false, observeLocale: () => undefined, emitExternalChanged: () => undefined, }); @@ -88,3 +90,91 @@ test('applies a chosen app icon once, and again only when the choice changes', a assert.deepEqual(appIcons, ['mono', 'default']); }); + + +test('an OS appearance flip re-applies the icon without any setting changing', async () => { + // The whole reason `refresh` is wired to nativeTheme: nothing in the + // settings object moves when the OS flips, so the fingerprint comparison + // that guards every other effect would report "no change" and the dock + // would keep the light tile. + let systemDark = false; + const current = createDefaultSettings(); + current.appearance.theme = 'auto'; + current.appearance.appIcon = 'sky'; + current.appearance.appIconDark = 'midnight'; + const applied: string[] = []; + const effects = createClientSettingsEffects({ + settingsStore: { get: async () => current }, + applyKeepSystemAwake: async () => undefined, + applyBotSettings: async () => undefined, + applyAppIcon: async (icon) => { + applied.push(icon); + }, + systemPrefersDark: () => systemDark, + observeLocale: () => undefined, + emitExternalChanged: () => undefined, + }); + + await effects.refresh(false); + assert.deepEqual(applied, [], 'the light tile is already up from startup'); + + systemDark = true; + assert.equal(await effects.refresh(false), true); + assert.deepEqual(applied, ['midnight']); + + // Idempotent: a second notification for the same appearance must not cost + // another 1024px decode. + assert.equal(await effects.refresh(false), false); + assert.deepEqual(applied, ['midnight']); + + systemDark = false; + await effects.refresh(false); + assert.deepEqual(applied, ['midnight', 'sky']); +}); + +test('with one icon for both appearances a theme flip changes nothing', async () => { + let systemDark = false; + const current = createDefaultSettings(); + current.appearance.theme = 'auto'; + current.appearance.appIcon = 'forest'; + delete current.appearance.appIconDark; + const applied: string[] = []; + const effects = createClientSettingsEffects({ + settingsStore: { get: async () => current }, + applyKeepSystemAwake: async () => undefined, + applyBotSettings: async () => undefined, + applyAppIcon: async (icon) => { + applied.push(icon); + }, + systemPrefersDark: () => systemDark, + observeLocale: () => undefined, + emitExternalChanged: () => undefined, + }); + + await effects.refresh(false); + assert.deepEqual(applied, ['forest']); + systemDark = true; + assert.equal(await effects.refresh(false), false); + assert.deepEqual(applied, ['forest'], 'no second tile was ever chosen'); +}); + +test('an explicit dark preference ignores what the OS reports', async () => { + const current = createDefaultSettings(); + current.appearance.theme = 'dark'; + current.appearance.appIcon = 'sky'; + current.appearance.appIconDark = 'ink'; + const applied: string[] = []; + const effects = createClientSettingsEffects({ + settingsStore: { get: async () => current }, + applyKeepSystemAwake: async () => undefined, + applyBotSettings: async () => undefined, + applyAppIcon: async (icon) => { + applied.push(icon); + }, + systemPrefersDark: () => false, + observeLocale: () => undefined, + emitExternalChanged: () => undefined, + }); + await effects.refresh(false); + assert.deepEqual(applied, ['ink']); +}); diff --git a/apps/desktop/src/main/__tests__/settings-ownership-app-icon.test.ts b/apps/desktop/src/main/__tests__/settings-ownership-app-icon.test.ts index 5b451ac7bd..49f4a1650e 100644 --- a/apps/desktop/src/main/__tests__/settings-ownership-app-icon.test.ts +++ b/apps/desktop/src/main/__tests__/settings-ownership-app-icon.test.ts @@ -47,3 +47,20 @@ test('the rest of appearance still travels', () => { const patch = clientOwnedSettingsPatch({ appearance: { theme: 'light', palette: 'nord' } }); assert.deepEqual(patch.appearance, { theme: 'light', palette: 'nord' }); }); + +test('the dark slot cannot travel on the generic channel either', () => { + // It names artwork on exactly the same terms as `appIcon`, so leaving it + // unfiltered reopens the removal/write race through the other slot. + const patch = clientOwnedSettingsPatch({ + appearance: { theme: 'dark', appIconDark: `custom:${'b'.repeat(32)}` }, + }); + + assert.deepEqual(patch.appearance, { theme: 'dark' }); +}); + +test('an appearance patch of only icon slots drops out entirely', () => { + assert.equal( + clientOwnedSettingsPatch({ appearance: { appIcon: 'sky', appIconDark: 'ink' } }).appearance, + undefined, + ); +}); diff --git a/apps/desktop/src/main/app-icon-ipc.ts b/apps/desktop/src/main/app-icon-ipc.ts index 08a105f3f5..8187ee9455 100644 --- a/apps/desktop/src/main/app-icon-ipc.ts +++ b/apps/desktop/src/main/app-icon-ipc.ts @@ -29,6 +29,9 @@ import { toAppIconChoice, type AppIconChoice, type AppSettings, + isAppIconTarget, + DEFAULT_APP_ICON, + type AppIconTarget, } from "@maka/core/settings"; import type { SettingsStore } from "@maka/storage/settings-store"; import type { AppIconPreview } from "./app-icon-surface.js"; @@ -44,14 +47,24 @@ export type AppIconImportResult = | { readonly ok: false; readonly reason: CustomAppIconImportReason }; export type AppIconSelectResult = - | { readonly ok: true; readonly selection: AppIconChoice } + | { + readonly ok: true; + readonly selection: AppIconChoice; + /** Absent when one icon serves both appearances. */ + readonly darkSelection?: AppIconChoice; + } | { readonly ok: false; readonly reason: "invalid_id" | "missing_artwork" | "write_failed"; }; export type AppIconRemoveResult = - | { readonly ok: true; readonly selection: AppIconChoice } + | { + readonly ok: true; + readonly selection: AppIconChoice; + /** Absent when one icon serves both appearances. */ + readonly darkSelection?: AppIconChoice; + } | { readonly ok: false; readonly reason: "invalid_id" | "reset_failed" | "remove_failed"; @@ -108,9 +121,16 @@ export function registerAppIconIpc(input: { * that can refuse a choice whose artwork is gone — the generic channel would * happily persist an id with nothing behind it. */ - input.ipcMain.handle("app:selectIcon", (_event, icon: unknown) => + input.ipcMain.handle("app:selectIcon", (_event, icon: unknown, target: unknown) => serialize(async (): Promise => { if (!isAppIconChoice(icon)) return { ok: false, reason: "invalid_id" }; + // An absent target is the pre-split call shape, which meant "the icon, + // everywhere". Anything else unrecognized is rejected rather than + // guessed: this writes to settings, and guessing would silently put the + // id in a slot the caller did not ask for. + const requested: unknown = target === undefined ? "both" : target; + if (!isAppIconTarget(requested)) return { ok: false, reason: "invalid_id" }; + const slot: AppIconTarget = requested; const custom = customAppIconId(icon); if (custom !== undefined) { const present = await listCustomAppIconIds(userDataPath()); @@ -119,12 +139,22 @@ export function registerAppIconIpc(input: { } try { const settings = await input.settingsStore.update({ - appearance: { appIcon: icon }, + appearance: + slot === "dark" + ? { appIconDark: icon } + : // `undefined` is a real value through mergeSettings' spread, + // so this clears the dark slot rather than leaving it behind + // to override every future light-only change. + slot === "both" + ? { appIcon: icon, appIconDark: undefined } + : { appIcon: icon }, }); await input.applySettings(settings); + const dark = settings.appearance.appIconDark; return { ok: true, selection: toAppIconChoice(settings.appearance.appIcon), + ...(dark === undefined ? {} : { darkSelection: toAppIconChoice(dark) }), }; } catch { return { ok: false, reason: "write_failed" }; @@ -193,14 +223,37 @@ export function registerAppIconIpc(input: { // When the predicate no longer holds, the newer selection stands and the // file is still deleted: it is no longer the one in use, which is // exactly the state the caller asked for. + // Either slot, both, or neither may name the icon being removed, and + // all of it has to move in ONE queued write. Two conditional updates + // would let the second fail after the first committed: the light slot + // would already be on disk as the default while the dark slot still + // named deleted artwork, `applySettings` would never run, and the + // handler would report `reset_failed` over a state it had half changed. + // The derived patch resets exactly the slots that matched, atomically. let settings: AppSettings; + let applied = false; try { const outcome = await input.settingsStore.updateIf( - (current) => toAppIconChoice(current.appearance.appIcon) === icon, - { appearance: { appIcon: "default" } }, + (current) => + toAppIconChoice(current.appearance.appIcon) === icon || + current.appearance.appIconDark === icon, + (current) => ({ + appearance: { + ...(toAppIconChoice(current.appearance.appIcon) === icon + ? { appIcon: DEFAULT_APP_ICON } + : {}), + // Cleared rather than reset to the shipped dark id: the user + // removed the only dark icon they had chosen, and inheriting the + // light one is the state that needs no further decision. + ...(current.appearance.appIconDark === icon + ? { appIconDark: undefined } + : {}), + }, + }), ); settings = outcome.settings; - if (outcome.applied) await input.applySettings(settings); + applied = outcome.applied; + if (applied) await input.applySettings(settings); } catch { return { ok: false, reason: "reset_failed" }; } @@ -210,9 +263,11 @@ export function registerAppIconIpc(input: { } catch { return { ok: false, reason: "remove_failed" }; } + const darkAfter = settings.appearance.appIconDark; return { ok: true, selection: toAppIconChoice(settings.appearance.appIcon), + ...(darkAfter === undefined ? {} : { darkSelection: toAppIconChoice(darkAfter) }), }; }), ); diff --git a/apps/desktop/src/main/client-settings-effects.ts b/apps/desktop/src/main/client-settings-effects.ts index 6a56ed0d1e..ea5c65e090 100644 --- a/apps/desktop/src/main/client-settings-effects.ts +++ b/apps/desktop/src/main/client-settings-effects.ts @@ -16,7 +16,13 @@ * specific language governing permissions and limitations * under the License. */ -import { toAppIconChoice, type AppIconChoice, type AppSettings } from '@maka/core/settings'; +import { + appIconForTheme, + startupAppIcon, + type AppIconChoice, + type AppSettings, +} from '@maka/core/settings'; +import { isDarkAppearance } from './theme-source.js'; import type { SettingsStore } from '@maka/storage/settings-store'; export interface ClientSettingsEffects { @@ -29,6 +35,12 @@ interface ClientSettingsEffectDependencies { readonly applyKeepSystemAwake: (enabled: boolean) => Promise; readonly applyBotSettings: (settings: AppSettings['botChat']) => Promise; readonly applyAppIcon: (icon: AppIconChoice) => Promise; + /** + * What the OS currently reports, read fresh on every pass. Injected + * rather than imported so this module stays free of electron and keeps + * its unit tests runnable outside a desktop session. + */ + readonly systemPrefersDark: () => boolean; readonly observeLocale: (settings: AppSettings) => void; readonly emitExternalChanged: () => void; } @@ -39,11 +51,11 @@ export function createClientSettingsEffects( let rendererFingerprint: string | undefined; let botFingerprint: string | undefined; let keepSystemAwake: boolean | undefined; - // Seeded rather than left undefined: the shipped default is already on - // screen before the first snapshot arrives — the dock gets it synchronously - // at startup and a new window gets it from its `icon` option — so treating - // "default" as unapplied would cost a 1024px PNG decode on every launch. - let appIcon: AppIconChoice = 'default'; + // Seeded rather than left undefined: the startup path has already put an + // icon on the dock synchronously, so treating it as unapplied would cost a + // 1024px PNG decode on every launch. Seeded lazily because the value depends + // on the OS appearance, which is read fresh rather than captured here. + let appIcon: AppIconChoice | undefined; let tail = Promise.resolve(); const schedule = ( @@ -62,7 +74,15 @@ export function createClientSettingsEffects( // partial patch rather than from a store read. // Same reason as `appIconPath`: this snapshot did not come through // `normalizeSettings`, so the value is untrusted until coerced. - const nextAppIcon = toAppIconChoice(settings.appearance.appIcon); + // Resolved against the current appearance, so this also re-runs when the + // OS flips light/dark under an `auto` preference — the settings object + // is unchanged then, and the icon is the only thing that moves. + const systemPrefersDark = dependencies.systemPrefersDark(); + appIcon ??= startupAppIcon(systemPrefersDark); + const nextAppIcon = appIconForTheme( + settings.appearance, + isDarkAppearance(settings.appearance.theme, systemPrefersDark), + ); const appIconChanged = nextAppIcon !== appIcon; dependencies.observeLocale(settings); if (keepAwakeChanged) { diff --git a/apps/desktop/src/main/desktop-shell-presentation.ts b/apps/desktop/src/main/desktop-shell-presentation.ts index 614160db51..2a6d96daa5 100644 --- a/apps/desktop/src/main/desktop-shell-presentation.ts +++ b/apps/desktop/src/main/desktop-shell-presentation.ts @@ -16,7 +16,8 @@ * specific language governing permissions and limitations * under the License. */ -import { app } from 'electron'; +import { app, nativeTheme } from 'electron'; +import { startupAppIcon } from '@maka/core/settings'; import { applyAppIcon } from './app-icon-surface.js'; import { installApplicationMenu } from './application-menu.js'; import { resolveDockPresentation } from './dock-presentation.js'; @@ -41,13 +42,18 @@ export function installDesktopShellPresentation( if (dockPresentation === 'hide') { app.dock.hide(); } else if (dockPresentation === 'icon') { - // The DEFAULT mark, synchronously, even when the user picked another - // one: reading the persisted choice means awaiting the settings store, - // and a dock that shows the generic Electron rocket until that resolves - // is the exact regression PR-GRAY-CARD-LIFT-0 fixed. The persisted - // choice lands a tick later, from the same client-settings effect that - // applies it when the user switches (see client-settings-effects.ts). - applyAppIcon('default', deps.onIconError); + // A DEFAULT, synchronously, even when the user picked another one: + // reading the persisted choice means awaiting the settings store, and a + // dock that shows the generic Electron rocket until that resolves is the + // exact regression PR-GRAY-CARD-LIFT-0 fixed. The persisted choice lands + // a tick later, from the same client-settings effect that applies it + // when the user switches (see client-settings-effects.ts). + // + // Which default depends on the appearance, and only the OS half of that + // is readable synchronously — the stored `theme` preference is not. So a + // user whose in-app theme disagrees with the OS still sees one swap, the + // same as before; what this avoids is every default install swapping. + applyAppIcon(startupAppIcon(nativeTheme.shouldUseDarkColors), deps.onIconError); } } diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts index fd21973268..4b2feea00d 100644 --- a/apps/desktop/src/main/main-window.ts +++ b/apps/desktop/src/main/main-window.ts @@ -21,7 +21,7 @@ import { app, BrowserWindow, dialog, nativeTheme, screen, shell } from 'electron import { mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; -import type { AppSettings } from '@maka/core/settings'; +import { appIconForTheme, type AppSettings } from '@maka/core/settings'; import { readableAppIconPath } from './app-icon-surface.js'; import { isExternalUrl } from './external-link-guard.js'; import { readSavedBounds, writeSavedBounds, SAFE_MIN_HEIGHT, SAFE_MIN_WIDTH, type SavedBounds } from './window-state.js'; @@ -30,7 +30,7 @@ import { BrowserViewManager } from './browser/view-manager.js'; import type { E2eFixture } from './e2e-fixture.js'; import { installMainWindowPermissionPolicy } from './main-window-permission-policy.js'; import { observeMainRendererProcessGone } from './main-renderer-process-gone.js'; -import { isThemePreference, toNativeThemeSource } from './theme-source.js'; +import { isDarkAppearance, isThemePreference, toNativeThemeSource } from './theme-source.js'; import { createWindowRevealGate } from './window-reveal.js'; import { createWindowsMaximizeRendererSync } from './windows-maximize-renderer-sync.js'; import { @@ -256,9 +256,7 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main // renderer to resources that teardown has already started closing. if (signal.aborted) return; const themePref = e2eFixture?.theme ?? persistedTheme; - const isDark = - themePref === 'dark' || - (themePref === 'auto' && nativeTheme.shouldUseDarkColors); + const isDark = isDarkAppearance(themePref, nativeTheme.shouldUseDarkColors); const initialBg = isDark ? '#1c1d21' : '#ffffff'; // Astro-Han review (#493): sync nativeTheme here too, not only via the // renderer's later setThemeSource() IPC call -- otherwise the vibrancy @@ -296,7 +294,13 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main // Windows and Linux draw this icon per window, so a window opened after // the user switched icons must be born with the chosen one — waiting for // the client-settings effect to catch up would show the default first. - icon: readableAppIconPath(persistedAppearance?.appIcon), + // Resolved for this window's appearance, not just the stored choice: + // Windows and Linux draw the icon per window, so a window opened while + // dark is in effect must be born with the dark tile — otherwise it keeps + // the light one until something else triggers a re-apply. + icon: readableAppIconPath( + appIconForTheme(persistedAppearance ?? {}, isDark), + ), // PR-WINDOW-TITLEBAR-0: hide the native title bar so the renderer // chrome can extend to the top edge on every platform. macOS keeps // `hiddenInset` + traffic-light buttons (top-left); Windows uses diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index bee614b763..b9a3771e17 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -22,6 +22,7 @@ import { clipboard, dialog, ipcMain, + nativeTheme, powerSaveBlocker, shell, type MessageBoxOptions, @@ -582,12 +583,23 @@ const clientSettingsEffects = createClientSettingsEffects({ console.error("[icon] failed to apply the app icon:", error), ); }, + systemPrefersDark: () => nativeTheme.shouldUseDarkColors, observeLocale: (settings) => desktopLocale.observe(settings), emitExternalChanged: () => { mainWindowController.send("settings:clientChanged"); sendActiveRuntimeHostEvent("settings:externalChanged", { ts: Date.now() }); }, }); +// An OS appearance flip changes no setting, so nothing else would notice it. +// Only the icon depends on the answer, and `refresh` re-resolves it and +// no-ops when the resolved tile is the one already applied — which is the +// case for every user who has not set a separate dark icon. +nativeTheme.on("updated", () => { + void clientSettingsEffects.refresh(false).catch((error) => { + console.error("[icon] failed to re-apply the app icon after a theme change:", error); + }); +}); + const clientSettingsTools = buildClientSettingsTools({ read: () => settingsStore.get(), update: async (patch) => { diff --git a/apps/desktop/src/main/theme-source.ts b/apps/desktop/src/main/theme-source.ts index 296f43f624..b636e297fe 100644 --- a/apps/desktop/src/main/theme-source.ts +++ b/apps/desktop/src/main/theme-source.ts @@ -41,3 +41,20 @@ export function isThemePreference(value: unknown): value is ThemePreference { export function toNativeThemeSource(pref: ThemePreference): NativeThemeSource { return pref === 'auto' ? 'system' : pref; } + +/** + * Whether the app is currently showing dark appearance. + * + * The stored preference alone cannot answer this: `auto` defers to the OS, so + * the caller has to supply what the OS currently says. Split out as a pure + * function because three places need the same answer — the window background, + * the window `icon` option, and the dock tile — and a disagreement between + * them is visible as a flash of the wrong theme on the first frame. + */ +export function isDarkAppearance( + pref: ThemePreference | undefined, + systemPrefersDark: boolean, +): boolean { + const resolved = isThemePreference(pref) ? pref : 'auto'; + return resolved === 'dark' || (resolved === 'auto' && systemPrefersDark); +} diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index c4bb909aa1..d214b325c4 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -29,6 +29,7 @@ import type { import type { AppIcon, AppIconChoice, + AppIconTarget, AppSettings, ChatDefaultsSettings, SettingsTestResult, @@ -128,11 +129,21 @@ import type { DesktopSessionSummary } from '../shared/desktop-session-projection * picker can say which rather than showing one generic failure. */ export type AppIconSelectResult = - | { readonly ok: true; readonly selection: AppIconChoice } + | { + readonly ok: true; + readonly selection: AppIconChoice; + /** Absent when one icon serves both appearances. */ + readonly darkSelection?: AppIconChoice; + } | { readonly ok: false; readonly reason: 'invalid_id' | 'missing_artwork' | 'write_failed' }; export type AppIconRemoveResult = - | { readonly ok: true; readonly selection: AppIconChoice } + | { + readonly ok: true; + readonly selection: AppIconChoice; + /** Absent when one icon serves both appearances. */ + readonly darkSelection?: AppIconChoice; + } | { readonly ok: false; readonly reason: 'invalid_id' | 'reset_failed' | 'remove_failed' }; export type AppIconImportResult = @@ -1397,7 +1408,7 @@ export interface MakaBridge { * generic settings channel so it queues behind import and removal in the * main process, and so a choice whose artwork is gone can be refused. */ - selectIcon(icon: AppIconChoice): Promise; + selectIcon(icon: AppIconChoice, target?: AppIconTarget): Promise; /** Opens a file picker in the main process and stores a normalized copy. */ importIcon(): Promise; /** diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 2154ea42b1..3cacc26ff3 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -102,6 +102,7 @@ import type { import type { AppIcon, AppIconChoice, + AppIconTarget, AppSettings, SettingsTestResult, UpdateAppSettingsInput, @@ -3006,8 +3007,8 @@ const makaBridge = { iconPreviews(): Promise> { return ipcRenderer.invoke('app:iconPreviews'); }, - selectIcon(icon: AppIconChoice): Promise { - return ipcRenderer.invoke('app:selectIcon', icon); + selectIcon(icon: AppIconChoice, target?: AppIconTarget): Promise { + return ipcRenderer.invoke('app:selectIcon', icon, target); }, importIcon(): Promise { return ipcRenderer.invoke('app:importIcon'); diff --git a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts index f3f716a473..86399bcba6 100644 --- a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts @@ -74,7 +74,25 @@ export type SettingsPreferencesCopy = { paletteGroups: { editor: string; product: string }; appIconLabels: Record; appIconHelp: Record; - appIconGroups: Record<'mascot' | 'blue' | 'contrast' | 'pencil' | 'mountain' | 'custom', string>; + appIconGroups: Record< + | 'mascot' + | 'blue' + | 'contrast' + | 'pencil' + | 'mountain' + | 'dark' + | 'neon' + | 'muted' + | 'warm' + | 'nature' + | 'metal' + | 'highContrast' + | 'custom', + string + >; + appIconSplitLabel: string; + appIconSplitHelp: string; + appIconTargets: Record<'light' | 'dark', string>; appIconCustom: string; appIconCustomHelp: string; appIconImport: string; @@ -261,12 +279,16 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = { paletteLabels: { default: '默认', onedark: 'One Dark', 'catppuccin-mocha': 'Catppuccin Mocha', 'tokyo-night': 'Tokyo Night', nord: 'Nord', coral: '珊瑚', azure: '湖蓝', forest: '森林', dusk: '暮光', sand: '沙金', mono: '极简灰' }, paletteHelp: { default: 'Maka 品牌蓝强调色', onedark: '编辑器经典深色', 'catppuccin-mocha': '紫调柔和深色', 'tokyo-night': '深蓝主题', nord: '北欧冷色', coral: '暖粉 / 珊瑚强调色', azure: '湖蓝强调色,干净冷静', forest: '深苔绿与暖蜂蜜强调色', dusk: '深紫罗兰与冷调画布', sand: '琥珀沙金与暖奶白', mono: '纯灰阶,无彩色干扰' }, paletteGroups: { editor: '编辑器主题', product: '产品色调' }, - appIconLabels: { default: '经典', mono: '单色', 'sky': '原色天蓝', 'cyan': '青蓝', 'ice': '冰蓝渐变', 'pale-inverted': '淡底深标', 'ink': '墨黑', 'paper': '纸白', 'graphite': '石墨', 'pencil-kraft': '铅笔・牛皮纸', 'pencil-sky': '铅笔・天蓝', 'pencil-navy': '铅笔・深蓝', 'alpine': '晴空雪山', 'dusk': '黄昏', 'night': '夜山', 'forest': '苍绿' }, - appIconHelp: { default: 'Maka 默认品牌图标', mono: '灰阶版本,Dock 里更安静', 'sky': '几何 M 标,品牌蓝', 'cyan': '偏青的蓝', 'ice': '由浅到深的蓝色渐变', 'pale-inverted': '淡蓝底配深蓝标', 'ink': '黑底白标,对比最强', 'paper': '白底黑标', 'graphite': '白底黑标,笔尖为灰', 'pencil-kraft': '铅笔意象,牛皮纸底', 'pencil-sky': '铅笔意象,天蓝底', 'pencil-navy': '铅笔意象,深蓝底', 'alpine': '雪顶山峰,晴空底', 'dusk': '雪顶山峰,黄昏底', 'night': '雪顶山峰,夜色底', 'forest': '雪顶山峰,苍绿底' }, + appIconLabels: { default: '经典', mono: '单色', 'sky': '原色天蓝', 'cyan': '青蓝', 'ice': '冰蓝渐变', 'pale-inverted': '淡底深标', 'ink': '墨黑', 'paper': '纸白', 'graphite': '石墨', 'pencil-kraft': '铅笔・牛皮纸', 'pencil-sky': '铅笔・天蓝', 'pencil-navy': '铅笔・深蓝', 'alpine': '晴空雪山', 'dusk': '黄昏', 'night': '夜山', 'midnight': '午夜蓝', 'carbon': 'OLED 纯黑', 'slate': '石板', 'obsidian': '曜石', 'neon-cyan': '荧光青', 'matrix': '磷绿', 'magenta': '品红', 'amber-crt': '琥珀 CRT', 'clay': '陶土', 'sage': '鼠尾草', 'dust': '灰粉', 'fog': '雾蓝', 'sunset': '日落', 'amber': '琥珀', 'terracotta': '赤陶', 'ocean': '深海', 'moss': '苔原', 'desert': '沙漠', 'glacier': '冰川', 'gold': '鎏金', 'chrome': '铬', 'mono-black': '单色・黑', 'mono-white': '单色・白', 'hazard': '黑黄', 'forest': '苍绿' }, + appIconHelp: { default: 'Maka 默认品牌图标', mono: '灰阶版本,Dock 里更安静', 'sky': '几何 M 标,品牌蓝', 'cyan': '偏青的蓝', 'ice': '由浅到深的蓝色渐变', 'pale-inverted': '淡蓝底配深蓝标', 'ink': '黑底白标,对比最强', 'paper': '白底黑标', 'graphite': '白底黑标,笔尖为灰', 'pencil-kraft': '铅笔意象,牛皮纸底', 'pencil-sky': '铅笔意象,天蓝底', 'pencil-navy': '铅笔意象,深蓝底', 'alpine': '雪顶山峰,晴空底', 'dusk': '雪顶山峰,黄昏底', 'night': '雪顶山峰,夜色底', 'midnight': '深蓝底配亮蓝标,深色 Dock 里仍有轮廓', 'carbon': '纯黑底,OLED 屏上只剩标本身', 'slate': '冷灰底配浅灰标', 'obsidian': '紫黑渐变底配淡紫标', 'neon-cyan': '近黑底配荧光青', 'matrix': '终端显示器的磷光绿', 'magenta': '深紫底配品红', 'amber-crt': '早期终端的琥珀色', 'clay': '低饱和的陶土色', 'sage': '低饱和的灰绿', 'dust': '低饱和的灰粉', 'fog': '低饱和的灰蓝', 'sunset': '橙到粉的斜向渐变', 'amber': '琥珀底配深褐标', 'terracotta': '砖红渐变', 'ocean': '深青绿渐变', 'moss': '深苔绿渐变', 'desert': '沙色渐变配深褐标', 'glacier': '极浅的冰蓝渐变', 'gold': '标本身带金色渐变', 'chrome': '标本身带银色渐变', 'mono-black': '纯白底黑标,可单色打印', 'mono-white': '纯黑底白标', 'hazard': '黑底黄标,这组里对比最高', 'forest': '雪顶山峰,苍绿底' }, appIconGroups: { mascot: '拟人', blue: '蓝色系', contrast: '黑白', pencil: '铅笔', mountain: '高山', + dark: '深色', neon: '霓虹', muted: '莫兰迪', warm: '暖色', nature: '自然', metal: '金属', highContrast: '高对比', custom: '自定义', }, + appIconSplitLabel: '浅色和深色用不同图标', + appIconSplitHelp: '关闭时两种外观共用一个图标。', + appIconTargets: { light: '浅色', dark: '深色' }, appIconCustom: '导入的图标', appIconCustomHelp: '你自己导入的图片', appIconImport: '导入图标…', @@ -337,7 +359,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = { pets: 'Custom pets', petsHelp: 'Manage PetPacks you import yourself. Maka does not bundle or enable any pet by default.', }, appearance: { - saveFailed: 'Could not save appearance settings', theme: 'Theme', palette: 'Color palette', themeOptions: { light: { label: 'Light', help: 'Always use the light interface.' }, dark: { label: 'Dark', help: 'Always use the dark interface.' }, auto: { label: 'Follow system', help: 'Match the current system appearance.' } }, paletteLabels: { default: 'Default', onedark: 'One Dark', 'catppuccin-mocha': 'Catppuccin Mocha', 'tokyo-night': 'Tokyo Night', nord: 'Nord', coral: 'Coral', azure: 'Azure', forest: 'Forest', dusk: 'Dusk', sand: 'Sand', mono: 'Monochrome' }, paletteHelp: { default: 'Maka brand-blue accent', onedark: 'Classic dark editor theme', 'catppuccin-mocha': 'Soft purple dark theme', 'tokyo-night': 'Deep-blue editor theme', nord: 'Cool Nordic colors', coral: 'Warm pink and coral accent', azure: 'Clean, calm blue accent', forest: 'Deep moss and warm honey', dusk: 'Deep violet on a cool canvas', sand: 'Amber sand and warm ivory', mono: 'Pure grayscale without color distraction' }, paletteGroups: { editor: 'Editor themes', product: 'Product colors' }, appIconLabels: { default: 'Classic', mono: 'Monochrome', 'sky': 'Sky', 'cyan': 'Cyan', 'ice': 'Ice', 'pale-inverted': 'Inverted', 'ink': 'Ink', 'paper': 'Paper', 'graphite': 'Graphite', 'pencil-kraft': 'Pencil, kraft', 'pencil-sky': 'Pencil, sky', 'pencil-navy': 'Pencil, navy', 'alpine': 'Alpine', 'dusk': 'Dusk', 'night': 'Night', 'forest': 'Forest' }, appIconHelp: { default: 'The default Maka mark', mono: 'Grayscale, for a quieter dock', 'sky': 'The geometric M mark in brand blue', 'cyan': 'Blue leaning to cyan', 'ice': 'A pale-to-deep blue gradient', 'pale-inverted': 'A deep blue mark on a pale field', 'ink': 'White on black, the highest contrast', 'paper': 'Black on white', 'graphite': 'Black on white with a grey tip', 'pencil-kraft': 'The pencil reading, on kraft paper', 'pencil-sky': 'The pencil reading, on sky blue', 'pencil-navy': 'The pencil reading, on deep navy', 'alpine': 'A snow-capped peak under clear sky', 'dusk': 'A snow-capped peak at dusk', 'night': 'A snow-capped peak at night', 'forest': 'A snow-capped peak in green' }, appIconGroups: { mascot: 'Mascot', blue: 'Blues', contrast: 'Black & white', pencil: 'Pencil', mountain: 'Mountain', custom: 'Imported' }, appIconCustom: 'Imported icon', appIconCustomHelp: 'An image you imported', appIconImport: 'Import icon…', appIconImporting: 'Importing…', appIconImportHelp: 'A square PNG works best. Leave about 10% transparent margin so it sits the same size as other apps in the dock.', appIconRemove: 'Remove', appIconImportError: 'Could not import the icon', appIconRemoveFailed: 'Could not remove the icon', appIconSelectFailed: 'Could not switch the icon', appIconImportFailed: { too_large: 'That file is too large; pick a smaller image', too_many_pixels: 'That image is too large; 4096×4096 is the maximum', unsupported_format: 'Only PNG and JPEG are supported', unreadable: 'No image could be read from that file', too_small: 'That image is too small; 128×128 is the minimum', write_failed: 'Could not store the imported icon' }, appIconUnavailable: 'Could not load the app icons', + saveFailed: 'Could not save appearance settings', theme: 'Theme', palette: 'Color palette', themeOptions: { light: { label: 'Light', help: 'Always use the light interface.' }, dark: { label: 'Dark', help: 'Always use the dark interface.' }, auto: { label: 'Follow system', help: 'Match the current system appearance.' } }, paletteLabels: { default: 'Default', onedark: 'One Dark', 'catppuccin-mocha': 'Catppuccin Mocha', 'tokyo-night': 'Tokyo Night', nord: 'Nord', coral: 'Coral', azure: 'Azure', forest: 'Forest', dusk: 'Dusk', sand: 'Sand', mono: 'Monochrome' }, paletteHelp: { default: 'Maka brand-blue accent', onedark: 'Classic dark editor theme', 'catppuccin-mocha': 'Soft purple dark theme', 'tokyo-night': 'Deep-blue editor theme', nord: 'Cool Nordic colors', coral: 'Warm pink and coral accent', azure: 'Clean, calm blue accent', forest: 'Deep moss and warm honey', dusk: 'Deep violet on a cool canvas', sand: 'Amber sand and warm ivory', mono: 'Pure grayscale without color distraction' }, paletteGroups: { editor: 'Editor themes', product: 'Product colors' }, appIconLabels: { default: 'Classic', mono: 'Monochrome', 'sky': 'Sky', 'cyan': 'Cyan', 'ice': 'Ice', 'pale-inverted': 'Inverted', 'ink': 'Ink', 'paper': 'Paper', 'graphite': 'Graphite', 'pencil-kraft': 'Pencil, kraft', 'pencil-sky': 'Pencil, sky', 'pencil-navy': 'Pencil, navy', 'alpine': 'Alpine', 'dusk': 'Dusk', 'night': 'Night', 'midnight': 'Midnight', 'carbon': 'Carbon', 'slate': 'Slate', 'obsidian': 'Obsidian', 'neon-cyan': 'Neon cyan', 'matrix': 'Phosphor', 'magenta': 'Magenta', 'amber-crt': 'Amber CRT', 'clay': 'Clay', 'sage': 'Sage', 'dust': 'Dust', 'fog': 'Fog', 'sunset': 'Sunset', 'amber': 'Amber', 'terracotta': 'Terracotta', 'ocean': 'Ocean', 'moss': 'Moss', 'desert': 'Desert', 'glacier': 'Glacier', 'gold': 'Gold', 'chrome': 'Chrome', 'mono-black': 'Mono black', 'mono-white': 'Mono white', 'hazard': 'Hazard', 'forest': 'Forest' }, appIconHelp: { default: 'The default Maka mark', mono: 'Grayscale, for a quieter dock', 'sky': 'The geometric M mark in brand blue', 'cyan': 'Blue leaning to cyan', 'ice': 'A pale-to-deep blue gradient', 'pale-inverted': 'A deep blue mark on a pale field', 'ink': 'White on black, the highest contrast', 'paper': 'Black on white', 'graphite': 'Black on white with a grey tip', 'pencil-kraft': 'The pencil reading, on kraft paper', 'pencil-sky': 'The pencil reading, on sky blue', 'pencil-navy': 'The pencil reading, on deep navy', 'alpine': 'A snow-capped peak under clear sky', 'dusk': 'A snow-capped peak at dusk', 'night': 'A snow-capped peak at night', 'midnight': 'A bright mark on deep navy; keeps its edge on a dark dock', 'carbon': 'True black, so an OLED panel shows nothing but the mark', 'slate': 'Pale grey on cool slate', 'obsidian': 'Lilac on a violet-black gradient', 'neon-cyan': 'Electric cyan on near-black', 'matrix': 'The green of a phosphor terminal', 'magenta': 'Hot pink on deep violet', 'amber-crt': 'The amber of an early terminal', 'clay': 'Muted terracotta', 'sage': 'Muted grey-green', 'dust': 'Muted dusty rose', 'fog': 'Muted blue-grey', 'sunset': 'An orange-to-pink diagonal', 'amber': 'A dark mark on amber', 'terracotta': 'A brick-red gradient', 'ocean': 'A deep teal gradient', 'moss': 'A deep moss gradient', 'desert': 'A dark mark on desert sand', 'glacier': 'A pale glacial blue', 'gold': 'The mark itself carries a gold gradient', 'chrome': 'The mark itself carries a silver gradient', 'mono-black': 'Black on pure white; prints in one colour', 'mono-white': 'White on pure black', 'hazard': 'Yellow on black, the highest contrast in the set', 'forest': 'A snow-capped peak in green' }, appIconGroups: { mascot: 'Mascot', blue: 'Blues', contrast: 'Black & white', pencil: 'Pencil', mountain: 'Mountain', dark: 'Dark', neon: 'Neon', muted: 'Muted', warm: 'Warm', nature: 'Nature', metal: 'Metal', highContrast: 'High contrast', custom: 'Imported' }, appIconSplitLabel: 'Use a different icon in dark mode', appIconSplitHelp: 'When off, one icon is used in both appearances.', appIconTargets: { light: 'Light', dark: 'Dark' }, appIconCustom: 'Imported icon', appIconCustomHelp: 'An image you imported', appIconImport: 'Import icon…', appIconImporting: 'Importing…', appIconImportHelp: 'A square PNG works best. Leave about 10% transparent margin so it sits the same size as other apps in the dock.', appIconRemove: 'Remove', appIconImportError: 'Could not import the icon', appIconRemoveFailed: 'Could not remove the icon', appIconSelectFailed: 'Could not switch the icon', appIconImportFailed: { too_large: 'That file is too large; pick a smaller image', too_many_pixels: 'That image is too large; 4096×4096 is the maximum', unsupported_format: 'Only PNG and JPEG are supported', unreadable: 'No image could be read from that file', too_small: 'That image is too small; 128×128 is the minimum', write_failed: 'Could not store the imported icon' }, appIconUnavailable: 'Could not load the app icons', }, pets: { import: 'Import PetPack', importing: 'Importing…', loading: 'Loading custom pets…', diff --git a/apps/desktop/src/renderer/settings/appearance-settings-page.tsx b/apps/desktop/src/renderer/settings/appearance-settings-page.tsx index 2c0dc9777e..205d447c99 100644 --- a/apps/desktop/src/renderer/settings/appearance-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/appearance-settings-page.tsx @@ -22,12 +22,14 @@ import { SettingsPage, SettingsSection } from './settings-section'; import { isAppIcon, type AppIcon, + DEFAULT_APP_ICON_DARK, type AppIconChoice, + type AppIconTarget, type ThemePalette, type ThemePreference, type UpdateAppSettingsResult, } from '@maka/core/settings'; -import { useMountedRef, useToast, useUiLocale } from '@maka/ui'; +import { Switch, useMountedRef, useToast, useUiLocale } from '@maka/ui'; import { settingsActionErrorMessage } from './settings-error-copy'; import { getSettingsPreferencesCopy } from '../locales/settings-preferences-copy.js'; import { CustomPetSettingsSection } from './custom-pet-settings-section.js'; @@ -84,14 +86,29 @@ function ThemePreviewPane(props: { mode: 'light' | 'dark' }) { * keyboard navigation. */ /** - * 19 shipped icons need grouping for the same reason 11 palettes did: an + * 40 shipped icons need grouping for the same reason 11 palettes did: an * ungrouped wall gives the eye nowhere to start. The brand pair leads; * everything after it is one drawing recoloured, split by what the colour is * doing. Imported art is appended as its own group by the renderer, since the * set is not known until the main process reads the directory. + * + * The order matches `APP_ICONS`, which follows the order the icon discussion + * used — see the note there about why it is not one-to-one with its numbering. */ const APP_ICON_GROUPS: ReadonlyArray<{ - id: 'mascot' | 'blue' | 'contrast' | 'pencil' | 'mountain'; + id: + | 'mascot' + | 'blue' + | 'contrast' + | 'pencil' + | 'mountain' + | 'dark' + | 'neon' + | 'muted' + | 'warm' + | 'nature' + | 'metal' + | 'highContrast'; icons: ReadonlyArray; }> = [ { id: 'mascot', icons: ['default', 'mono'] }, @@ -99,6 +116,13 @@ const APP_ICON_GROUPS: ReadonlyArray<{ { id: 'contrast', icons: ['ink', 'paper', 'graphite'] }, { id: 'pencil', icons: ['pencil-kraft', 'pencil-sky', 'pencil-navy'] }, { id: 'mountain', icons: ['alpine', 'dusk', 'night', 'forest'] }, + { id: 'dark', icons: ['midnight', 'carbon', 'slate', 'obsidian'] }, + { id: 'neon', icons: ['neon-cyan', 'matrix', 'magenta', 'amber-crt'] }, + { id: 'muted', icons: ['clay', 'sage', 'dust', 'fog'] }, + { id: 'warm', icons: ['sunset', 'amber', 'terracotta'] }, + { id: 'nature', icons: ['ocean', 'moss', 'desert', 'glacier'] }, + { id: 'metal', icons: ['gold', 'chrome'] }, + { id: 'highContrast', icons: ['mono-black', 'mono-white', 'hazard'] }, ]; const PALETTE_GROUPS: ReadonlyArray<{ id: 'editor' | 'product'; palettes: ReadonlyArray }> = [ @@ -122,6 +146,8 @@ export function AppearanceSettingsPage(props: { themePref: ThemePreference; themePalette: ThemePalette; appIcon: AppIconChoice; + /** Absent when one icon serves both appearances. */ + appIconDark?: AppIconChoice; /* No `settings` prop: the page reads theme and palette from the two dedicated props above and writes through `onUpdate`. It used to accept the whole AppSettings object and pass it down one level, where nothing @@ -161,7 +187,10 @@ export function AppearanceSettingsPage(props: { return; } await refreshAppIcons(); - await setAppIcon(result.icon); + // Imported art lands in whichever slot the picker is editing, the same + // as clicking a tile — importing while on the dark slot and having it + // silently replace the light icon would be the surprising reading. + await setAppIcon(result.icon, appIconSplit ? appIconTarget : 'both'); } catch (error) { // Reasons above describe the *file*; landing here instead means the call // itself failed — a stale preload bundle with no `importIcon` on the @@ -242,19 +271,41 @@ export function AppearanceSettingsPage(props: { // the renderer, so a click has to show immediately. The app icon is an OS // surface applied by the main process, and the tile follows the settings // snapshot the write returns. - async function setAppIcon(next: AppIconChoice) { + // Which slot the grid is editing. Only meaningful while the two + // appearances are split; the toggle below owns that. + const [appIconTarget, setAppIconTarget] = useState<'light' | 'dark'>('light'); + const appIconSplit = props.appIconDark !== undefined; + const editedAppIcon = + appIconSplit && appIconTarget === 'dark' ? (props.appIconDark ?? props.appIcon) : props.appIcon; + + async function setAppIcon(next: AppIconChoice, target: AppIconTarget) { // Not `persistAppearance`: selection goes through the icon seam so it // queues behind import and removal in the main process. Writing it on the // generic settings channel is what let a selection land between a removal // resetting the setting and deleting the file. try { - const result = await window.maka.app.selectIcon(next); + const result = await window.maka.app.selectIcon(next, target); if (!result.ok) toast.error(copy.appIconSelectFailed); } catch (error) { toast.error(copy.appIconSelectFailed, settingsActionErrorMessage(error, locale)); } } + // Turning the split on seeds the dark slot with the shipped dark + // recommendation and moves the grid to it, so the user lands on a sensible + // dark tile already selected rather than on a copy of the light one they + // then have to change. Turning it off writes the light choice with `both`, + // which clears the slot. + async function setAppIconSplit(enabled: boolean) { + if (enabled) { + setAppIconTarget('dark'); + await setAppIcon(DEFAULT_APP_ICON_DARK, 'dark'); + } else { + setAppIconTarget('light'); + await setAppIcon(props.appIcon, 'both'); + } + } + // Group membership is a renderer concern: the main process reports what // artwork loaded, and the grouping is how the picker chooses to read it. // Anything the main process reports that no group claims — imported art — @@ -399,6 +450,33 @@ export function AppearanceSettingsPage(props: { {copy.appIconUnavailable} ) : ( + + void setAppIconSplit(enabled)} + /> + + {copy.appIconSplitHelp} + + + {appIconSplit ? ( + /* Which slot the grid below edits. Two buttons rather than a + second grid: 43 tiles twice over is a wall, and the choice + being made is the same one either way. */ + + {(['light', 'dark'] as const).map((target) => ( +