diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts deleted file mode 100644 index f5fede54a7..0000000000 --- a/src/app/app.component.spec.ts +++ /dev/null @@ -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); - }); - }); -}); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index aec30ab3b4..d344b11669 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -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(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(); diff --git a/src/app/core/theme/dialog-wallpaper/dialog-wallpaper.component.ts b/src/app/core/theme/dialog-wallpaper/dialog-wallpaper.component.ts new file mode 100644 index 0000000000..0864b66f15 --- /dev/null +++ b/src/app/core/theme/dialog-wallpaper/dialog-wallpaper.component.ts @@ -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: ` +

{{ T.GCF.MISC.WALLPAPER_DIALOG_TITLE | translate }}

+ +

{{ T.GCF.MISC.WALLPAPER_HINT | translate }}

+
+ +
+
+ + + + + `, + styles: [ + ` + .wallpaper-hint { + margin: 0 0 16px; + opacity: 0.7; + } + `, + ], +}) +export class DialogWallpaperComponent { + private _dialogRef = inject>(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 = { + // 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(); + } +} diff --git a/src/app/core/theme/global-theme.service.spec.ts b/src/app/core/theme/global-theme.service.spec.ts new file mode 100644 index 0000000000..76d9c812f4 --- /dev/null +++ b/src/app/core/theme/global-theme.service.spec.ts @@ -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); + }); + }); +}); diff --git a/src/app/core/theme/global-theme.service.ts b/src/app/core/theme/global-theme.service.ts index 2a6f4e1127..d70545bfc8 100644 --- a/src/app/core/theme/global-theme.service.ts +++ b/src/app/core/theme/global-theme.service.ts @@ -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 => ({ + 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); @@ -75,6 +151,7 @@ export class GlobalThemeService { private _matIconRegistry = inject(MatIconRegistry); private readonly _registeredPluginIcons = new Set(); 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 = 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 = 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 = 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) { diff --git a/src/app/core/theme/theme-selector/theme-selector.component.ts b/src/app/core/theme/theme-selector/theme-selector.component.ts index 9934299575..373be66693 100644 --- a/src/app/core/theme/theme-selector/theme-selector.component.ts +++ b/src/app/core/theme/theme-selector/theme-selector.component.ts @@ -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)" /> + +
+

{{ T.GCF.MISC.WALLPAPER | translate }}

+ +
`, 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>('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 { const input = event.target as HTMLInputElement; const file = input.files?.[0]; diff --git a/src/app/features/config/default-global-config.const.ts b/src/app/features/config/default-global-config.const.ts index 9fa5bf8d3e..5325b8ce11 100644 --- a/src/app/features/config/default-global-config.const.ts +++ b/src/app/features/config/default-global-config.const.ts @@ -69,6 +69,8 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = { isShowProductivityTipLonger: false, customTheme: 'default', defaultStartPage: 0, + backgroundImageDark: null, + backgroundImageLight: null, }, shortSyntax: { isEnableProject: true, diff --git a/src/app/features/config/global-config.model.ts b/src/app/features/config/global-config.model.ts index 5aac140002..00213b98ae 100644 --- a/src/app/features/config/global-config.model.ts +++ b/src/app/features/config/global-config.model.ts @@ -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 diff --git a/src/app/features/work-context/work-context.const.ts b/src/app/features/work-context/work-context.const.ts index 49d89e7a36..576f9f4a44 100644 --- a/src/app/features/work-context/work-context.const.ts +++ b/src/app/features/work-context/work-context.const.ts @@ -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)) { diff --git a/src/app/t.const.ts b/src/app/t.const.ts index e8ba94c7c0..27537fa553 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -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', diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index c8f31a12c1..ec92269b09 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -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",