feat(theme): add global wallpaper with per-context override (#8643) (#8663)

Fixes the Today background leaking onto non-context pages (Planner,
Schedule, Boards, Config) and the startup flash reported in #8643.

The active work context stays "Today" (the reducer default) on pages
that aren't a tag/project, so its background was shown there wrongly.
Resolve the background per-route instead: per-context image -> global
wallpaper -> none, with overlay-opacity and blur following whichever
image is actually shown (never the sticky context's on global pages).

- add a global wallpaper (image dark/light + overlay opacity + blur) to
  MiscConfig, surfaced via a "Set wallpaper..." dialog under Theme
- resolveBackground() replaces the URL image helper and returns the
  styling source; a cleared/empty image falls back to the global one
- app.component derives opacity/blur from the resolved source

Builds on the URL-aware background stream contributed in the PR.

Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
Co-authored-by: jibin7jose <jibin7jose@users.noreply.github.com>
This commit is contained in:
jibin jose 2026-07-02 16:23:38 +05:30 committed by GitHub
parent db99a4c345
commit 393f686e4b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 476 additions and 85 deletions

View file

@ -1,36 +0,0 @@
import { getBackgroundImageBlur, getBackgroundOverlayOpacity } from './app.component';
describe('AppComponent theme helpers', () => {
describe('getBackgroundOverlayOpacity()', () => {
it('should use the default overlay opacity when the active context is missing', () => {
expect(getBackgroundOverlayOpacity(null)).toBe(0.2);
expect(getBackgroundOverlayOpacity(undefined)).toBe(0.2);
});
it('should use the default overlay opacity when a persisted context has a null theme', () => {
expect(getBackgroundOverlayOpacity({ theme: null })).toBe(0.2);
});
it('should resolve configured overlay opacity to a CSS alpha value', () => {
expect(
getBackgroundOverlayOpacity({ theme: { backgroundOverlayOpacity: 65 } }),
).toBe(0.65);
});
});
describe('getBackgroundImageBlur()', () => {
it('should use zero blur when the active context is missing', () => {
expect(getBackgroundImageBlur(null)).toBe(0);
expect(getBackgroundImageBlur(undefined)).toBe(0);
});
it('should use zero blur when a persisted context has a null theme', () => {
expect(getBackgroundImageBlur({ theme: null })).toBe(0);
});
it('should normalize configured blur values', () => {
expect(getBackgroundImageBlur({ theme: { backgroundImageBlur: 12 } })).toBe(12);
expect(getBackgroundImageBlur({ theme: { backgroundImageBlur: -5 } })).toBe(0);
});
});
});

View file

@ -64,14 +64,10 @@ import { SuperSyncEncryptionMigrationBannerService } from './imex/sync/super-syn
import { ProjectService } from './features/project/project.service';
import { TagService } from './features/tag/tag.service';
import { ContextMenuComponent } from './ui/context-menu/context-menu.component';
import {
WorkContextType,
type WorkContextThemeCfg,
} from './features/work-context/work-context.model';
import { WorkContextType } from './features/work-context/work-context.model';
import { SectionService } from './features/section/section.service';
import { DialogPromptComponent } from './ui/dialog-prompt/dialog-prompt.component';
import { TODAY_TAG } from './features/tag/tag.const';
import { normalizeBackgroundImageBlur } from './features/work-context/work-context.const';
import { openWorkContextSettingsDialog } from './features/work-context/dialog-work-context-settings/open-work-context-settings-dialog';
import { isInputElement } from './util/dom-element';
import { MobileBottomNavComponent } from './core-ui/mobile-bottom-nav/mobile-bottom-nav.component';
@ -95,21 +91,6 @@ interface BeforeInstallPromptEvent extends Event {
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
type WorkContextThemeSource =
| {
theme?: WorkContextThemeCfg | null;
}
| null
| undefined;
export const getBackgroundOverlayOpacity = (context: WorkContextThemeSource): number => {
const baseOpacity = context?.theme?.backgroundOverlayOpacity ?? 20;
return baseOpacity * 0.01;
};
export const getBackgroundImageBlur = (context: WorkContextThemeSource): number =>
normalizeBackgroundImageBlur(context?.theme?.backgroundImageBlur);
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
@ -226,10 +207,6 @@ export class AppComponent implements OnDestroy, AfterViewInit {
{ initialValue: null },
);
private readonly _activeWorkContext = toSignal(
this.workContextService.activeWorkContext$,
{ initialValue: null },
);
readonly resolvedBgImage = signal<string | null>(null);
isShowOnboardingPresets = signal(
@ -449,13 +426,12 @@ export class AppComponent implements OnDestroy, AfterViewInit {
this.layoutService.scrollToNewTask(taskId);
}
readonly bgOverlayOpacity = computed((): number => {
return getBackgroundOverlayOpacity(this._activeWorkContext());
});
readonly bgImageBlur = computed((): number => {
return getBackgroundImageBlur(this._activeWorkContext());
});
// Opacity + blur follow the resolved background source (per-context image or
// the global wallpaper), resolved centrally by GlobalThemeService — on
// non-context pages the active context is the sticky "Today" default and must
// not style the wallpaper.
readonly bgOverlayOpacity = this._globalThemeService.bgOverlayOpacity;
readonly bgImageBlur = this._globalThemeService.bgImageBlur;
readonly bgImageBlurFilter = computed((): string => {
const blur = this.bgImageBlur();

View file

@ -0,0 +1,171 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
MatDialogActions,
MatDialogContent,
MatDialogRef,
MatDialogTitle,
} from '@angular/material/dialog';
import { MatButton } from '@angular/material/button';
import { ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
import { FormlyFieldConfig, FormlyModule } from '@ngx-formly/core';
import { TranslatePipe } from '@ngx-translate/core';
import { T } from '../../../t.const';
import { GlobalConfigService } from '../../../features/config/global-config.service';
import { MiscConfig } from '../../../features/config/global-config.model';
import {
DEFAULT_BACKGROUND_OVERLAY_OPACITY,
hasAnyBackgroundImage,
MAX_BACKGROUND_IMAGE_BLUR,
} from '../../../features/work-context/work-context.const';
import { GlobalWallpaperCfg } from '../global-theme.service';
// Mutable copy of the global wallpaper shape so Formly can write to the model.
type WallpaperModel = {
-readonly [K in keyof GlobalWallpaperCfg]: GlobalWallpaperCfg[K];
};
// Mirrors the image-input + slider controls of the per-context theme form
// (work-context.const.ts); built fresh per dialog open because Formly mutates
// field configs at runtime. Opacity/blur stay hidden until an image is set.
const buildWallpaperFields = (): FormlyFieldConfig[] => [
{
key: 'backgroundImageDark',
type: 'image-input',
templateOptions: {
label: T.F.PROJECT.FORM_THEME.L_BACKGROUND_IMAGE_DARK,
description: '* https://some/cool.jpg',
},
},
{
key: 'backgroundImageLight',
type: 'image-input',
templateOptions: {
label: T.F.PROJECT.FORM_THEME.L_BACKGROUND_IMAGE_LIGHT,
description: '* https://some/cool.jpg',
},
},
{
key: 'backgroundOverlayOpacity',
type: 'slider',
resetOnHide: false,
props: {
label: T.F.PROJECT.FORM_THEME.L_BACKGROUND_OVERLAY_OPACITY,
description: T.F.PROJECT.FORM_THEME.D_BACKGROUND_OVERLAY_OPACITY,
type: 'number',
min: 0,
max: 99,
required: false,
displayWith: (value: number): string => `${value}%`,
},
expressions: {
hide: (field: FormlyFieldConfig): boolean => !hasAnyBackgroundImage(field.model),
},
},
{
key: 'backgroundImageBlur',
type: 'slider',
resetOnHide: false,
props: {
label: T.F.PROJECT.FORM_THEME.L_BACKGROUND_IMAGE_BLUR,
description: T.F.PROJECT.FORM_THEME.D_BACKGROUND_IMAGE_BLUR,
type: 'number',
min: 0,
max: MAX_BACKGROUND_IMAGE_BLUR,
required: false,
displayWith: (value: number): string => `${value}px`,
},
expressions: {
hide: (field: FormlyFieldConfig): boolean => !hasAnyBackgroundImage(field.model),
},
},
];
@Component({
selector: 'dialog-wallpaper',
standalone: true,
imports: [
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatButton,
ReactiveFormsModule,
FormlyModule,
TranslatePipe,
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1 mat-dialog-title>{{ T.GCF.MISC.WALLPAPER_DIALOG_TITLE | translate }}</h1>
<mat-dialog-content>
<p class="wallpaper-hint">{{ T.GCF.MISC.WALLPAPER_HINT | translate }}</p>
<form [formGroup]="form">
<formly-form
[form]="form"
[fields]="fields"
[model]="model"
(modelChange)="model = $event"
></formly-form>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button
mat-button
type="button"
(click)="close()"
>
{{ T.G.CANCEL | translate }}
</button>
<button
mat-flat-button
color="primary"
type="button"
(click)="save()"
>
{{ T.G.SAVE | translate }}
</button>
</mat-dialog-actions>
`,
styles: [
`
.wallpaper-hint {
margin: 0 0 16px;
opacity: 0.7;
}
`,
],
})
export class DialogWallpaperComponent {
private _dialogRef = inject<MatDialogRef<DialogWallpaperComponent>>(MatDialogRef);
private _globalConfigService = inject(GlobalConfigService);
readonly T = T;
readonly form = new UntypedFormGroup({});
readonly fields = buildWallpaperFields();
model: WallpaperModel = ((): WallpaperModel => {
const misc = this._globalConfigService.misc();
return {
backgroundImageDark: misc?.backgroundImageDark ?? null,
backgroundImageLight: misc?.backgroundImageLight ?? null,
backgroundOverlayOpacity:
misc?.backgroundOverlayOpacity ?? DEFAULT_BACKGROUND_OVERLAY_OPACITY,
backgroundImageBlur: misc?.backgroundImageBlur ?? 0,
};
})();
save(): void {
const changes: Partial<MiscConfig> = {
// A cleared picker stores '' — normalize to null so the fallback chain
// (context → global → none) treats it as unset.
backgroundImageDark: this.model.backgroundImageDark || null,
backgroundImageLight: this.model.backgroundImageLight || null,
backgroundOverlayOpacity: this.model.backgroundOverlayOpacity,
backgroundImageBlur: this.model.backgroundImageBlur,
};
this._globalConfigService.updateSection('misc', changes);
this._dialogRef.close();
}
close(): void {
this._dialogRef.close();
}
}

View file

@ -0,0 +1,133 @@
import { type WorkContextThemeCfg } from '../../features/work-context/work-context.model';
import { GlobalWallpaperCfg, resolveBackground } from './global-theme.service';
describe('resolveBackground()', () => {
const contextTheme: WorkContextThemeCfg = {
backgroundImageDark: 'ctx-dark.jpg',
backgroundImageLight: 'ctx-light.jpg',
backgroundOverlayOpacity: 50,
backgroundImageBlur: 8,
};
const contextThemeWithoutImage: WorkContextThemeCfg = {
backgroundImageDark: null,
backgroundImageLight: null,
backgroundOverlayOpacity: 50,
backgroundImageBlur: 8,
};
const globalCfg: GlobalWallpaperCfg = {
backgroundImageDark: 'global-dark.jpg',
backgroundImageLight: 'global-light.jpg',
backgroundOverlayOpacity: 30,
backgroundImageBlur: 4,
};
const globalCfgEmpty: GlobalWallpaperCfg = {
backgroundImageDark: null,
backgroundImageLight: null,
};
describe('before initial navigation resolves (empty url)', () => {
it('shows no background when no global wallpaper is set', () => {
expect(
resolveBackground(contextTheme, globalCfgEmpty, false, '').imageUrl,
).toBeNull();
});
it('shows the global wallpaper when set (never the sticky context image)', () => {
const res = resolveBackground(contextTheme, globalCfg, false, '');
expect(res.imageUrl).toBe('global-light.jpg');
// global styling, not the sticky context's
expect(res.overlayOpacity).toBe(0.3);
expect(res.blur).toBe(4);
});
});
['/planner', '/schedule', '/boards', '/config'].forEach((url) => {
it(`never uses the active context image on non-context route ${url}`, () => {
expect(
resolveBackground(contextTheme, globalCfgEmpty, false, url).imageUrl,
).toBeNull();
});
it(`shows the global wallpaper + global styling on ${url}`, () => {
const light = resolveBackground(contextTheme, globalCfg, false, url);
expect(light.imageUrl).toBe('global-light.jpg');
expect(light.overlayOpacity).toBe(0.3);
expect(light.blur).toBe(4);
const dark = resolveBackground(contextTheme, globalCfg, true, url);
expect(dark.imageUrl).toBe('global-dark.jpg');
});
});
['/tag/TODAY/tasks', '/project/project-1/tasks'].forEach((url) => {
it(`uses the context image + context styling on ${url}`, () => {
const res = resolveBackground(contextTheme, globalCfg, false, url);
expect(res.imageUrl).toBe('ctx-light.jpg');
// styling must come from the context that owns the image
expect(res.overlayOpacity).toBe(0.5);
expect(res.blur).toBe(8);
});
it(`falls back to the global wallpaper + global styling on ${url} when the context has no image`, () => {
const res = resolveBackground(contextThemeWithoutImage, globalCfg, false, url);
expect(res.imageUrl).toBe('global-light.jpg');
expect(res.overlayOpacity).toBe(0.3);
expect(res.blur).toBe(4);
});
it(`treats a cleared (empty-string) context image as unset and falls back to global on ${url}`, () => {
const cleared: WorkContextThemeCfg = {
backgroundImageLight: ' ',
backgroundImageDark: '',
};
expect(resolveBackground(cleared, globalCfg, false, url).imageUrl).toBe(
'global-light.jpg',
);
});
});
it('does not misclassify a non-context route that mentions /tag/ in its query', () => {
// regex is anchored to the path start
expect(
resolveBackground(contextTheme, globalCfgEmpty, false, '/planner?ref=/tag/TODAY')
.imageUrl,
).toBeNull();
});
it('uses the dark context image in dark mode', () => {
expect(
resolveBackground(contextTheme, globalCfgEmpty, true, '/tag/TODAY/tasks').imageUrl,
).toBe('ctx-dark.jpg');
});
describe('overlay-opacity + blur math (migrated from app.component helpers)', () => {
it('defaults to 20% overlay / 0 blur when the winning source sets neither', () => {
const res = resolveBackground(
{},
{ backgroundImageLight: 'g.jpg' },
false,
'/planner',
);
expect(res.overlayOpacity).toBe(0.2);
expect(res.blur).toBe(0);
});
it('converts overlay opacity to a CSS alpha and clamps blur', () => {
const highOpacity = resolveBackground(
{ backgroundImageLight: 'c.jpg', backgroundOverlayOpacity: 65 },
globalCfgEmpty,
false,
'/tag/TODAY/tasks',
);
expect(highOpacity.overlayOpacity).toBe(0.65);
const negativeBlur = resolveBackground(
{ backgroundImageLight: 'c.jpg', backgroundImageBlur: -5 },
globalCfgEmpty,
false,
'/tag/TODAY/tasks',
);
expect(negativeBlur.blur).toBe(0);
});
});
});

View file

@ -1,4 +1,5 @@
import {
computed,
DestroyRef,
effect,
EnvironmentInjector,
@ -11,7 +12,15 @@ import {
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
import { BodyClass, IS_ELECTRON, IS_GNOME_WAYLAND } from '../../app.constants';
import { IS_MAC } from '../../util/is-mac';
import { distinctUntilChanged, map, startWith, switchMap, take } from 'rxjs/operators';
import {
distinctUntilChanged,
filter,
map,
startWith,
switchMap,
take,
} from 'rxjs/operators';
import { NavigationEnd, Router } from '@angular/router';
import { IS_TOUCH_ONLY } from '../../util/is-touch-only';
import { MaterialCssVarsService } from 'angular-material-css-vars';
import { DOCUMENT } from '@angular/common';
@ -21,6 +30,11 @@ import { ChromeExtensionInterfaceService } from '../chrome-extension-interface/c
import { GlobalConfigService } from '../../features/config/global-config.service';
import { WorkContextThemeCfg } from '../../features/work-context/work-context.model';
import {
DEFAULT_BACKGROUND_OVERLAY_OPACITY,
isBackgroundImageSet,
normalizeBackgroundImageBlur,
} from '../../features/work-context/work-context.const';
import { WorkContextService } from '../../features/work-context/work-context.service';
import { combineLatest, fromEvent, Observable, of } from 'rxjs';
import { IS_FIREFOX } from '../../util/is-firefox';
@ -65,6 +79,68 @@ const CSS_VAR_SAFE_AREA_LEFT = '--safe-area-inset-left';
const CSS_VAR_SAFE_AREA_RIGHT = '--safe-area-inset-right';
const VIEWPORT_RESIZE_EPSILON_PX = 1;
/** The four wallpaper fields of the app-level (global) background config. */
export type GlobalWallpaperCfg = Pick<
WorkContextThemeCfg,
| 'backgroundImageDark'
| 'backgroundImageLight'
| 'backgroundOverlayOpacity'
| 'backgroundImageBlur'
>;
export interface ResolvedBackground {
/** The image to show, or null when no background applies to this URL. */
imageUrl: string | null;
/** Final CSS overlay opacity (0..0.99) of the resolved image's source. */
overlayOpacity: number;
/** Blur radius in px of the resolved image's source. */
blur: number;
}
const _styleOf = (
theme: GlobalWallpaperCfg,
): Pick<ResolvedBackground, 'overlayOpacity' | 'blur'> => ({
overlayOpacity:
(theme.backgroundOverlayOpacity ?? DEFAULT_BACKGROUND_OVERLAY_OPACITY) * 0.01,
blur: normalizeBackgroundImageBlur(theme.backgroundImageBlur),
});
/**
* Resolve which background image and styling apply to the current route.
*
* Precedence: per-context image global wallpaper none. Crucially, on
* non-work-context routes (Planner, Schedule, Boards, Config, ) the active
* work context stays "Today" (the reducer default), so we must never use its
* image there only the global wallpaper. The overlay-opacity and blur travel
* with the resolved image so a global wallpaper is never styled by the sticky
* context's settings.
*/
export const resolveBackground = (
contextTheme: WorkContextThemeCfg,
globalCfg: GlobalWallpaperCfg,
isDarkMode: boolean,
url: string,
): ResolvedBackground => {
// Anchor to the path start so a query/fragment containing "/tag/" or
// "/project/" can't misclassify a non-context route.
const isWorkContextUrl = /^\/(tag|project)\//.test(url);
const contextImg = isDarkMode
? contextTheme.backgroundImageDark
: contextTheme.backgroundImageLight;
if (isWorkContextUrl && isBackgroundImageSet(contextImg)) {
return { imageUrl: contextImg, ..._styleOf(contextTheme) };
}
const globalImg = isDarkMode
? globalCfg.backgroundImageDark
: globalCfg.backgroundImageLight;
return {
imageUrl: isBackgroundImageSet(globalImg) ? globalImg : null,
..._styleOf(globalCfg),
};
};
@Injectable({ providedIn: 'root' })
export class GlobalThemeService {
private document = inject<Document>(DOCUMENT);
@ -75,6 +151,7 @@ export class GlobalThemeService {
private _matIconRegistry = inject(MatIconRegistry);
private readonly _registeredPluginIcons = new Set<string>();
private _domSanitizer = inject(DomSanitizer);
private _router = inject(Router);
private _chromeExtensionInterfaceService = inject(ChromeExtensionInterfaceService);
private _imexMetaService = inject(ImexViewService);
@ -134,17 +211,47 @@ export class GlobalThemeService {
isDarkTheme = toSignal(this._isDarkThemeObs$, { initialValue: false });
private _backgroundImgObs$: Observable<string | null | undefined> = combineLatest([
this._workContextService.currentTheme$,
this._isDarkThemeObs$,
]).pipe(
map(([theme, isDarkMode]) =>
isDarkMode ? theme.backgroundImageDark : theme.backgroundImageLight,
),
distinctUntilChanged(),
// Emits the current URL after each completed navigation, starting with the
// current URL so the stream is immediately available before any navigation.
private _currentUrl$: Observable<string> = this._router.events.pipe(
filter((e) => e instanceof NavigationEnd),
map((e) => (e as NavigationEnd).urlAfterRedirects),
startWith(this._router.url),
);
backgroundImg = toSignal(this._backgroundImgObs$);
private _resolvedBackground$: Observable<ResolvedBackground> = combineLatest([
this._workContextService.currentTheme$,
this._isDarkThemeObs$,
this._currentUrl$,
this._globalConfigService.misc$,
]).pipe(
map(([theme, isDarkMode, url, misc]) =>
resolveBackground(theme, misc, isDarkMode, url),
),
distinctUntilChanged(
(a, b) =>
a.imageUrl === b.imageUrl &&
a.overlayOpacity === b.overlayOpacity &&
a.blur === b.blur,
),
);
private _resolvedBackground = toSignal(this._resolvedBackground$, {
initialValue: {
imageUrl: null,
overlayOpacity: DEFAULT_BACKGROUND_OVERLAY_OPACITY * 0.01,
blur: 0,
} satisfies ResolvedBackground,
});
/** The resolved background image URL for the current route (null if none). */
readonly backgroundImg = computed(() => this._resolvedBackground().imageUrl);
/** Final CSS overlay opacity for the resolved background image. */
readonly bgOverlayOpacity = computed(() => this._resolvedBackground().overlayOpacity);
/** Blur radius (px) for the resolved background image. */
readonly bgImageBlur = computed(() => this._resolvedBackground().blur);
init(): void {
if (this._hasInitialized) {

View file

@ -17,7 +17,9 @@ import { MatSelect, MatSelectChange } from '@angular/material/select';
import { MatOption } from '@angular/material/core';
import { MatFormField, MatLabel } from '@angular/material/form-field';
import { MatTooltip } from '@angular/material/tooltip';
import { MatDialog } from '@angular/material/dialog';
import { GlobalThemeService } from '../global-theme.service';
import { DialogWallpaperComponent } from '../dialog-wallpaper/dialog-wallpaper.component';
import { CustomTheme, CustomThemeRef, CustomThemeService } from '../custom-theme.service';
import { ThemeStorageService } from '../theme-storage.service';
import { SnackService } from '../../snack/snack.service';
@ -129,6 +131,18 @@ const valueToRef = (value: string): CustomThemeRef => {
(change)="onFileSelected($event)"
/>
</div>
<div class="wallpaper-select">
<h3>{{ T.GCF.MISC.WALLPAPER | translate }}</h3>
<button
mat-stroked-button
type="button"
(click)="openWallpaperDialog()"
>
<mat-icon>wallpaper</mat-icon>
{{ T.GCF.MISC.WALLPAPER_BUTTON | translate }}
</button>
</div>
</div>
`,
styles: [
@ -141,7 +155,8 @@ const valueToRef = (value: string): CustomThemeRef => {
}
.dark-mode-select,
.theme-select {
.theme-select,
.wallpaper-select {
display: flex;
align-items: center;
gap: 16px;
@ -182,7 +197,8 @@ const valueToRef = (value: string): CustomThemeRef => {
@media (max-width: 600px) {
.dark-mode-select,
.theme-select {
.theme-select,
.wallpaper-select {
flex-direction: column;
align-items: flex-start;
}
@ -200,6 +216,7 @@ export class ThemeSelectorComponent {
readonly customThemeService = inject(CustomThemeService);
private readonly _themeStorage = inject(ThemeStorageService);
private readonly _snackService = inject(SnackService);
private readonly _matDialog = inject(MatDialog);
readonly T = T;
readonly fileInput = viewChild<ElementRef<HTMLInputElement>>('fileInput');
@ -234,6 +251,10 @@ export class ThemeSelectorComponent {
this.fileInput()?.nativeElement.click();
}
openWallpaperDialog(): void {
this._matDialog.open(DialogWallpaperComponent, { autoFocus: false });
}
async onFileSelected(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];

View file

@ -69,6 +69,8 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
isShowProductivityTipLonger: false,
customTheme: 'default',
defaultStartPage: 0,
backgroundImageDark: null,
backgroundImageLight: null,
},
shortSyntax: {
isEnableProject: true,

View file

@ -46,6 +46,12 @@ export type MiscConfig = Readonly<{
// number: one of DefaultStartPage. string: project id.
defaultStartPage?: number | string;
unsplashApiKey?: string | null;
// Global wallpaper: shown on pages that don't provide their own background
// (Planner, Schedule, Boards, Config, and tags/projects without an image).
backgroundImageDark?: string | null;
backgroundImageLight?: string | null;
backgroundOverlayOpacity?: number;
backgroundImageBlur?: number;
// @todo: remove deprecated items in future major releases, after giving users time to migrate
isConfirmBeforeTaskDelete?: boolean; // Deprecated

View file

@ -18,6 +18,7 @@ export const DEFAULT_TAG_COLOR = '#a05db1';
export const DEFAULT_TODAY_TAG_COLOR = '#6495ED';
export const DEFAULT_BACKGROUND_IMAGE_BLUR = 0;
export const MAX_BACKGROUND_IMAGE_BLUR = 20;
export const DEFAULT_BACKGROUND_OVERLAY_OPACITY = 20;
export const WORK_CONTEXT_DEFAULT_THEME: WorkContextThemeCfg = {
isAutoContrast: true,
@ -61,20 +62,22 @@ export const HUES = [
{ value: '900', label: '900' },
];
const _isBackgroundImageSet = (value: unknown): boolean =>
// A cleared image picker stores '' rather than null, so treat empty/whitespace
// strings as "no image set".
export const isBackgroundImageSet = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0;
export const hasAnyBackgroundImage = (model: unknown): boolean =>
typeof model === 'object' &&
model !== null &&
(_isBackgroundImageSet((model as WorkContextThemeCfg).backgroundImageDark) ||
_isBackgroundImageSet((model as WorkContextThemeCfg).backgroundImageLight));
(isBackgroundImageSet((model as WorkContextThemeCfg).backgroundImageDark) ||
isBackgroundImageSet((model as WorkContextThemeCfg).backgroundImageLight));
export const hasAllBackgroundImages = (model: unknown): boolean =>
typeof model === 'object' &&
model !== null &&
_isBackgroundImageSet((model as WorkContextThemeCfg).backgroundImageDark) &&
_isBackgroundImageSet((model as WorkContextThemeCfg).backgroundImageLight);
isBackgroundImageSet((model as WorkContextThemeCfg).backgroundImageDark) &&
isBackgroundImageSet((model as WorkContextThemeCfg).backgroundImageLight);
export const normalizeBackgroundImageBlur = (value: unknown): number => {
if (typeof value !== 'number' || !Number.isFinite(value)) {

View file

@ -2592,6 +2592,10 @@ const T = {
THEME_REMOVED_TOAST: 'GCF.MISC.THEME_REMOVED_TOAST',
THEME_SELECT_LABEL: 'GCF.MISC.THEME_SELECT_LABEL',
TITLE: 'GCF.MISC.TITLE',
WALLPAPER: 'GCF.MISC.WALLPAPER',
WALLPAPER_BUTTON: 'GCF.MISC.WALLPAPER_BUTTON',
WALLPAPER_DIALOG_TITLE: 'GCF.MISC.WALLPAPER_DIALOG_TITLE',
WALLPAPER_HINT: 'GCF.MISC.WALLPAPER_HINT',
},
POMODORO: {
BREAK_DURATION: 'GCF.POMODORO.BREAK_DURATION',

View file

@ -2531,7 +2531,11 @@
"THEME_REMOVE_BUTTON": "Remove theme",
"THEME_REMOVED_TOAST": "Theme removed; reverted to Default.",
"THEME_SELECT_LABEL": "Select Theme",
"TITLE": "Misc Settings"
"TITLE": "Misc Settings",
"WALLPAPER": "Wallpaper",
"WALLPAPER_BUTTON": "Set wallpaper…",
"WALLPAPER_DIALOG_TITLE": "Global wallpaper",
"WALLPAPER_HINT": "Shown on pages without their own background (Planner, Schedule, Boards, Settings…). Tags and projects with their own image override it."
},
"POMODORO": {
"BREAK_DURATION": "Short break duration",