From 6a813965196c41128c7e315f6c0664b51b3a319d Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Sat, 5 Sep 2026 10:30:52 +0700 Subject: [PATCH 01/14] feat: add a themeable appearance system Replace the fixed palette with semantic tokens that a theme definition fills in, so colours come from data rather than from rules scattered through the stylesheets. A theme sets an accent and a surface, and the palette derives the rest, including the contrasting text colour for each generated background. Adds an appearance menu for choosing a mode, an editor for adjusting a custom theme, and an optional background image kept on the device that uploaded it. Preferences are stored per user and applied before first paint so the page does not flash the previous colours. Course, list, welcome and home pages read the tokens instead of hard-coded values, which is what lets a theme restyle them without touching each page. --- .gitignore | 7 + src/app/home/course/course.page.html | 3 + src/app/home/course/course.page.scss | 19 +- src/app/home/course/course.page.ts | 4 + .../course/modal-evaluation.component.html | 2 +- src/app/home/home.page.html | 6 +- src/app/home/home.page.scss | 40 +- src/app/home/home.page.ts | 5 +- src/app/home/list/list.page.html | 7 +- src/app/home/list/list.page.scss | 10 - src/app/home/list/list.page.ts | 7 +- src/app/shared/theme-editor.component.html | 131 +++++++ src/app/shared/theme-editor.component.scss | 152 ++++++++ src/app/shared/theme-editor.component.ts | 159 ++++++++ src/app/shared/theme-menu.component.html | 23 ++ src/app/shared/theme-menu.component.scss | 35 ++ src/app/shared/theme-menu.component.ts | 57 +++ src/app/theme.service.ts | 360 ++++++++++++++++++ src/app/theme/background-store.ts | 116 ++++++ src/app/theme/color.ts | 193 ++++++++++ src/app/theme/palette.spec.ts | 122 ++++++ src/app/theme/palette.ts | 255 +++++++++++++ src/app/theme/theme-presets.ts | 278 ++++++++++++++ src/app/theme/theme.model.ts | 87 +++++ src/app/welcome/welcome.page.html | 3 + src/app/welcome/welcome.page.scss | 7 + src/app/welcome/welcome.page.ts | 2 + src/global.scss | 74 +++- src/helpers.ts | 24 +- src/index.html | 21 + src/main.ts | 7 +- src/theme/variables.scss | 292 +++++++------- 32 files changed, 2262 insertions(+), 246 deletions(-) create mode 100644 src/app/shared/theme-editor.component.html create mode 100644 src/app/shared/theme-editor.component.scss create mode 100644 src/app/shared/theme-editor.component.ts create mode 100644 src/app/shared/theme-menu.component.html create mode 100644 src/app/shared/theme-menu.component.scss create mode 100644 src/app/shared/theme-menu.component.ts create mode 100644 src/app/theme.service.ts create mode 100644 src/app/theme/background-store.ts create mode 100644 src/app/theme/color.ts create mode 100644 src/app/theme/palette.spec.ts create mode 100644 src/app/theme/palette.ts create mode 100644 src/app/theme/theme-presets.ts create mode 100644 src/app/theme/theme.model.ts diff --git a/.gitignore b/.gitignore index 97f54ae..ff18163 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,10 @@ npm-debug.log* # Sentry Config File .env.sentry-build-plugin .sentryclirc + +# Local agent and assistant files +/.agents +/.claude +AGENTS.md +CLAUDE.md +*.local.json diff --git a/src/app/home/course/course.page.html b/src/app/home/course/course.page.html index bac1407..8ac1e2e 100644 --- a/src/app/home/course/course.page.html +++ b/src/app/home/course/course.page.html @@ -7,6 +7,9 @@ {{ (courseProgress.duration - courseProgress.viewed) / 3600 | number:'1.0-1' }} hours left ({{ (courseProgress.viewed / courseProgress.duration) * 100 | number:'1.0-1' }}%) } + + + diff --git a/src/app/home/course/course.page.scss b/src/app/home/course/course.page.scss index 37c02b7..f45c09d 100644 --- a/src/app/home/course/course.page.scss +++ b/src/app/home/course/course.page.scss @@ -3,7 +3,7 @@ span a { } ion-title small { - color: gray; + color: var(--flick-muted-text); } .e-learning-tag { @@ -12,30 +12,21 @@ ion-title small { span.date { font-family: monospace, monospace; - color: darkblue; - @media (prefers-color-scheme: dark) { - color: lightblue; - } + color: var(--flick-companion-text); } span.date-divider { margin: 0 0.2rem 0 0.2rem; opacity: 0.5; - color: darkblue; - @media (prefers-color-scheme: dark) { - color: lightblue; - } + color: var(--flick-companion-text); } .time-info { - color: darkgoldenrod; - @media (prefers-color-scheme: dark) { - color: yellow; - } + color: var(--flick-warning-text); } .ion-color .check-icon { - background-color: white; + background-color: var(--ion-color-contrast); } .set-playback-speed-button { diff --git a/src/app/home/course/course.page.ts b/src/app/home/course/course.page.ts index f30584f..44b6c16 100644 --- a/src/app/home/course/course.page.ts +++ b/src/app/home/course/course.page.ts @@ -10,6 +10,7 @@ import { AlertController, IonBackButton, IonButton, + IonButtons, IonCard, IonCardContent, IonCardHeader, @@ -40,6 +41,7 @@ import { ulid } from 'ulid'; import { AsyncPipe, DatePipe, DecimalPipe, NgClass } from '@angular/common'; import { ModalEvaluationComponent } from './modal-evaluation.component'; import { PomodoroTimerComponent } from '../../shared/pomodoro-timer.component'; +import { ThemeMenuComponent } from '../../shared/theme-menu.component'; @Component({ selector: 'app-course', @@ -72,6 +74,8 @@ import { PomodoroTimerComponent } from '../../shared/pomodoro-timer.component'; DecimalPipe, DatePipe, PomodoroTimerComponent, + IonButtons, + ThemeMenuComponent, ] }) export class CoursePage implements OnInit, AfterViewInit, OnDestroy { diff --git a/src/app/home/course/modal-evaluation.component.html b/src/app/home/course/modal-evaluation.component.html index 4a6b558..e44f007 100644 --- a/src/app/home/course/modal-evaluation.component.html +++ b/src/app/home/course/modal-evaluation.component.html @@ -14,7 +14,7 @@ @if (video) { -
+
Lecture: {{ video.title }}
Instructor: {{ video.lecturer }}
diff --git a/src/app/home/home.page.html b/src/app/home/home.page.html index 50d3485..50162e5 100644 --- a/src/app/home/home.page.html +++ b/src/app/home/home.page.html @@ -4,6 +4,7 @@ MDCU Recorded Lecture + Sign out @@ -53,7 +54,7 @@

{{ result.title }}

{{ formatDuration(result.duration) }} } @if (result.courseYear) { - {{ result.courseYear }} + {{ result.courseYear }} }

@@ -83,7 +84,8 @@

{{ response.last_played.video.course.name }}

@for (folder of Object.keys(response.years); track folder) { - {{ folder }} diff --git a/src/app/home/home.page.scss b/src/app/home/home.page.scss index e1ad0f2..aadba50 100644 --- a/src/app/home/home.page.scss +++ b/src/app/home/home.page.scss @@ -10,18 +10,15 @@ ion-card { min-height: 4rem; } -ion-card-title { - color: white; +/* Ionic gives the header and title a colour of their own, so both have to defer to the + card, which carries the group's readable label colour. */ +.hover-card ion-card-header, +.hover-card ion-card-title { + color: inherit; } .course-name { - color: #222222; -} - -@media (prefers-color-scheme: dark) { - .course-name { - color: #cccccc; - } + color: var(--ion-text-color); } .center-text { @@ -34,18 +31,10 @@ ion-card-title { } .small-text { - color: #aaaaaa; + color: var(--flick-muted-text); a { - color: #bbbbbb; - } - - @media (prefers-color-scheme: dark) { - color: #555555; - - a { - color: #666666; - } + color: var(--flick-muted-text); } } @@ -60,12 +49,12 @@ ion-card-title { p { padding: 0 1.5rem 0 1.5rem; - color: gray; + color: var(--flick-muted-text); text-align: center; ion-icon { margin: 0 0.6em; - color: gray; + color: var(--flick-muted-text); } a { @@ -149,10 +138,6 @@ p { padding: 0.1rem 0.4rem; font-size: 0.75rem; font-variant-numeric: tabular-nums; - - @media (prefers-color-scheme: dark) { - background: var(--ion-color-dark-tint); - } } .course-info { @@ -173,15 +158,10 @@ p { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - - @media (prefers-color-scheme: dark) { - color: var(--ion-color-primary-tint); - } } .course-year-badge { flex-shrink: 0; - color: #fff; border-radius: 4px; padding: 0.05rem 0.4rem; font-size: 0.7rem; diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts index d2e6b67..9fd2cc2 100644 --- a/src/app/home/home.page.ts +++ b/src/app/home/home.page.ts @@ -3,7 +3,7 @@ import {Observable, Subject, combineLatest, of} from 'rxjs'; import {CourseListResponse, Lecture, ManService, SearchVideoResult} from '../man.service'; import {Router, RouterLink} from '@angular/router'; import {AuthService} from '../auth.service'; -import {colorByFolderName} from '../../helpers'; +import {colorByFolderName, contrastByFolderName} from '../../helpers'; import {addIcons} from "ionicons"; import {logOutOutline, searchOutline} from "ionicons/icons"; import {debounceTime, distinctUntilChanged, map, switchMap, tap} from 'rxjs/operators'; @@ -30,6 +30,7 @@ import { IonToolbar, } from '@ionic/angular/standalone'; import {AsyncPipe, NgStyle} from '@angular/common'; +import {ThemeMenuComponent} from '../shared/theme-menu.component'; export interface EnrichedSearchResult extends SearchVideoResult { courseName?: string; @@ -45,6 +46,7 @@ export interface EnrichedSearchResult extends SearchVideoResult { IonContent, IonGrid, IonRow, IonCol, IonCard, RouterLink, NgStyle, IonCardHeader, IonCardTitle, AsyncPipe, IonCardContent, IonItem, IonLabel, IonText, IonSpinner, IonSearchbar, IonList, + ThemeMenuComponent, ] }) export class HomePage implements OnInit { @@ -120,6 +122,7 @@ export class HomePage implements OnInit { } protected readonly colorByFolderName = colorByFolderName; + protected readonly contrastByFolderName = contrastByFolderName; protected readonly Object = Object; goToLastVideo(lastVideo: Lecture) { diff --git a/src/app/home/list/list.page.html b/src/app/home/list/list.page.html index c3bbd09..90cf8ac 100644 --- a/src/app/home/list/list.page.html +++ b/src/app/home/list/list.page.html @@ -2,8 +2,11 @@ - {{ year || 'Loading'}} + {{ year || 'Loading'}} + + + {{ result.title }} {{ formatDuration(result.duration) }} } @if (result.courseYear) { - {{ result.courseYear }} + {{ result.courseYear }} }

diff --git a/src/app/home/list/list.page.scss b/src/app/home/list/list.page.scss index c7d5737..b93f618 100644 --- a/src/app/home/list/list.page.scss +++ b/src/app/home/list/list.page.scss @@ -1,6 +1,5 @@ ion-title>span { padding: 0.2em 0.5em; - color: white; } ion-label { @@ -94,10 +93,6 @@ ion-label { padding: 0.1rem 0.4rem; font-size: 0.75rem; font-variant-numeric: tabular-nums; - - @media (prefers-color-scheme: dark) { - background: var(--ion-color-dark-tint); - } } .course-info { @@ -118,16 +113,11 @@ ion-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - - @media (prefers-color-scheme: dark) { - color: var(--ion-color-primary-tint); - } } .course-year-badge { grid-column: 4; flex-shrink: 0; - color: #fff; border-radius: 4px; padding: 0.05rem 0.4rem; font-size: 0.7rem; diff --git a/src/app/home/list/list.page.ts b/src/app/home/list/list.page.ts index b745a5b..02a32cb 100644 --- a/src/app/home/list/list.page.ts +++ b/src/app/home/list/list.page.ts @@ -3,9 +3,10 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { EMPTY, Observable, Subject, of } from 'rxjs'; import { debounceTime, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'; import { ManService, SearchVideoResult } from '../../man.service'; -import { colorByFolderName } from '../../../helpers'; +import { colorByFolderName, contrastByFolderName } from '../../../helpers'; import { IonBackButton, + IonButtons, IonContent, IonHeader, IonItem, @@ -18,6 +19,7 @@ import { IonToolbar } from '@ionic/angular/standalone'; import { AsyncPipe, NgStyle } from '@angular/common'; +import { ThemeMenuComponent } from '../../shared/theme-menu.component'; export interface EnrichedSearchResult extends SearchVideoResult { courseName?: string; @@ -31,7 +33,7 @@ export interface EnrichedSearchResult extends SearchVideoResult { imports: [ IonHeader, IonToolbar, RouterLink, IonBackButton, IonTitle, NgStyle, IonContent, IonList, IonListHeader, IonItem, IonLabel, AsyncPipe, - IonSearchbar, IonSpinner, + IonSearchbar, IonSpinner, IonButtons, ThemeMenuComponent, ] }) export class ListPage implements OnInit { @@ -122,6 +124,7 @@ export class ListPage implements OnInit { } protected readonly colorByFolderName = colorByFolderName; + protected readonly contrastByFolderName = contrastByFolderName; private groupByAcademicYear(courses: { name: string, is_remote: boolean, id: number, link: string[] }[]) { const groups = new Map(); diff --git a/src/app/shared/theme-editor.component.html b/src/app/shared/theme-editor.component.html new file mode 100644 index 0000000..ad1cdad --- /dev/null +++ b/src/app/shared/theme-editor.component.html @@ -0,0 +1,131 @@ + + + Custom theme + + + + + + + + + + @if (settings$ | async; as settings) { +
+

Pre-made templates

+

Ready-made pairings first, then quick colours.

+
+ @for (template of themeService.templates; track template.id) { + + } + + + + @for (swatch of accentSwatches; track swatch) { + + } + +
+
+ +
+

Primary colour

+

The page behind everything.

+
+ + @if (settings.custom.background.color) { + + Match secondary + + } +
+
+ +
+

Secondary colour

+

Buttons, links, outlines, course cards and the heatmap.

+
+ +
+
+ +
+

Light or dark

+

Leaves your colours behind and goes back to the plain theme.

+ + @for (option of themeService.standardModes; track option.value) { + + + {{ option.label }} + + } + +
+ +
+

Background picture

+

Optional, and kept on this device only.

+ +
+ + + {{ (backgroundImageUrl$ | async) ? 'Replace' : 'Upload' }} + + @if (backgroundImageUrl$ | async; as imageUrl) { + Current background + Remove + } +
+ + @if (imageError) { +

{{ imageError }}

+ } + + @if (backgroundImageUrl$ | async) { + + @for (option of themeService.backgroundFitOptions; track option.value) { + + {{ option.label }} + + } + + + + Visibility + + + + + Blur + + + } +
+ + Start over + } +
diff --git a/src/app/shared/theme-editor.component.scss b/src/app/shared/theme-editor.component.scss new file mode 100644 index 0000000..19e9c0f --- /dev/null +++ b/src/app/shared/theme-editor.component.scss @@ -0,0 +1,152 @@ +.theme-section { + margin-bottom: 1.5rem; +} + +.section-title { + margin: 0 0 0.15rem; + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--flick-muted-text); +} + +.section-hint { + margin: 0 0 0.6rem; + font-size: 0.75rem; + color: var(--flick-muted-text); +} + +.colour-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.colour-field { + position: relative; + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.6rem; + border: 1px solid var(--ion-border-color, var(--ion-color-step-150)); + border-radius: 8px; + cursor: pointer; +} + +.colour-value { + font-size: 0.8rem; + font-variant-numeric: tabular-nums; + text-transform: uppercase; + color: var(--flick-muted-text); +} + +.swatch-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; +} + +.swatch-template { + background: linear-gradient(90deg, var(--swatch-from) 0 50%, var(--swatch-to) 50% 100%); +} + +.swatch-divider { + width: 1px; + height: 1.5rem; + margin: 0 0.15rem; + background: var(--ion-border-color, var(--ion-color-step-150)); +} + +.swatch { + width: 2rem; + height: 2rem; + padding: 0; + border: 1px solid var(--ion-border-color, var(--ion-color-step-150)); + border-radius: 50%; + cursor: pointer; +} + +.swatch.selected { + box-shadow: 0 0 0 2px var(--ion-background-color), 0 0 0 4px var(--ion-color-primary); +} + +.swatch-picker { + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--flick-surface-muted); + color: var(--ion-text-color); + position: relative; + overflow: hidden; +} + +.swatch-picker input, +.colour-field input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + +.colour-preview { + display: inline-block; + width: 1.6rem; + height: 1.6rem; + border: 1px solid var(--ion-border-color, var(--ion-color-step-150)); + border-radius: 6px; + display: inline-block; +} + +.control-item { + --padding-start: 0; + --inner-padding-end: 0; +} + +.control-range { + max-width: 60%; + padding-inline-end: 0; +} + +.image-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.image-preview { + width: 4rem; + height: 2.5rem; + object-fit: cover; + border-radius: 6px; + border: 1px solid var(--ion-border-color, var(--ion-color-step-150)); +} + +.file-input { + display: none; +} + +.hint { + margin: 0.35rem 0 0; + font-size: 0.72rem; + color: var(--flick-muted-text); +} + +/* Ionic paints these labels in a palette colour; the theme's readable variants are used + instead so the editor stays legible whatever colour the page ends up. */ +.quiet-button { + --color: var(--flick-muted-text); +} + +.accent-button { + --color: var(--flick-accent-text); + --border-color: var(--flick-accent-text); +} + +ion-segment-button { + --color: var(--flick-muted-text); + --color-checked: var(--flick-accent-text); + --indicator-color: var(--flick-accent-text); +} diff --git a/src/app/shared/theme-editor.component.ts b/src/app/shared/theme-editor.component.ts new file mode 100644 index 0000000..2a9fa36 --- /dev/null +++ b/src/app/shared/theme-editor.component.ts @@ -0,0 +1,159 @@ +import {Component, ElementRef, inject, ViewChild} from '@angular/core'; +import {AsyncPipe} from '@angular/common'; +import { + IonButton, + IonButtons, + IonContent, + IonHeader, + IonIcon, + IonItem, + IonLabel, + IonRange, + IonSegment, + IonSegmentButton, + IonText, + IonTitle, + IonToolbar, + ModalController, +} from '@ionic/angular/standalone'; +import {addIcons} from 'ionicons'; +import {close, colorPaletteOutline, imageOutline, moonOutline, phonePortraitOutline, sunnyOutline} from 'ionicons/icons'; +import {OWN_COLOR_TEMPLATE_ID, ThemeService} from '../theme.service'; +import {ACCENT_SWATCHES} from '../theme/theme-presets'; +import {BackgroundFit, ThemeMode, ThemeSettings, ThemeTemplate} from '../theme/theme.model'; + +function detailValue(event: Event): T | undefined { + return (event as CustomEvent<{value?: T}>).detail?.value; +} + +@Component({ + selector: 'app-theme-editor', + templateUrl: './theme-editor.component.html', + styleUrls: ['./theme-editor.component.scss'], + imports: [ + IonHeader, IonToolbar, IonTitle, IonButtons, IonButton, IonIcon, IonContent, + IonSegment, IonSegmentButton, IonLabel, IonItem, IonRange, IonText, AsyncPipe, + ], +}) +export class ThemeEditorComponent { + protected themeService = inject(ThemeService); + private modalCtrl = inject(ModalController); + + @ViewChild('fileInput') fileInput: ElementRef; + + protected readonly accentSwatches = ACCENT_SWATCHES; + protected readonly settings$ = this.themeService.settings$; + protected readonly backgroundImageUrl$ = this.themeService.backgroundImageUrl$; + protected imageError: string | null = null; + + constructor() { + addIcons({close, colorPaletteOutline, imageOutline, sunnyOutline, moonOutline, phonePortraitOutline}); + } + + close(): void { + this.modalCtrl.dismiss(); + } + + selectTemplate(templateId: string): void { + this.themeService.selectTemplate(templateId); + } + + selectAccent(accent: string): void { + this.themeService.setAccent(accent); + } + + reset(): void { + this.imageError = null; + this.themeService.resetCustom(); + } + + /** A template reads as its page colour beside the colour everything else is drawn in. */ + templateSwatch(template: ThemeTemplate): Record { + const accent = template.seed.accent ?? ''; + return { + '--swatch-from': template.seed.surfaceTint ?? accent, + '--swatch-to': accent, + }; + } + + isAccent(settings: ThemeSettings, swatch: string): boolean { + return settings.custom.templateId === OWN_COLOR_TEMPLATE_ID + && settings.custom.seed.accent?.toLowerCase() === swatch.toLowerCase(); + } + + accentValue(settings: ThemeSettings): string { + return settings.custom.seed.accent + ?? this.themeService.preview(settings.custom.seed)['--ion-color-primary']; + } + + backgroundValue(settings: ThemeSettings): string { + return settings.custom.background.color + ?? this.themeService.preview(settings.custom.seed)['--ion-background-color']; + } + + /** The plain modes replace the custom theme rather than recolouring it. */ + onModeChange(event: Event): void { + const value = detailValue(event); + if (value) { + this.themeService.setMode(value); + this.close(); + } + } + + onFitChange(event: Event): void { + const value = detailValue(event); + if (value) { + this.themeService.setBackgroundFit(value); + } + } + + onOpacityChange(event: Event): void { + const value = detailValue(event); + if (typeof value === 'number') { + this.themeService.setBackgroundOpacity(value); + } + } + + onBlurChange(event: Event): void { + const value = detailValue(event); + if (typeof value === 'number') { + this.themeService.setBackgroundBlur(value); + } + } + + onAccentInput(event: Event): void { + this.themeService.setAccent((event.target as HTMLInputElement).value); + } + + onBackgroundColorInput(event: Event): void { + this.themeService.setBackgroundColor((event.target as HTMLInputElement).value); + } + + clearBackgroundColor(): void { + this.themeService.setBackgroundColor(null); + } + + pickImage(): void { + this.imageError = null; + this.fileInput?.nativeElement.click(); + } + + async onImageSelected(event: Event): Promise { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + input.value = ''; + if (!file) { + return; + } + try { + await this.themeService.setBackgroundImage(file); + } catch { + this.imageError = 'That picture could not be used. Please try another one.'; + } + } + + async removeImage(): Promise { + this.imageError = null; + await this.themeService.clearBackgroundImage(); + } +} diff --git a/src/app/shared/theme-menu.component.html b/src/app/shared/theme-menu.component.html new file mode 100644 index 0000000..98cf444 --- /dev/null +++ b/src/app/shared/theme-menu.component.html @@ -0,0 +1,23 @@ + + + + + + + + @for (option of themeService.modes; track option.value) { + + + +

{{ option.label }}

+

{{ option.description }}

+
+ @if (option.value === (themeService.mode$ | async)) { + + } +
+ } +
+
+
diff --git a/src/app/shared/theme-menu.component.scss b/src/app/shared/theme-menu.component.scss new file mode 100644 index 0000000..57369a2 --- /dev/null +++ b/src/app/shared/theme-menu.component.scss @@ -0,0 +1,35 @@ +:host { + display: inline-flex; + align-items: center; +} + +.mode-list { + padding: 0.25rem 0; +} + +.mode-list ion-item { + --min-height: 3rem; + cursor: pointer; +} + +.mode-list h3 { + margin: 0; + font-size: 0.95rem; + font-weight: 600; +} + +.mode-list p { + margin: 0; + font-size: 0.75rem; + color: var(--flick-muted-text); +} + +.mode-icon { + margin-inline-end: 0.75rem; + font-size: 1.15rem; + color: var(--flick-muted-text); +} + +.selected .mode-icon { + color: var(--ion-color-primary); +} diff --git a/src/app/shared/theme-menu.component.ts b/src/app/shared/theme-menu.component.ts new file mode 100644 index 0000000..46676f1 --- /dev/null +++ b/src/app/shared/theme-menu.component.ts @@ -0,0 +1,57 @@ +import {Component, inject} from '@angular/core'; +import {AsyncPipe} from '@angular/common'; +import { + IonButton, + IonIcon, + IonItem, + IonLabel, + IonList, + IonPopover, + ModalController, +} from '@ionic/angular/standalone'; +import {addIcons} from 'ionicons'; +import { + checkmark, + colorPaletteOutline, + moonOutline, + phonePortraitOutline, + sunnyOutline, +} from 'ionicons/icons'; +import {ulid} from 'ulid'; +import {ThemeService} from '../theme.service'; +import {ThemeMode} from '../theme/theme.model'; +import {ThemeEditorComponent} from './theme-editor.component'; + +@Component({ + selector: 'app-theme-menu', + templateUrl: './theme-menu.component.html', + styleUrls: ['./theme-menu.component.scss'], + imports: [IonButton, IonIcon, IonPopover, IonList, IonItem, IonLabel, AsyncPipe], +}) +export class ThemeMenuComponent { + protected themeService = inject(ThemeService); + private modalCtrl = inject(ModalController); + + /** The menu can appear on more than one toolbar, so each trigger needs its own id. */ + protected readonly triggerId = 'theme-menu-' + ulid(); + + constructor() { + addIcons({sunnyOutline, moonOutline, phonePortraitOutline, colorPaletteOutline, checkmark}); + } + + async select(mode: ThemeMode): Promise { + this.themeService.setMode(mode); + if (mode === 'custom') { + await this.openEditor(); + } + } + + private async openEditor(): Promise { + const modal = await this.modalCtrl.create({ + component: ThemeEditorComponent, + breakpoints: [0, 0.9], + initialBreakpoint: 0.9, + }); + await modal.present(); + } +} diff --git a/src/app/theme.service.ts b/src/app/theme.service.ts new file mode 100644 index 0000000..55be7a6 --- /dev/null +++ b/src/app/theme.service.ts @@ -0,0 +1,360 @@ +import {DestroyRef, inject, Injectable} from '@angular/core'; +import {BehaviorSubject, Observable} from 'rxjs'; +import {distinctUntilChanged, map} from 'rxjs/operators'; +import {ulid} from 'ulid'; +import {AuthService} from './auth.service'; +import {isValidColor} from './theme/color'; +import {buildThemeVariables, CssVariables} from './theme/palette'; +import { + BACKGROUND_FIT_OPTIONS, + DEFAULT_BACKGROUND, + defaultCustomTheme, + defaultThemeSettings, + findTemplate, + CUSTOM_SCHEME, + NEUTRAL_SEED, + OWN_COLOR_INTENSITY, + STANDARD_MODES, + OWN_COLOR_TEMPLATE_ID, + THEME_MODES, + THEME_TEMPLATES, +} from './theme/theme-presets'; +import {BackgroundImageStore, prepareBackgroundImage} from './theme/background-store'; +import { + BackgroundFit, + ColorScheme, + CustomTheme, + SchemePreference, + ThemeBackground, + ThemeMode, + ThemeModeOption, + ThemeSeed, + ThemeSettings, +} from './theme/theme.model'; + +/** Mirrors the last applied palette so index.html can paint it before Angular boots. */ +export const APPLIED_THEME_KEY = 'flickThemeApplied'; +export const THEME_STORAGE_KEY_PREFIX = 'flickTheme_'; +export const BACKGROUND_IMAGE_CLASS = 'flick-has-background-image'; +const GUEST_ID = 'guest'; + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function sanitizeColor(value: unknown): string | null { + return typeof value === 'string' && isValidColor(value) ? value : null; +} + +function sanitizeSeed(raw: unknown): ThemeSeed { + const seed = (raw ?? {}) as Partial; + return { + accent: sanitizeColor(seed.accent), + companion: sanitizeColor(seed.companion), + tertiary: sanitizeColor(seed.tertiary), + surfaceTint: sanitizeColor(seed.surfaceTint), + intensity: clamp(Number(seed.intensity) || 0, 0, 1), + }; +} + +function sanitizeCustom(raw: unknown): CustomTheme { + const fallback = defaultCustomTheme(); + const value = (raw ?? {}) as Partial; + const background = (value.background ?? {}) as Partial; + const templateId = typeof value.templateId === 'string' + && (value.templateId === OWN_COLOR_TEMPLATE_ID || !!findTemplate(value.templateId)) + ? value.templateId + : fallback.templateId; + const template = findTemplate(templateId); + // Only a stored background speaks for the user; without one the template's own page + // colour applies, so opening the editor matches picking that template. + const storedBackground = !!value.background; + return { + templateId, + seed: template ? {...template.seed} : sanitizeSeed(value.seed), + background: { + color: storedBackground ? sanitizeColor(background.color) : (template?.background ?? null), + imageId: typeof background.imageId === 'string' ? background.imageId : null, + imageOpacity: clamp(Number(background.imageOpacity ?? DEFAULT_BACKGROUND.imageOpacity), 0, 1), + imageBlur: clamp(Number(background.imageBlur ?? DEFAULT_BACKGROUND.imageBlur), 0, 20), + imageFit: BACKGROUND_FIT_OPTIONS.some(option => option.value === background.imageFit) + ? background.imageFit as BackgroundFit + : DEFAULT_BACKGROUND.imageFit, + }, + }; +} + +/** Accept only values this build understands, so stored data can never break rendering. */ +export function sanitizeSettings(raw: unknown): ThemeSettings { + const fallback = defaultThemeSettings(); + const value = (raw ?? {}) as Partial; + return { + mode: THEME_MODES.some(option => option.value === value.mode) + ? value.mode as ThemeMode + : fallback.mode, + custom: sanitizeCustom(value.custom), + updatedAt: Number(value.updatedAt) || 0, + }; +} + +@Injectable({ + providedIn: 'root', +}) +export class ThemeService { + private imageStore = new BackgroundImageStore(); + + readonly modes = THEME_MODES; + readonly templates = THEME_TEMPLATES; + readonly standardModes = STANDARD_MODES; + readonly backgroundFitOptions = BACKGROUND_FIT_OPTIONS; + + private readonly settingsSubject = new BehaviorSubject(defaultThemeSettings()); + private readonly schemeSubject = new BehaviorSubject('light'); + private readonly imageUrlSubject = new BehaviorSubject(null); + + readonly settings$: Observable = this.settingsSubject.asObservable(); + readonly scheme$: Observable = this.schemeSubject.asObservable(); + readonly backgroundImageUrl$: Observable = this.imageUrlSubject.asObservable(); + readonly mode$: Observable = this.settings$.pipe( + map(settings => settings.mode), + distinctUntilChanged(), + ); + readonly modeOption$: Observable = this.mode$.pipe( + map(mode => this.modes.find(option => option.value === mode) ?? this.modes[0]), + ); + + private userId = GUEST_ID; + private objectUrl: string | null = null; + private darkQuery: MediaQueryList | null = null; + + constructor() { + const destroyRef = inject(DestroyRef); + this.darkQuery = window.matchMedia?.('(prefers-color-scheme: dark)') ?? null; + const onSystemChange = () => this.apply(this.settingsSubject.value, false); + this.darkQuery?.addEventListener('change', onSystemChange); + destroyRef.onDestroy(() => { + this.darkQuery?.removeEventListener('change', onSystemChange); + this.releaseObjectUrl(); + }); + + this.apply(this.readLocal(GUEST_ID), false); + + inject(AuthService).user.subscribe(user => { + if (user?.uid) { + this.attachUser(user.uid); + } else if (this.userId !== GUEST_ID) { + this.detachUser(); + } + }); + } + + get settings(): ThemeSettings { + return this.settingsSubject.value; + } + + get scheme(): ColorScheme { + return this.schemeSubject.value; + } + + get mode(): ThemeMode { + return this.settings.mode; + } + + setMode(mode: ThemeMode): void { + this.update({mode}); + } + + selectTemplate(templateId: string): void { + const template = findTemplate(templateId); + if (template) { + this.updateCustom({ + templateId: template.id, + seed: {...template.seed}, + background: {...this.settings.custom.background, color: template.background}, + }); + } + } + + setAccent(accent: string): void { + if (!isValidColor(accent)) { + return; + } + this.updateCustom({ + templateId: OWN_COLOR_TEMPLATE_ID, + seed: { + accent, + companion: null, + tertiary: null, + surfaceTint: null, + intensity: OWN_COLOR_INTENSITY, + }, + }); + } + + setBackgroundColor(color: string | null): void { + if (color !== null && !isValidColor(color)) { + return; + } + this.updateCustom({background: {...this.settings.custom.background, color}}); + } + + setBackgroundFit(imageFit: BackgroundFit): void { + this.updateCustom({background: {...this.settings.custom.background, imageFit}}); + } + + setBackgroundOpacity(imageOpacity: number): void { + this.updateCustom({background: {...this.settings.custom.background, imageOpacity: clamp(imageOpacity, 0, 1)}}); + } + + setBackgroundBlur(imageBlur: number): void { + this.updateCustom({background: {...this.settings.custom.background, imageBlur: clamp(imageBlur, 0, 20)}}); + } + + /** Store an uploaded picture on this device and use it as the page background. */ + async setBackgroundImage(file: File): Promise { + const image = await prepareBackgroundImage(file); + const imageId = ulid(); + await this.imageStore.save(imageId, image); + await this.imageStore.prune(imageId); + this.updateCustom({background: {...this.settings.custom.background, imageId}}); + } + + async clearBackgroundImage(): Promise { + const {imageId} = this.settings.custom.background; + if (imageId) { + await this.imageStore.remove(imageId); + } + this.updateCustom({background: {...this.settings.custom.background, imageId: null}}); + } + + resetCustom(): void { + this.update({custom: defaultCustomTheme()}); + } + + /** Palette for a seed without applying it, used to preview a template in the picker. */ + preview(seed: ThemeSeed, scheme = this.scheme): CssVariables { + return buildThemeVariables(seed, scheme, DEFAULT_BACKGROUND); + } + + private updateCustom(change: Partial): void { + this.update({mode: 'custom', custom: {...this.settings.custom, ...change}}); + } + + private update(change: Partial): void { + this.apply({...this.settings, ...change, updatedAt: Date.now()}, true); + } + + /** The standard modes deliberately carry no colour, picture or background of their own. */ + private resolve(settings: ThemeSettings): {scheme: ColorScheme, seed: ThemeSeed, background: ThemeBackground} { + if (settings.mode === 'custom') { + return { + scheme: CUSTOM_SCHEME, + seed: settings.custom.seed, + background: settings.custom.background, + }; + } + return { + scheme: this.resolveScheme(settings.mode), + seed: {...NEUTRAL_SEED}, + background: {...DEFAULT_BACKGROUND}, + }; + } + + private apply(settings: ThemeSettings, persist: boolean): void { + const {scheme, seed, background} = this.resolve(settings); + const variables = buildThemeVariables(seed, scheme, background); + + const root = document.documentElement; + root.setAttribute('data-theme', scheme); + for (const [name, value] of Object.entries(variables)) { + root.style.setProperty(name, value); + } + + this.updateBrowserThemeColor(variables['--ion-toolbar-background']); + this.schemeSubject.next(scheme); + this.settingsSubject.next(settings); + this.writeAppliedMirror(scheme, variables); + void this.applyBackgroundImage(background.imageId); + + if (persist) { + this.writeLocal(settings); + } + } + + private resolveScheme(preference: SchemePreference): ColorScheme { + if (preference === 'system') { + return this.darkQuery?.matches ? 'dark' : 'light'; + } + return preference; + } + + /** Keep the browser chrome (address bar, task switcher) in step with the theme. */ + private updateBrowserThemeColor(color: string): void { + const meta = document.querySelector('meta[name="theme-color"]'); + if (meta) { + meta.content = color; + } + } + + private async applyBackgroundImage(imageId: string | null): Promise { + const image = imageId ? await this.imageStore.load(imageId) : null; + this.releaseObjectUrl(); + if (image) { + this.objectUrl = URL.createObjectURL(image); + } + const root = document.documentElement; + root.style.setProperty('--flick-background-image', this.objectUrl ? `url("${this.objectUrl}")` : 'none'); + document.body.classList.toggle(BACKGROUND_IMAGE_CLASS, !!this.objectUrl); + this.imageUrlSubject.next(this.objectUrl); + } + + private releaseObjectUrl(): void { + if (this.objectUrl) { + URL.revokeObjectURL(this.objectUrl); + this.objectUrl = null; + } + } + + private attachUser(uid: string): void { + if (this.userId === uid) { + return; + } + const guestSettings = this.userId === GUEST_ID ? this.settings : null; + this.userId = uid; + const stored = this.readLocal(uid); + const adoptGuest = guestSettings && guestSettings.updatedAt > stored.updatedAt; + this.apply(adoptGuest ? guestSettings : stored, adoptGuest); + } + + private detachUser(): void { + this.userId = GUEST_ID; + this.apply(this.readLocal(GUEST_ID), false); + } + + private readLocal(uid: string): ThemeSettings { + try { + const raw = localStorage.getItem(THEME_STORAGE_KEY_PREFIX + uid); + return sanitizeSettings(raw ? JSON.parse(raw) : null); + } catch { + return defaultThemeSettings(); + } + } + + private writeLocal(settings: ThemeSettings): void { + try { + localStorage.setItem(THEME_STORAGE_KEY_PREFIX + this.userId, JSON.stringify(settings)); + } catch { + // Storage may be unavailable; the theme still applies for this session. + } + } + + private writeAppliedMirror(scheme: ColorScheme, variables: CssVariables): void { + try { + localStorage.setItem(APPLIED_THEME_KEY, JSON.stringify({scheme, variables})); + } catch { + // Without the mirror the first paint falls back to the default palette. + } + } +} + +export {OWN_COLOR_TEMPLATE_ID}; diff --git a/src/app/theme/background-store.ts b/src/app/theme/background-store.ts new file mode 100644 index 0000000..d8e8be9 --- /dev/null +++ b/src/app/theme/background-store.ts @@ -0,0 +1,116 @@ +export const BACKGROUND_DB_NAME = 'flick-theme'; +export const BACKGROUND_STORE_NAME = 'backgrounds'; +export const BACKGROUND_MAX_DIMENSION = 1920; +export const BACKGROUND_IMAGE_QUALITY = 0.82; +export const BACKGROUND_IMAGE_TYPES = ['image/webp', 'image/jpeg']; + +function openDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(BACKGROUND_DB_NAME, 1); + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains(BACKGROUND_STORE_NAME)) { + request.result.createObjectStore(BACKGROUND_STORE_NAME); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +function transact(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest): Promise { + return openDatabase().then(db => new Promise((resolve, reject) => { + const transaction = db.transaction(BACKGROUND_STORE_NAME, mode); + const request = run(transaction.objectStore(BACKGROUND_STORE_NAME)); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + transaction.oncomplete = () => db.close(); + })); +} + +/** + * Background images stay on the device that uploaded them. Only the small display settings + * travel between devices, so nothing large is ever written to the server. + */ +export class BackgroundImageStore { + save(id: string, image: Blob): Promise { + return transact('readwrite', store => store.put(image, id)).then(() => undefined); + } + + load(id: string): Promise { + return transact('readonly', store => store.get(id)) + .then(value => value ?? null) + .catch(() => null); + } + + remove(id: string): Promise { + return transact('readwrite', store => store.delete(id)).then(() => undefined).catch(() => undefined); + } + + keys(): Promise { + return transact('readonly', store => store.getAllKeys()) + .then(keys => keys.map(String)) + .catch(() => []); + } + + /** Drop every stored image except the one still in use. */ + async prune(keepId: string | null): Promise { + for (const key of await this.keys()) { + if (key !== keepId) { + await this.remove(key); + } + } + } +} + +function pickImageType(): string { + const canvas = document.createElement('canvas'); + return BACKGROUND_IMAGE_TYPES.find(type => canvas.toDataURL(type).startsWith(`data:${type}`)) + ?? BACKGROUND_IMAGE_TYPES[BACKGROUND_IMAGE_TYPES.length - 1]; +} + +function drawScaled(source: CanvasImageSource, width: number, height: number): HTMLCanvasElement { + const scale = Math.min(1, BACKGROUND_MAX_DIMENSION / Math.max(width, height)); + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round(width * scale)); + canvas.height = Math.max(1, Math.round(height * scale)); + const context = canvas.getContext('2d'); + context.drawImage(source, 0, 0, canvas.width, canvas.height); + return canvas; +} + +function loadImageElement(file: File): Promise { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(file); + const image = new Image(); + image.onload = () => { + URL.revokeObjectURL(url); + resolve(image); + }; + image.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('The image could not be read.')); + }; + image.src = url; + }); +} + +/** Downscale and re-encode an uploaded picture so a large photo cannot fill up storage. */ +export async function prepareBackgroundImage(file: File): Promise { + let canvas: HTMLCanvasElement; + if ('createImageBitmap' in window) { + const bitmap = await createImageBitmap(file); + canvas = drawScaled(bitmap, bitmap.width, bitmap.height); + bitmap.close(); + } else { + const image = await loadImageElement(file); + canvas = drawScaled(image, image.naturalWidth, image.naturalHeight); + } + const type = pickImageType(); + return new Promise((resolve, reject) => { + canvas.toBlob( + blob => blob ? resolve(blob) : reject(new Error('The image could not be processed.')), + type, + BACKGROUND_IMAGE_QUALITY, + ); + }); +} diff --git a/src/app/theme/color.ts b/src/app/theme/color.ts new file mode 100644 index 0000000..396908e --- /dev/null +++ b/src/app/theme/color.ts @@ -0,0 +1,193 @@ +export interface Rgb { + r: number; + g: number; + b: number; +} + +export interface Hsl { + h: number; + s: number; + l: number; +} + +const HEX_SHORT = /^#?([\da-f])([\da-f])([\da-f])$/i; +const HEX_FULL = /^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i; + +export const WHITE: Rgb = {r: 255, g: 255, b: 255}; +export const BLACK: Rgb = {r: 0, g: 0, b: 0}; + +export function parseColor(value: string): Rgb | null { + if (!value) { + return null; + } + const short = HEX_SHORT.exec(value.trim()); + if (short) { + return { + r: parseInt(short[1] + short[1], 16), + g: parseInt(short[2] + short[2], 16), + b: parseInt(short[3] + short[3], 16), + }; + } + const full = HEX_FULL.exec(value.trim()); + if (full) { + return {r: parseInt(full[1], 16), g: parseInt(full[2], 16), b: parseInt(full[3], 16)}; + } + return null; +} + +export function isValidColor(value: string): boolean { + return parseColor(value) !== null; +} + +function clampChannel(value: number): number { + return Math.min(255, Math.max(0, Math.round(value))); +} + +export function toHex(color: Rgb): string { + return '#' + [color.r, color.g, color.b] + .map(channel => clampChannel(channel).toString(16).padStart(2, '0')) + .join(''); +} + +export function toRgbString(color: Rgb): string { + return [clampChannel(color.r), clampChannel(color.g), clampChannel(color.b)].join(', '); +} + +export function rgbToHsl(color: Rgb): Hsl { + const r = color.r / 255; + const g = color.g / 255; + const b = color.b / 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const delta = max - min; + const l = (max + min) / 2; + + if (!delta) { + return {h: 0, s: 0, l}; + } + + const s = l > 0.5 ? delta / (2 - max - min) : delta / (max + min); + let h: number; + if (max === r) { + h = ((g - b) / delta) % 6; + } else if (max === g) { + h = (b - r) / delta + 2; + } else { + h = (r - g) / delta + 4; + } + h *= 60; + return {h: (h + 360) % 360, s, l}; +} + +export function hslToRgb(hsl: Hsl): Rgb { + const h = ((hsl.h % 360) + 360) % 360; + const s = Math.min(1, Math.max(0, hsl.s)); + const l = Math.min(1, Math.max(0, hsl.l)); + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + const sector = Math.floor(h / 60) % 6; + const table: [number, number, number][] = [ + [c, x, 0], [x, c, 0], [0, c, x], [0, x, c], [x, 0, c], [c, 0, x], + ]; + const [r, g, b] = table[sector]; + return {r: (r + m) * 255, g: (g + m) * 255, b: (b + m) * 255}; +} + +/** Blend `weight` of `color` with `(1 - weight)` of `onto`. */ +export function mix(color: Rgb, onto: Rgb, weight: number): Rgb { + const ratio = Math.min(1, Math.max(0, weight)); + return { + r: color.r * ratio + onto.r * (1 - ratio), + g: color.g * ratio + onto.g * (1 - ratio), + b: color.b * ratio + onto.b * (1 - ratio), + }; +} + +export function shade(color: Rgb, amount: number): Rgb { + return mix(BLACK, color, amount); +} + +export function tint(color: Rgb, amount: number): Rgb { + return mix(WHITE, color, amount); +} + +export function rotateHue(color: Rgb, degrees: number): Rgb { + const hsl = rgbToHsl(color); + return hslToRgb({...hsl, h: hsl.h + degrees}); +} + +export function withHue(color: Rgb, hue: number): Rgb { + const hsl = rgbToHsl(color); + return hslToRgb({...hsl, h: hue}); +} + +export function relativeLuminance(color: Rgb): number { + const channels = [color.r, color.g, color.b].map(channel => { + const value = Math.min(255, Math.max(0, channel)) / 255; + return value <= 0.03928 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4); + }); + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; +} + +export function contrastRatio(a: Rgb, b: Rgb): number { + const first = relativeLuminance(a); + const second = relativeLuminance(b); + const lighter = Math.max(first, second); + const darker = Math.min(first, second); + return (lighter + 0.05) / (darker + 0.05); +} + +/** Whichever of black or white text is more readable on `background`. */ +export function readableOn(background: Rgb): Rgb { + return contrastRatio(background, WHITE) >= contrastRatio(background, BLACK) ? WHITE : BLACK; +} + +/** + * Lighten or darken `color` until it reaches `minRatio` against `background`, keeping its + * hue and saturation so a themed colour stays vivid rather than washing out to grey. + */ +export function adjustLightnessToContrast(color: Rgb, background: Rgb, minRatio: number, step = 0.04): Rgb { + if (contrastRatio(color, background) >= minRatio) { + return color; + } + const hsl = rgbToHsl(color); + const lighten = relativeLuminance(background) < 0.5; + let best = color; + for (let offset = step; offset <= 1; offset += step) { + const lightness = lighten ? hsl.l + offset : hsl.l - offset; + if (lightness <= 0 || lightness >= 1) { + break; + } + best = hslToRgb({...hsl, l: lightness}); + if (contrastRatio(best, background) >= minRatio) { + return best; + } + } + return best; +} + +/** + * Nudge `color` lighter or darker until it reaches `minRatio` against `background`, + * so a colour picked without regard for contrast still renders legibly. + */ +export function ensureContrast(color: Rgb, background: Rgb, minRatio: number): Rgb { + if (contrastRatio(color, background) >= minRatio) { + return color; + } + const target = relativeLuminance(background) > 0.5 ? BLACK : WHITE; + let low = 0; + let high = 1; + let result = mix(target, color, high); + for (let i = 0; i < 12; i++) { + const middle = (low + high) / 2; + const candidate = mix(target, color, middle); + if (contrastRatio(candidate, background) >= minRatio) { + result = candidate; + high = middle; + } else { + low = middle; + } + } + return result; +} diff --git a/src/app/theme/palette.spec.ts b/src/app/theme/palette.spec.ts new file mode 100644 index 0000000..d4e53db --- /dev/null +++ b/src/app/theme/palette.spec.ts @@ -0,0 +1,122 @@ +import {contrastRatio, parseColor, rgbToHsl} from './color'; +import {buildThemeVariables} from './palette'; +import { + BASE_SCHEMES, + DEFAULT_BACKGROUND, + DEFAULT_SERIES_COLORS, + MIN_MUTED_CONTRAST, + MIN_SERIES_CONTRAST, + MIN_TEXT_CONTRAST, + NEUTRAL_SEED, + SERIES_COUNT, + SERIES_HUE_DRIFT, + THEME_TEMPLATES, +} from './theme-presets'; +import {ColorScheme, SEMANTIC_ROLES, ThemeSeed} from './theme.model'; + +const SCHEMES: ColorScheme[] = ['light', 'dark']; + +function ratio(first: string, second: string): number { + return contrastRatio(parseColor(first), parseColor(second)); +} + +function build(seed: ThemeSeed, scheme: ColorScheme, background = DEFAULT_BACKGROUND) { + return buildThemeVariables(seed, scheme, background); +} + +describe('buildThemeVariables', () => { + it('should leave the base palette untouched for the standard modes', () => { + for (const scheme of SCHEMES) { + const variables = build(NEUTRAL_SEED, scheme); + const base = BASE_SCHEMES[scheme]; + expect(variables['--ion-background-color']).toBe(base.background); + expect(variables['--ion-text-color']).toBe(base.text); + for (const role of SEMANTIC_ROLES) { + expect(variables[`--ion-color-${role}`]).toBe(base.roles[role]); + } + } + }); + + it('should keep the course colours the app has always used outside a custom theme', () => { + for (const scheme of SCHEMES) { + const variables = build(NEUTRAL_SEED, scheme); + DEFAULT_SERIES_COLORS.forEach((color, index) => { + expect(variables[`--flick-series-${index}`]).toBe(color); + }); + } + }); + + it('should derive Ionic shade, tint and contrast the same way Ionic does', () => { + const variables = build(NEUTRAL_SEED, 'light'); + expect(variables['--ion-color-primary-shade']).toBe('#3171e0'); + expect(variables['--ion-color-primary-tint']).toBe('#4c8dff'); + expect(variables['--ion-color-primary-contrast']).toBe('#ffffff'); + expect(variables['--ion-color-warning-contrast']).toBe('#000000'); + }); + + it('should keep body text readable in every template and mode', () => { + for (const template of THEME_TEMPLATES) { + for (const scheme of SCHEMES) { + const variables = build(template.seed, scheme); + expect(ratio(variables['--ion-text-color'], variables['--ion-background-color'])) + .toBeGreaterThanOrEqual(MIN_TEXT_CONTRAST); + } + } + }); + + it('should keep secondary text readable on every surface', () => { + const surfaces = [ + '--ion-background-color', '--ion-card-background', + '--ion-item-background', '--ion-toolbar-background', '--flick-surface-muted', + ]; + for (const template of THEME_TEMPLATES) { + for (const scheme of SCHEMES) { + const variables = build(template.seed, scheme); + for (const surface of surfaces) { + expect(ratio(variables['--flick-muted-text'], variables[surface])) + .toBeGreaterThanOrEqual(MIN_MUTED_CONTRAST - 0.01); + } + } + } + }); + + it('should label every generated course colour legibly', () => { + for (const template of THEME_TEMPLATES) { + for (const scheme of SCHEMES) { + const variables = build(template.seed, scheme); + for (const key of [...Array(SERIES_COUNT).keys()].map(String).concat('fallback')) { + expect(ratio(variables[`--flick-series-${key}`], variables[`--flick-series-${key}-contrast`])) + .toBeGreaterThanOrEqual(MIN_SERIES_CONTRAST); + } + } + } + }); + + it('should keep the course colours of a custom theme in shades of its own colour', () => { + const variables = build(THEME_TEMPLATES[0].seed, 'light'); + const hues = [...Array(SERIES_COUNT).keys()] + .map(index => rgbToHsl(parseColor(variables[`--flick-series-${index}`])).h); + const accentHue = rgbToHsl(parseColor(variables['--ion-color-primary'])).h; + for (const hue of hues) { + const distance = Math.min(Math.abs(hue - accentHue), 360 - Math.abs(hue - accentHue)); + expect(distance).toBeLessThanOrEqual(SERIES_HUE_DRIFT + 1); + } + }); + + it('should stay readable when a background colour fights the chosen mode', () => { + const seed: ThemeSeed = {accent: '#e91e90', companion: null, tertiary: null, surfaceTint: null, intensity: 1}; + const variables = build(seed, 'light', {...DEFAULT_BACKGROUND, color: '#101820'}); + expect(variables['--ion-background-color']).toBe('#101820'); + expect(ratio(variables['--ion-text-color'], '#101820')).toBeGreaterThanOrEqual(MIN_TEXT_CONTRAST); + expect(variables['--ion-card-background']).not.toBe('#ffffff'); + expect(ratio(variables['--ion-text-color'], variables['--ion-card-background'])) + .toBeGreaterThanOrEqual(MIN_MUTED_CONTRAST); + }); + + it('should give the heatmap a ramp that ends at the accent', () => { + const variables = build(THEME_TEMPLATES[0].seed, 'light'); + const levels = [0, 1, 2, 3, 4].map(level => variables[`--flick-heat-${level}`]); + expect(new Set(levels).size).toBe(levels.length); + expect(levels[4]).toBe(variables['--flick-accent']); + }); +}); diff --git a/src/app/theme/palette.ts b/src/app/theme/palette.ts new file mode 100644 index 0000000..579c662 --- /dev/null +++ b/src/app/theme/palette.ts @@ -0,0 +1,255 @@ +import { + adjustLightnessToContrast, + contrastRatio, + ensureContrast, + hslToRgb, + mix, + parseColor, + readableOn, + rgbToHsl, + relativeLuminance, + Rgb, + rotateHue, + shade, + tint, + toHex, + toRgbString, + BLACK, + WHITE, +} from './color'; +import { + ACCENT_ROLES, + DEFAULT_SERIES_COLORS, + DEFAULT_SERIES_FALLBACK, + DEFAULT_SERIES_LABEL, + SERIES_COUNT, + SERIES_HUE_DRIFT, + SERIES_LIGHTNESS_RANGE, + SERIES_SATURATION, + TAG_SURFACE_WEIGHT, + ACCENT_WEIGHTS, + BASE_SCHEMES, + COLOR_STEPS, + COMPANION_HUE_OFFSET, + CONTRAST_LUMINANCE_THRESHOLD, + HEATMAP_EMPTY_WEIGHT, + HEATMAP_LEVEL_WEIGHTS, + MIN_ACCENT_CONTRAST, + MIN_ACCENT_ROLE_CONTRAST, + MIN_HEATMAP_CONTRAST, + MIN_MUTED_CONTRAST, + MUTED_TEXT_WEIGHT, + MIN_TEXT_CONTRAST, + ROLE_WEIGHTS, + SHADE_AMOUNT, + SURFACE_STEPS, + TERTIARY_HUE_OFFSET, + TINT_AMOUNT, +} from './theme-presets'; +import {BaseScheme, ColorScheme, SEMANTIC_ROLES, SemanticRole, ThemeBackground, ThemeSeed} from './theme.model'; + +export type CssVariables = Record; + +function clamp01(value: number): number { + return Math.min(1, Math.max(0, value)); +} + +/** Ionic picks black or white for text on a colour by how light that colour is. */ +function contrastFor(color: Rgb): Rgb { + return relativeLuminance(color) > CONTRAST_LUMINANCE_THRESHOLD ? BLACK : WHITE; +} + +function assignRole(variables: CssVariables, role: SemanticRole, color: Rgb): void { + const contrast = contrastFor(color); + variables[`--ion-color-${role}`] = toHex(color); + variables[`--ion-color-${role}-rgb`] = toRgbString(color); + variables[`--ion-color-${role}-contrast`] = toHex(contrast); + variables[`--ion-color-${role}-contrast-rgb`] = toRgbString(contrast); + variables[`--ion-color-${role}-shade`] = toHex(shade(color, SHADE_AMOUNT)); + variables[`--ion-color-${role}-tint`] = toHex(tint(color, TINT_AMOUNT)); +} + +interface AccentRoles { + accent: Rgb; + companion: Rgb; + tertiary: Rgb; + surfaceTint: Rgb; + /** True when the colours come from the theme seed rather than the untouched base palette. */ + derived: boolean; +} + +function seedColor(value: string | null): Rgb | null { + return value ? parseColor(value) : null; +} + +/** + * A seed without an accent keeps the scheme's own roles, which is what makes the + * untouched theme render exactly as the base palette in both light and dark. + */ +function resolveAccents(seed: ThemeSeed, base: BaseScheme): AccentRoles { + const chosen = seedColor(seed.accent); + const accent = chosen ?? parseColor(base.roles.primary) ?? BLACK; + const companion = seedColor(seed.companion) + ?? (chosen ? rotateHue(accent, COMPANION_HUE_OFFSET) : parseColor(base.roles.secondary) ?? accent); + const tertiary = seedColor(seed.tertiary) + ?? (chosen + ? rotateHue(seedColor(seed.companion) ?? accent, TERTIARY_HUE_OFFSET) + : parseColor(base.roles.tertiary) ?? accent); + const surfaceTint = seedColor(seed.surfaceTint) ?? accent; + return {accent, companion, tertiary, surfaceTint, derived: chosen !== null}; +} + +/** + * Turn a theme seed into the full set of CSS custom properties the app renders with. + * + * Every colour beyond the seed is derived here, so features never name a colour of their own + * and any accent — including one the user picked — produces a complete, legible palette. + */ +export function buildThemeVariables( + seed: ThemeSeed, + scheme: ColorScheme, + background: ThemeBackground, +): CssVariables { + const base = BASE_SCHEMES[scheme]; + const baseBackground = parseColor(base.background) ?? WHITE; + const baseText = parseColor(base.text) ?? BLACK; + const intensity = clamp01(seed.intensity); + const {accent, companion, tertiary, surfaceTint, derived} = resolveAccents(seed, base); + + const chosenBackground = background.color ? parseColor(background.color) : null; + const pageBackground = chosenBackground ?? mix(surfaceTint, baseBackground, ACCENT_WEIGHTS.background * intensity); + // A hand-picked background may be far lighter or darker than the scheme's own, so the text + // starts from whichever of black or white reads on it rather than from the scheme's colour. + const textAnchor = chosenBackground ? readableOn(chosenBackground) : baseText; + const tintedText = mix(surfaceTint, textAnchor, ACCENT_WEIGHTS.text * intensity); + const pageText = ensureContrast(tintedText, pageBackground, MIN_TEXT_CONTRAST); + + const variables: CssVariables = {}; + + const visible = (color: Rgb) => + derived ? adjustLightnessToContrast(color, pageBackground, MIN_ACCENT_ROLE_CONTRAST) : color; + const roleColors: Record = {} as Record; + roleColors.primary = visible(accent); + roleColors.secondary = visible(companion); + roleColors.tertiary = visible(tertiary); + for (const role of SEMANTIC_ROLES) { + if (ACCENT_ROLES.includes(role)) { + continue; + } + const baseRole = parseColor(base.roles[role]) ?? pageText; + roleColors[role] = mix(surfaceTint, baseRole, ROLE_WEIGHTS[role] * intensity); + } + variables['--ion-background-color'] = toHex(pageBackground); + variables['--ion-background-color-rgb'] = toRgbString(pageBackground); + variables['--ion-text-color'] = toHex(pageText); + variables['--ion-text-color-rgb'] = toRgbString(pageText); + + for (const step of COLOR_STEPS) { + variables[`--ion-color-step-${step}`] = toHex(mix(pageText, pageBackground, step / 1000)); + } + + // Surfaces follow the page background rather than the requested scheme, so a hand-picked + // background of any lightness still separates cards, items and toolbars from the page. + const surfaceScheme: ColorScheme = relativeLuminance(pageBackground) > CONTRAST_LUMINANCE_THRESHOLD + ? 'light' + : 'dark'; + const steps = SURFACE_STEPS[surfaceScheme]; + const surfaceOver = (step: number, weight: number) => + mix(surfaceTint, mix(pageText, pageBackground, step), weight * intensity); + + const item = surfaceOver(steps.item, ACCENT_WEIGHTS.item); + const card = surfaceOver(steps.card, ACCENT_WEIGHTS.card); + const toolbar = surfaceOver(steps.toolbar, ACCENT_WEIGHTS.toolbar); + const border = mix(pageText, pageBackground, COLOR_STEPS[2] / 1000); + + variables['--ion-item-background'] = toHex(item); + variables['--ion-item-background-activated'] = toHex(mix(surfaceTint, item, ACCENT_WEIGHTS.toolbar)); + variables['--ion-card-background'] = toHex(card); + variables['--ion-toolbar-background'] = toHex(toolbar); + variables['--ion-toolbar-color'] = toHex(pageText); + variables['--ion-border-color'] = toHex(border); + + // Every surface text can land on, including the light role, which some pages use as a page. + const surfaces = [ + pageBackground, card, item, toolbar, + roleColors.light, + mix(pageText, pageBackground, HEATMAP_EMPTY_WEIGHT), + ]; + const readableOnSurfaces = (color: Rgb, minRatio: number) => + surfaces.reduce((result, surface) => adjustLightnessToContrast(result, surface, minRatio), color); + + // Secondary text is held to a contrast target, but a chosen accent is left as chosen: + // a theme built from a brand colour has to render that colour, not an adjusted version. + if (derived) { + roleColors.medium = readableOnSurfaces(roleColors.medium, MIN_MUTED_CONTRAST); + } + for (const role of SEMANTIC_ROLES) { + assignRole(variables, role, roleColors[role]); + } + const accentText = readableOnSurfaces(accent, MIN_ACCENT_CONTRAST); + const companionText = readableOnSurfaces(companion, MIN_ACCENT_CONTRAST); + variables['--flick-accent'] = toHex(roleColors.primary); + variables['--flick-accent-rgb'] = toRgbString(roleColors.primary); + variables['--flick-accent-text'] = toHex(accentText); + variables['--flick-companion-text'] = toHex(companionText); + // Secondary text has to stay readable on every surface it can land on, not just the page. + variables['--flick-muted-text'] = toHex( + readableOnSurfaces(mix(pageText, pageBackground, MUTED_TEXT_WEIGHT), MIN_MUTED_CONTRAST)); + variables['--flick-surface'] = toHex(card); + variables['--flick-surface-muted'] = toHex(surfaces[surfaces.length - 1]); + + const tagColor = readableOnSurfaces(roleColors.success, MIN_ACCENT_CONTRAST); + variables['--flick-tag-color'] = toHex(tagColor); + variables['--flick-tag-background'] = toHex(mix(tagColor, pageBackground, TAG_SURFACE_WEIGHT)); + variables['--flick-warning-text'] = toHex(readableOnSurfaces(roleColors.warning, MIN_ACCENT_CONTRAST)); + + // The standard modes keep the group colours the app has always used. A custom theme + // colours the groups in shades of its own colour, each with a label that reads on it. + if (derived) { + const {h, s: saturation} = rgbToHsl(roleColors.primary); + const range = SERIES_LIGHTNESS_RANGE[scheme]; + const seriesColor = (position: number) => hslToRgb({ + h: h + (position - 0.5) * 2 * SERIES_HUE_DRIFT, + s: Math.max(saturation, SERIES_SATURATION[scheme]), + l: range.from + (range.to - range.from) * position, + }); + for (let index = 0; index < SERIES_COUNT; index++) { + const color = seriesColor(index / Math.max(1, SERIES_COUNT - 1)); + variables[`--flick-series-${index}`] = toHex(color); + // Whichever of black or white actually reads best: a mid-tone shade needs black, + // where Ionic's lightness rule would still hand it white. + variables[`--flick-series-${index}-contrast`] = toHex(readableOn(color)); + } + const fallback = mix(roleColors.medium, pageBackground, 0.85); + variables['--flick-series-fallback'] = toHex(fallback); + variables['--flick-series-fallback-contrast'] = toHex(readableOn(fallback)); + } else { + DEFAULT_SERIES_COLORS.forEach((color, index) => { + variables[`--flick-series-${index}`] = color; + variables[`--flick-series-${index}-contrast`] = DEFAULT_SERIES_LABEL; + }); + variables['--flick-series-fallback'] = DEFAULT_SERIES_FALLBACK; + variables['--flick-series-fallback-contrast'] = DEFAULT_SERIES_LABEL; + } + + const heatAccent = adjustLightnessToContrast(roleColors.primary, pageBackground, MIN_HEATMAP_CONTRAST); + variables['--flick-heat-0'] = toHex(mix(pageText, pageBackground, HEATMAP_EMPTY_WEIGHT)); + HEATMAP_LEVEL_WEIGHTS.forEach((weight, index) => { + variables[`--flick-heat-${index + 1}`] = toHex(mix(heatAccent, pageBackground, weight)); + }); + + const opacity = clamp01(background.imageOpacity); + variables['--flick-background-scrim'] = `rgba(${toRgbString(pageBackground)}, ${(1 - opacity).toFixed(3)})`; + variables['--flick-background-blur'] = `${Math.max(0, background.imageBlur)}px`; + variables['--flick-background-size'] = background.imageFit === 'tile' ? 'auto' : background.imageFit; + variables['--flick-background-repeat'] = background.imageFit === 'tile' ? 'repeat' : 'no-repeat'; + + return variables; +} + +/** Contrast of body text against the page background, used to verify a generated palette. */ +export function paletteTextContrast(variables: CssVariables): number { + const background = parseColor(variables['--ion-background-color']); + const text = parseColor(variables['--ion-text-color']); + return background && text ? contrastRatio(background, text) : 0; +} diff --git a/src/app/theme/theme-presets.ts b/src/app/theme/theme-presets.ts new file mode 100644 index 0000000..4e9c1e3 --- /dev/null +++ b/src/app/theme/theme-presets.ts @@ -0,0 +1,278 @@ +import { + BackgroundFitOption, + BaseScheme, + ColorScheme, + CustomTheme, + SemanticRole, + SurfaceSteps, + ThemeBackground, + ThemeModeOption, + ThemeSettings, + ThemeTemplate, +} from './theme.model'; + +/** + * Neutral starting palettes. Light, dark and system render exactly these, which keeps the + * standard modes identical to the app as it shipped. + */ +export const BASE_SCHEMES: Record = { + light: { + background: '#ffffff', + text: '#000000', + roles: { + primary: '#3880ff', + secondary: '#3dc2ff', + tertiary: '#5260ff', + success: '#2dd36f', + warning: '#ffc409', + danger: '#eb445a', + dark: '#222428', + medium: '#92949c', + light: '#f4f5f8', + }, + }, + dark: { + background: '#121212', + text: '#ffffff', + roles: { + primary: '#428cff', + secondary: '#50c8ff', + tertiary: '#6a64ff', + success: '#2fdf75', + warning: '#ffd534', + danger: '#ff4961', + dark: '#f4f5f8', + medium: '#989aa2', + light: '#222428', + }, + }, +}; + +/** + * Surfaces are placed relative to whatever the page background ends up being, so a light or + * dark background always keeps cards, items and toolbars separated from it. + */ +export const SURFACE_STEPS: Record = { + light: {card: 0, item: 0, toolbar: 0}, + dark: {card: 0.05, item: 0.033, toolbar: 0}, +}; + +/** How far each surface is blended towards the accent at full intensity. */ +export const ACCENT_WEIGHTS: Record = { + background: 0.06, + item: 0.1, + card: 0.1, + toolbar: 0.22, + text: 0.3, +}; + +/** + * Roles that carry the chosen colour directly. The rest keep their own meaning and are + * only blended towards the accent by the theme intensity. + */ +export const ACCENT_ROLES: SemanticRole[] = ['primary', 'secondary', 'tertiary']; + +export const ROLE_WEIGHTS: Record = { + primary: 1, + secondary: 1, + tertiary: 1, + success: 0.18, + warning: 0.18, + danger: 0.18, + dark: 0.15, + medium: 0.2, + light: 0.15, +}; + +/** Ionic's own derivation of the shade and tint variants of a colour. */ +export const SHADE_AMOUNT = 0.12; +export const TINT_AMOUNT = 0.1; +/** Above this relative luminance a colour takes black text rather than white. */ +export const CONTRAST_LUMINANCE_THRESHOLD = 0.5; + +/** Hue offsets used to derive the companion colours when a theme supplies only an accent. */ +export const COMPANION_HUE_OFFSET = -18; +export const TERTIARY_HUE_OFFSET = 18; + +/** Minimum contrast ratio body text must keep against the page background. */ +export const MIN_TEXT_CONTRAST = 7; +/** Minimum contrast ratio accent-coloured text must keep against the page background. */ +export const MIN_ACCENT_CONTRAST = 4.5; +/** Minimum contrast the accent roles keep against the page, so buttons stay visible. */ +export const MIN_ACCENT_ROLE_CONTRAST = 3.5; +/** Minimum contrast for secondary text such as captions and labels. */ +export const MIN_MUTED_CONTRAST = 4.5; +/** How far secondary text is faded towards the page before contrast is enforced. */ +export const MUTED_TEXT_WEIGHT = 0.55; +/** Contrast each group colour keeps against the label drawn on it. */ +export const MIN_SERIES_CONTRAST = 4.5; + +export const COLOR_STEPS = [ + 50, 100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800, 850, 900, 950, +]; + +/** Course groups that carry a colour of their own, and the colours the app has always used. */ +export const SERIES_NAMES = [ + '1st year', '2nd year', '3rd year', '4th year', '5th year', '6th year', 'NLE1', 'NLE2', +]; + +export const DEFAULT_SERIES_COLORS = [ + '#00BCD4', '#FF9800', '#795548', '#9C27B0', '#4CAF50', '#E91E63', '#607D8B', '#FDD835', +]; + +export const DEFAULT_SERIES_FALLBACK = '#808080'; +export const DEFAULT_SERIES_LABEL = '#ffffff'; +export const SERIES_COUNT = SERIES_NAMES.length; + +/** + * A custom theme colours the course groups in shades of its own colour: the lightness runs + * across this range while the hue drifts slightly, so the groups stay apart without + * introducing colours the theme never asked for. + */ +export const SERIES_LIGHTNESS_RANGE: Record = { + light: {from: 0.74, to: 0.26}, + dark: {from: 0.68, to: 0.3}, +}; +export const SERIES_SATURATION: Record = {light: 0.55, dark: 0.5}; +export const SERIES_HUE_DRIFT = 14; + +/** How far the informational tag is blended into the page behind it. */ +export const TAG_SURFACE_WEIGHT = 0.12; + +export const HEATMAP_LEVEL_WEIGHTS = [0.28, 0.5, 0.74, 1]; +export const HEATMAP_EMPTY_WEIGHT = 0.08; +/** Contrast the heatmap's strongest level keeps against the page, so it stays visible. */ +export const MIN_HEATMAP_CONTRAST = 2.5; + +export const THEME_MODES: ThemeModeOption[] = [ + {value: 'light', label: 'Light', description: 'Always light.', icon: 'sunny-outline'}, + {value: 'dark', label: 'Dark', description: 'Always dark.', icon: 'moon-outline'}, + {value: 'system', label: 'System', description: 'Follow your device.', icon: 'phone-portrait-outline'}, + {value: 'custom', label: 'Custom', description: 'Your own colours.', icon: 'color-palette-outline'}, +]; + +export const DEFAULT_MODE = THEME_MODES[2].value; + +/** The modes that are not a custom theme, offered inside the editor as the way back out. */ +export const STANDARD_MODES = THEME_MODES.filter(option => option.value !== 'custom'); + +/** A custom theme always renders light, so a chosen colour shows as itself. */ +export const CUSTOM_SCHEME: ColorScheme = 'light'; + +/** Brand colours taken from the faculty's own sites. */ +export const FACULTY_ACCENT = '#1f6241'; +export const UNIVERSITY_ACCENT = '#e91e90'; +/** The pages the brand colours are drawn on. */ +export const UNIVERSITY_PAGE = '#fdf0f5'; +export const FACULTY_PAGE = '#f2f6f4'; + +/** + * Ready-made colour templates. Each names the page it is drawn on and the colour everything + * else is drawn in, so a pairing can be offered either way round. + */ +export const THEME_TEMPLATES: ThemeTemplate[] = [ + { + id: 'university', + name: 'Pink', + description: 'Pink page with pink buttons.', + seed: {accent: UNIVERSITY_ACCENT, companion: null, tertiary: null, surfaceTint: null, intensity: 1}, + background: UNIVERSITY_PAGE, + }, + { + id: 'faculty', + name: 'Green', + description: 'Green page with green buttons.', + seed: {accent: FACULTY_ACCENT, companion: null, tertiary: null, surfaceTint: null, intensity: 1}, + background: FACULTY_PAGE, + }, + { + id: 'university-faculty', + name: 'Pink & Green', + description: 'Pink page with green buttons.', + seed: { + accent: FACULTY_ACCENT, + companion: null, + tertiary: null, + surfaceTint: UNIVERSITY_ACCENT, + intensity: 1, + }, + background: UNIVERSITY_PAGE, + }, + { + id: 'faculty-university', + name: 'Green & Pink', + description: 'Green page with pink buttons.', + seed: { + accent: UNIVERSITY_ACCENT, + companion: null, + tertiary: null, + surfaceTint: FACULTY_ACCENT, + intensity: 1, + }, + background: FACULTY_PAGE, + }, +]; + +/** Used when the colour comes from the picker rather than a template. */ +export const OWN_COLOR_TEMPLATE_ID = 'own'; +export const DEFAULT_TEMPLATE_ID = THEME_TEMPLATES[0].id; + +/** + * Curated accents for the picker. The brand colours are left out because the templates + * already offer them, which keeps the row to a single line. + */ +export const ACCENT_SWATCHES: string[] = [ + '#3880ff', '#0f766e', '#7c3aed', '#b45309', + '#be123c', '#0369a1', '#4d7c0f', '#475569', +]; + +/** A colour picked in the editor tints the page as fully as a template does. */ +export const OWN_COLOR_INTENSITY = 1; + +export const BACKGROUND_FIT_OPTIONS: BackgroundFitOption[] = [ + {value: 'cover', label: 'Fill'}, + {value: 'contain', label: 'Fit'}, + {value: 'tile', label: 'Tile'}, +]; + +export const DEFAULT_BACKGROUND: ThemeBackground = { + color: null, + imageId: null, + imageOpacity: 0.35, + imageBlur: 2, + imageFit: 'cover', +}; + +/** The seed the standard modes use: no accent, so the base palette renders untouched. */ +export const NEUTRAL_SEED = { + accent: null, + companion: null, + tertiary: null, + surfaceTint: null, + intensity: 0, +}; + +export function defaultCustomTheme(): CustomTheme { + const template = THEME_TEMPLATES[0]; + return { + templateId: template.id, + seed: {...template.seed}, + background: {...DEFAULT_BACKGROUND, color: template.background}, + }; +} + +export function defaultThemeSettings(): ThemeSettings { + return { + mode: DEFAULT_MODE, + custom: defaultCustomTheme(), + updatedAt: 0, + }; +} + +export function findTemplate(id: string): ThemeTemplate | undefined { + return THEME_TEMPLATES.find(template => template.id === id); +} + +export function seriesIndexOf(name: string): number { + return SERIES_NAMES.indexOf(name); +} diff --git a/src/app/theme/theme.model.ts b/src/app/theme/theme.model.ts new file mode 100644 index 0000000..4bf4f0f --- /dev/null +++ b/src/app/theme/theme.model.ts @@ -0,0 +1,87 @@ +/** What the appearance menu offers: the three standard modes plus a colour theme of your own. */ +export type ThemeMode = 'light' | 'dark' | 'system' | 'custom'; +export type ColorScheme = 'light' | 'dark'; +export type SchemePreference = ColorScheme | 'system'; +export type BackgroundFit = 'cover' | 'contain' | 'tile'; + +export const SEMANTIC_ROLES = [ + 'primary', 'secondary', 'tertiary', 'success', 'warning', 'danger', 'dark', 'medium', 'light', +] as const; + +export type SemanticRole = typeof SEMANTIC_ROLES[number]; + +/** + * The few values a colour theme is described by. Everything else in the palette is derived, + * so a theme never has to enumerate colours for individual features. + */ +export interface ThemeSeed { + /** Main colour. When null the scheme's own neutral palette is used unchanged. */ + accent: string | null; + /** Second colour; derived from the accent's hue when null. */ + companion: string | null; + /** Third colour; derived from the companion's hue when null. */ + tertiary: string | null; + /** Colour blended into backgrounds and surfaces; falls back to the accent. */ + surfaceTint: string | null; + /** How strongly the surface tint colours the page. 0 keeps the neutral base palette. */ + intensity: number; +} + +export interface ThemeBackground { + color: string | null; + imageId: string | null; + imageOpacity: number; + imageBlur: number; + imageFit: BackgroundFit; +} + +/** + * Everything the custom mode remembers. The standard modes deliberately carry none of it, + * and a custom theme is always drawn light so a chosen colour shows as itself. + */ +export interface CustomTheme { + templateId: string; + seed: ThemeSeed; + background: ThemeBackground; +} + +export interface ThemeSettings { + mode: ThemeMode; + custom: CustomTheme; + updatedAt: number; +} + +export interface ThemeTemplate { + id: string; + name: string; + description: string; + seed: ThemeSeed; + /** Page colour the template starts from; null lets it be derived from the accent. */ + background: string | null; +} + +export interface ThemeModeOption { + value: ThemeMode; + label: string; + description: string; + icon: string; +} + +export interface BackgroundFitOption { + value: BackgroundFit; + label: string; +} + +/** Neutral palette each theme starts from; the accent is blended into it by intensity. */ +export interface BaseScheme { + background: string; + text: string; + roles: Record; +} + +/** How far each surface sits from the page background, towards the text colour. */ +export interface SurfaceSteps { + card: number; + item: number; + toolbar: number; +} diff --git a/src/app/welcome/welcome.page.html b/src/app/welcome/welcome.page.html index 9b5b603..4c3c4d0 100644 --- a/src/app/welcome/welcome.page.html +++ b/src/app/welcome/welcome.page.html @@ -1,4 +1,7 @@ +
+ +
diff --git a/src/app/welcome/welcome.page.scss b/src/app/welcome/welcome.page.scss index fd92e92..6819e45 100644 --- a/src/app/welcome/welcome.page.scss +++ b/src/app/welcome/welcome.page.scss @@ -1,3 +1,10 @@ +.theme-corner { + position: absolute; + top: 0.5rem; + right: 0.5rem; + z-index: 2; +} + ion-content { ion-card { max-width: 450px; diff --git a/src/app/welcome/welcome.page.ts b/src/app/welcome/welcome.page.ts index e3d9fb9..25bc064 100644 --- a/src/app/welcome/welcome.page.ts +++ b/src/app/welcome/welcome.page.ts @@ -16,6 +16,7 @@ import {HttpErrorResponse} from "@angular/common/http"; import {Subscription} from "rxjs"; import {User} from "@angular/fire/auth"; import {AuthService} from "../auth.service"; +import {ThemeMenuComponent} from "../shared/theme-menu.component"; @Component({ selector: "app-welcome", @@ -29,6 +30,7 @@ import {AuthService} from "../auth.service"; IonCardContent, IonButton, IonText, + ThemeMenuComponent, ], }) export class WelcomePage implements OnInit, OnDestroy { diff --git a/src/global.scss b/src/global.scss index 17b3d0f..5e551c8 100644 --- a/src/global.scss +++ b/src/global.scss @@ -27,6 +27,19 @@ @import "video.js/dist/video-js.css"; +$theme-transition: 0.25s ease; + +body, +ion-content, +ion-header, +ion-toolbar, +ion-card, +ion-item, +ion-list, +.e-learning-tag { + transition: background-color $theme-transition, color $theme-transition, border-color $theme-transition; +} + .small-text { font-size: 0.8em; } @@ -35,19 +48,21 @@ margin-right: 0.2em; padding: 0.15em 0.2em; font-size: 0.8em; - border: #22C55E solid 1px; + border: var(--flick-tag-color) solid 1px; border-radius: 2px; - background-color: #F0FDF4; - color: #22C55E; + background-color: var(--flick-tag-background); + color: var(--flick-tag-color); display: inline-block; } -@media (prefers-color-scheme: dark) { - .e-learning-tag { - border: #15803D solid 1px; - background-color: #052E16; - color: #15803D; - } +ion-card:not(.ion-color) { + color: var(--flick-muted-text); +} + +/* An item inside a coloured card keeps its own background, so it needs the page's text + colour rather than the card's contrast colour, which is meant for the card itself. */ +ion-card.ion-color ion-item { + --color: var(--ion-text-color); } /* Fullscreen Pomodoro Notification Toast (Appended inside video fullscreen element) */ @@ -57,13 +72,13 @@ left: 50%; transform: translateX(-50%); z-index: 2147483647; - background: rgba(34, 36, 40, 0.95); - color: #ffffff; + background: var(--ion-color-dark); + color: var(--ion-color-dark-contrast); padding: 0.75rem 1.5rem; border-radius: 2rem; font-size: 1.1rem; font-weight: 600; - border: 1px solid rgba(255, 255, 255, 0.25); + border: 1px solid rgba(var(--ion-color-dark-contrast-rgb), 0.25); box-shadow: 0 4px 24px rgba(0, 0, 0, 0.6); pointer-events: none; opacity: 0; @@ -74,3 +89,38 @@ opacity: 1; transform: translate(-50%, 0); } + +/* + * Custom page background. The picture and its scrim sit behind the routed page, which + * becomes transparent so the image shows through while toolbars and cards stay readable. + */ +body.flick-has-background-image { + ion-app::before, + ion-app::after { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + } + + ion-app::before { + background-image: var(--flick-background-image); + background-size: var(--flick-background-size); + background-repeat: var(--flick-background-repeat); + background-position: center center; + filter: blur(var(--flick-background-blur)); + transform: scale(1.06); + } + + ion-app::after { + background-color: var(--flick-background-scrim); + } + + ion-router-outlet { + z-index: 1; + } + + ion-content { + --background: transparent; + } +} diff --git a/src/helpers.ts b/src/helpers.ts index e07f4e0..3015581 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,13 +1,15 @@ +import {seriesIndexOf} from './app/theme/theme-presets'; + +function seriesToken(name: string): string { + const index = seriesIndexOf(name); + return index < 0 ? 'fallback' : String(index); +} + export function colorByFolderName(name: string) { - const colorMap = { - '1st year': '#00BCD4', - '2nd year': '#FF9800', - '3rd year': '#795548', - '4th year': '#9C27B0', - '5th year': '#4CAF50', - '6th year': '#E91E63', - 'NLE1': '#607D8B', - 'NLE2': '#FDD835' - }; - return colorMap[name] || 'gray'; + return `var(--flick-series-${seriesToken(name)})`; +} + +/** Text colour that reads on the group's own colour. */ +export function contrastByFolderName(name: string) { + return `var(--flick-series-${seriesToken(name)}-contrast)`; } diff --git a/src/index.html b/src/index.html index bbcc773..766fa50 100644 --- a/src/index.html +++ b/src/index.html @@ -7,6 +7,27 @@ + + + diff --git a/src/main.ts b/src/main.ts index dddcbfd..c7949f0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import {enableProdMode, importProvidersFrom} from '@angular/core'; +import {enableProdMode, importProvidersFrom, inject, provideAppInitializer} from '@angular/core'; import {environment} from './environments/environment'; import {RouteReuseStrategy} from '@angular/router'; @@ -21,6 +21,7 @@ import {WelcomePageModule} from './app/welcome/welcome.module'; import {ServiceWorkerModule} from '@angular/service-worker'; import {AppComponent} from './app/app.component'; import Aura from '@primeuix/themes/aura'; +import {ThemeService} from './app/theme.service'; if (environment.production) { enableProdMode(); @@ -30,6 +31,10 @@ bootstrapApplication(AppComponent, { providers: [ importProvidersFrom(BrowserModule, AppRoutingModule, WelcomePageModule, ServiceWorkerModule.register('ngsw-worker.js', {enabled: environment.production})), {provide: RouteReuseStrategy, useClass: IonicRouteStrategy}, + // Start theming before the first page renders, so the saved theme is live from the outset. + provideAppInitializer(() => { + inject(ThemeService); + }), UserTrackingService, {provide: INSTRUMENTATION_ENABLED, useValue: environment.production}, {provide: DEBUG_MODE, useValue: !environment.production}, diff --git a/src/theme/variables.scss b/src/theme/variables.scss index e111d61..37ca4e4 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -1,9 +1,11 @@ // Ionic Variables and Theming. For more info, please see: // http://ionicframework.com/docs/theming/ +// +// These are the palettes the app falls back to before ThemeService has run. The service +// writes the same variables onto the document, derived from the user's theme, so anything +// added here must be a value a theme can also produce. -/** Ionic CSS Variables **/ :root { - /** primary **/ --ion-color-primary: #3880ff; --ion-color-primary-rgb: 56, 128, 255; --ion-color-primary-contrast: #ffffff; @@ -11,7 +13,6 @@ --ion-color-primary-shade: #3171e0; --ion-color-primary-tint: #4c8dff; - /** secondary **/ --ion-color-secondary: #3dc2ff; --ion-color-secondary-rgb: 61, 194, 255; --ion-color-secondary-contrast: #ffffff; @@ -19,7 +20,6 @@ --ion-color-secondary-shade: #36abe0; --ion-color-secondary-tint: #50c8ff; - /** tertiary **/ --ion-color-tertiary: #5260ff; --ion-color-tertiary-rgb: 82, 96, 255; --ion-color-tertiary-contrast: #ffffff; @@ -27,7 +27,6 @@ --ion-color-tertiary-shade: #4854e0; --ion-color-tertiary-tint: #6370ff; - /** success **/ --ion-color-success: #2dd36f; --ion-color-success-rgb: 45, 211, 111; --ion-color-success-contrast: #ffffff; @@ -35,7 +34,6 @@ --ion-color-success-shade: #28ba62; --ion-color-success-tint: #42d77d; - /** warning **/ --ion-color-warning: #ffc409; --ion-color-warning-rgb: 255, 196, 9; --ion-color-warning-contrast: #000000; @@ -43,7 +41,6 @@ --ion-color-warning-shade: #e0ac08; --ion-color-warning-tint: #ffca22; - /** danger **/ --ion-color-danger: #eb445a; --ion-color-danger-rgb: 235, 68, 90; --ion-color-danger-contrast: #ffffff; @@ -51,7 +48,6 @@ --ion-color-danger-shade: #cf3c4f; --ion-color-danger-tint: #ed576b; - /** dark **/ --ion-color-dark: #222428; --ion-color-dark-rgb: 34, 36, 40; --ion-color-dark-contrast: #ffffff; @@ -59,7 +55,6 @@ --ion-color-dark-shade: #1e2023; --ion-color-dark-tint: #383a3e; - /** medium **/ --ion-color-medium: #92949c; --ion-color-medium-rgb: 146, 148, 156; --ion-color-medium-contrast: #ffffff; @@ -67,163 +62,140 @@ --ion-color-medium-shade: #808289; --ion-color-medium-tint: #9d9fa6; - /** light **/ --ion-color-light: #f4f5f8; --ion-color-light-rgb: 244, 245, 248; --ion-color-light-contrast: #000000; --ion-color-light-contrast-rgb: 0, 0, 0; --ion-color-light-shade: #d7d8da; --ion-color-light-tint: #f5f6f9; + + --ion-background-color: #ffffff; + --ion-background-color-rgb: 255, 255, 255; + --ion-text-color: #000000; + --ion-text-color-rgb: 0, 0, 0; + --ion-toolbar-background: #ffffff; + --ion-item-background: #ffffff; + --ion-card-background: #ffffff; + + --flick-accent: #3880ff; + --flick-accent-text: #0055d4; + --flick-companion-text: #007ba8; + --flick-warning-text: #8a6a00; + --flick-muted-text: #737373; + --flick-surface: #ffffff; + --flick-surface-muted: #ebebeb; + --flick-tag-color: #1a7f45; + --flick-tag-background: #e8f6ee; + + --flick-background-image: none; + --flick-background-scrim: rgba(255, 255, 255, 0.65); + --flick-background-blur: 2px; + --flick-background-size: cover; + --flick-background-repeat: no-repeat; } -@media (prefers-color-scheme: dark) { - /* - * Dark Colors - * ------------------------------------------- - */ - - body { - --ion-color-primary: #428cff; - --ion-color-primary-rgb: 66,140,255; - --ion-color-primary-contrast: #ffffff; - --ion-color-primary-contrast-rgb: 255,255,255; - --ion-color-primary-shade: #3a7be0; - --ion-color-primary-tint: #5598ff; - - --ion-color-secondary: #50c8ff; - --ion-color-secondary-rgb: 80,200,255; - --ion-color-secondary-contrast: #ffffff; - --ion-color-secondary-contrast-rgb: 255,255,255; - --ion-color-secondary-shade: #46b0e0; - --ion-color-secondary-tint: #62ceff; - - --ion-color-tertiary: #6a64ff; - --ion-color-tertiary-rgb: 106,100,255; - --ion-color-tertiary-contrast: #ffffff; - --ion-color-tertiary-contrast-rgb: 255,255,255; - --ion-color-tertiary-shade: #5d58e0; - --ion-color-tertiary-tint: #7974ff; - - --ion-color-success: #2fdf75; - --ion-color-success-rgb: 47,223,117; - --ion-color-success-contrast: #000000; - --ion-color-success-contrast-rgb: 0,0,0; - --ion-color-success-shade: #29c467; - --ion-color-success-tint: #44e283; - - --ion-color-warning: #ffd534; - --ion-color-warning-rgb: 255,213,52; - --ion-color-warning-contrast: #000000; - --ion-color-warning-contrast-rgb: 0,0,0; - --ion-color-warning-shade: #e0bb2e; - --ion-color-warning-tint: #ffd948; - - --ion-color-danger: #ff4961; - --ion-color-danger-rgb: 255,73,97; - --ion-color-danger-contrast: #ffffff; - --ion-color-danger-contrast-rgb: 255,255,255; - --ion-color-danger-shade: #e04055; - --ion-color-danger-tint: #ff5b71; - - --ion-color-dark: #f4f5f8; - --ion-color-dark-rgb: 244,245,248; - --ion-color-dark-contrast: #000000; - --ion-color-dark-contrast-rgb: 0,0,0; - --ion-color-dark-shade: #d7d8da; - --ion-color-dark-tint: #f5f6f9; - - --ion-color-medium: #989aa2; - --ion-color-medium-rgb: 152,154,162; - --ion-color-medium-contrast: #000000; - --ion-color-medium-contrast-rgb: 0,0,0; - --ion-color-medium-shade: #86888f; - --ion-color-medium-tint: #a2a4ab; - - --ion-color-light: #222428; - --ion-color-light-rgb: 34,36,40; - --ion-color-light-contrast: #ffffff; - --ion-color-light-contrast-rgb: 255,255,255; - --ion-color-light-shade: #1e2023; - --ion-color-light-tint: #383a3e; - } - - /* - * iOS Dark Theme - * ------------------------------------------- - */ - - .ios body { - --ion-background-color: #000000; - --ion-background-color-rgb: 0,0,0; - - --ion-text-color: #ffffff; - --ion-text-color-rgb: 255,255,255; - - --ion-color-step-50: #0d0d0d; - --ion-color-step-100: #1a1a1a; - --ion-color-step-150: #262626; - --ion-color-step-200: #333333; - --ion-color-step-250: #404040; - --ion-color-step-300: #4d4d4d; - --ion-color-step-350: #595959; - --ion-color-step-400: #666666; - --ion-color-step-450: #737373; - --ion-color-step-500: #808080; - --ion-color-step-550: #8c8c8c; - --ion-color-step-600: #999999; - --ion-color-step-650: #a6a6a6; - --ion-color-step-700: #b3b3b3; - --ion-color-step-750: #bfbfbf; - --ion-color-step-800: #cccccc; - --ion-color-step-850: #d9d9d9; - --ion-color-step-900: #e6e6e6; - --ion-color-step-950: #f2f2f2; - - --ion-toolbar-background: #0d0d0d; - - --ion-item-background: #1c1c1c; - --ion-item-background-activated: #313131; - } - - - /* - * Material Design Dark Theme - * ------------------------------------------- - */ - - .md body { - --ion-background-color: #121212; - --ion-background-color-rgb: 18,18,18; - - --ion-text-color: #ffffff; - --ion-text-color-rgb: 255,255,255; - - --ion-border-color: #222222; - - --ion-color-step-50: #1e1e1e; - --ion-color-step-100: #2a2a2a; - --ion-color-step-150: #363636; - --ion-color-step-200: #414141; - --ion-color-step-250: #4d4d4d; - --ion-color-step-300: #595959; - --ion-color-step-350: #656565; - --ion-color-step-400: #717171; - --ion-color-step-450: #7d7d7d; - --ion-color-step-500: #898989; - --ion-color-step-550: #949494; - --ion-color-step-600: #a0a0a0; - --ion-color-step-650: #acacac; - --ion-color-step-700: #b8b8b8; - --ion-color-step-750: #c4c4c4; - --ion-color-step-800: #d0d0d0; - --ion-color-step-850: #dbdbdb; - --ion-color-step-900: #e7e7e7; - --ion-color-step-950: #f3f3f3; - - --ion-item-background: #1A1B1E; - } - - ion-title.title-large { - --color: white; - } +:root[data-theme='dark'] { + --ion-color-primary: #428cff; + --ion-color-primary-rgb: 66, 140, 255; + --ion-color-primary-contrast: #ffffff; + --ion-color-primary-contrast-rgb: 255, 255, 255; + --ion-color-primary-shade: #3a7be0; + --ion-color-primary-tint: #5598ff; + + --ion-color-secondary: #50c8ff; + --ion-color-secondary-rgb: 80, 200, 255; + --ion-color-secondary-contrast: #ffffff; + --ion-color-secondary-contrast-rgb: 255, 255, 255; + --ion-color-secondary-shade: #46b0e0; + --ion-color-secondary-tint: #62ceff; + + --ion-color-tertiary: #6a64ff; + --ion-color-tertiary-rgb: 106, 100, 255; + --ion-color-tertiary-contrast: #ffffff; + --ion-color-tertiary-contrast-rgb: 255, 255, 255; + --ion-color-tertiary-shade: #5d58e0; + --ion-color-tertiary-tint: #7974ff; + + --ion-color-success: #2fdf75; + --ion-color-success-rgb: 47, 223, 117; + --ion-color-success-contrast: #000000; + --ion-color-success-contrast-rgb: 0, 0, 0; + --ion-color-success-shade: #29c467; + --ion-color-success-tint: #44e283; + + --ion-color-warning: #ffd534; + --ion-color-warning-rgb: 255, 213, 52; + --ion-color-warning-contrast: #000000; + --ion-color-warning-contrast-rgb: 0, 0, 0; + --ion-color-warning-shade: #e0bb2e; + --ion-color-warning-tint: #ffd948; + + --ion-color-danger: #ff4961; + --ion-color-danger-rgb: 255, 73, 97; + --ion-color-danger-contrast: #ffffff; + --ion-color-danger-contrast-rgb: 255, 255, 255; + --ion-color-danger-shade: #e04055; + --ion-color-danger-tint: #ff5b71; + + --ion-color-dark: #f4f5f8; + --ion-color-dark-rgb: 244, 245, 248; + --ion-color-dark-contrast: #000000; + --ion-color-dark-contrast-rgb: 0, 0, 0; + --ion-color-dark-shade: #d7d8da; + --ion-color-dark-tint: #f5f6f9; + + --ion-color-medium: #989aa2; + --ion-color-medium-rgb: 152, 154, 162; + --ion-color-medium-contrast: #000000; + --ion-color-medium-contrast-rgb: 0, 0, 0; + --ion-color-medium-shade: #86888f; + --ion-color-medium-tint: #a2a4ab; + + --ion-color-light: #222428; + --ion-color-light-rgb: 34, 36, 40; + --ion-color-light-contrast: #ffffff; + --ion-color-light-contrast-rgb: 255, 255, 255; + --ion-color-light-shade: #1e2023; + --ion-color-light-tint: #383a3e; + + --ion-background-color: #121212; + --ion-background-color-rgb: 18, 18, 18; + --ion-text-color: #ffffff; + --ion-text-color-rgb: 255, 255, 255; + --ion-border-color: #363636; + --ion-toolbar-background: #121212; + --ion-item-background: #1a1b1e; + --ion-card-background: #1e1e1e; + + --ion-color-step-50: #1e1e1e; + --ion-color-step-100: #2a2a2a; + --ion-color-step-150: #363636; + --ion-color-step-200: #414141; + --ion-color-step-250: #4d4d4d; + --ion-color-step-300: #595959; + --ion-color-step-350: #656565; + --ion-color-step-400: #717171; + --ion-color-step-450: #7d7d7d; + --ion-color-step-500: #898989; + --ion-color-step-550: #949494; + --ion-color-step-600: #a0a0a0; + --ion-color-step-650: #acacac; + --ion-color-step-700: #b8b8b8; + --ion-color-step-750: #c4c4c4; + --ion-color-step-800: #d0d0d0; + --ion-color-step-850: #dbdbdb; + --ion-color-step-900: #e7e7e7; + --ion-color-step-950: #f3f3f3; + + --flick-accent: #428cff; + --flick-accent-text: #428cff; + --flick-companion-text: #50c8ff; + --flick-warning-text: #ffd534; + --flick-muted-text: #8c8c8c; + --flick-surface: #1e1e1e; + --flick-surface-muted: #252525; + --flick-tag-color: #2fdf75; + --flick-tag-background: #1c2b22; + + --flick-background-scrim: rgba(18, 18, 18, 0.65); } From 3e6e9a6f15577db2b75ee78d7d93730f69c49c7c Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Sat, 5 Sep 2026 16:45:17 +0700 Subject: [PATCH 02/14] feat: add theme shades, saved colours and smooth transitions - Light, dark and fill shades for a custom theme, remembered per colour and template - Save and delete colours on the device, beside the pre-made templates - Pin the pre-made pink theme to its exact palette as template data - Softer course colours, with one label colour across the row - Animate theme changes on a single clock by registering the colour variables - Colour ion-list-header from the theme - Fix contrast helpers moving the wrong way, and judging contrast before rounding - Fix flat surfaces on a page filled with the theme colour --- src/app/home/home.page.scss | 2 +- src/app/shared/theme-editor.component.html | 60 +++- src/app/shared/theme-editor.component.scss | 41 +++ src/app/shared/theme-editor.component.ts | 49 ++- src/app/theme.service.ts | 384 +++++++++++++++++++-- src/app/theme/color.ts | 40 ++- src/app/theme/palette.ts | 111 +++++- src/app/theme/theme-presets.ts | 174 +++++++++- src/app/theme/theme.model.ts | 33 +- src/global.scss | 99 +++++- 10 files changed, 901 insertions(+), 92 deletions(-) diff --git a/src/app/home/home.page.scss b/src/app/home/home.page.scss index aadba50..4205b41 100644 --- a/src/app/home/home.page.scss +++ b/src/app/home/home.page.scss @@ -167,4 +167,4 @@ p { font-size: 0.7rem; font-weight: 600; letter-spacing: 0.02em; -} \ No newline at end of file +} diff --git a/src/app/shared/theme-editor.component.html b/src/app/shared/theme-editor.component.html index ad1cdad..526473c 100644 --- a/src/app/shared/theme-editor.component.html +++ b/src/app/shared/theme-editor.component.html @@ -33,10 +33,41 @@

Pre-made templates

[attr.aria-label]="'Use colour ' + swatch" (click)="selectAccent(swatch)"> } +
+ + +
+

Saved colours

+

Colours you keep, on this device.

+
+ @for (colour of savedColors$ | async; track colour) { + + + + + } @empty { +

Nothing saved yet.

+ } +
@@ -49,11 +80,6 @@

Primary colour

{{ backgroundValue(settings) }} - @if (settings.custom.background.color) { - - Match secondary - - } @@ -70,9 +96,25 @@

Secondary colour

-

Light or dark

+

Shade

+

+ Your colour is shown just as you picked it in the shade it belongs to. The other two + adapt it: a tinted light page, or a filled dark one. +

+ + @for (option of themeService.shades; track option.value) { + + + {{ option.label }} + + } + +
+ +
+

Plain theme

Leaves your colours behind and goes back to the plain theme.

- + @for (option of themeService.standardModes; track option.value) { @@ -126,6 +168,8 @@

Background picture

}
- Start over + + Reset theme preference + }
diff --git a/src/app/shared/theme-editor.component.scss b/src/app/shared/theme-editor.component.scss index 19e9c0f..7c865ea 100644 --- a/src/app/shared/theme-editor.component.scss +++ b/src/app/shared/theme-editor.component.scss @@ -150,3 +150,44 @@ ion-segment-button { --color-checked: var(--flick-accent-text); --indicator-color: var(--flick-accent-text); } + +.saved-swatch { + position: relative; + display: inline-flex; +} + +.swatch-remove { + position: absolute; + top: -0.2rem; + inset-inline-end: -0.2rem; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.1rem; + height: 1.1rem; + padding: 0; + font-size: 0.65rem; + border: 1px solid var(--ion-border-color, var(--ion-color-step-150)); + border-radius: 50%; + background: var(--ion-background-color); + color: var(--flick-muted-text); + cursor: pointer; +} + +.swatch-save { + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--flick-surface-muted); + color: var(--ion-text-color); +} + +.swatch-save[disabled] { + opacity: 0.45; + cursor: default; +} + +.saved-empty { + margin: 0; + align-self: center; +} diff --git a/src/app/shared/theme-editor.component.ts b/src/app/shared/theme-editor.component.ts index 2a9fa36..304c07f 100644 --- a/src/app/shared/theme-editor.component.ts +++ b/src/app/shared/theme-editor.component.ts @@ -17,10 +17,19 @@ import { ModalController, } from '@ionic/angular/standalone'; import {addIcons} from 'ionicons'; -import {close, colorPaletteOutline, imageOutline, moonOutline, phonePortraitOutline, sunnyOutline} from 'ionicons/icons'; +import { + add, + close, + colorFillOutline, + colorPaletteOutline, + imageOutline, + moonOutline, + phonePortraitOutline, + sunnyOutline, +} from 'ionicons/icons'; import {OWN_COLOR_TEMPLATE_ID, ThemeService} from '../theme.service'; import {ACCENT_SWATCHES} from '../theme/theme-presets'; -import {BackgroundFit, ThemeMode, ThemeSettings, ThemeTemplate} from '../theme/theme.model'; +import {BackgroundFit, ThemeMode, ThemeSettings, ThemeShade, ThemeTemplate} from '../theme/theme.model'; function detailValue(event: Event): T | undefined { return (event as CustomEvent<{value?: T}>).detail?.value; @@ -44,10 +53,14 @@ export class ThemeEditorComponent { protected readonly accentSwatches = ACCENT_SWATCHES; protected readonly settings$ = this.themeService.settings$; protected readonly backgroundImageUrl$ = this.themeService.backgroundImageUrl$; + protected readonly savedColors$ = this.themeService.savedColors$; protected imageError: string | null = null; constructor() { - addIcons({close, colorPaletteOutline, imageOutline, sunnyOutline, moonOutline, phonePortraitOutline}); + addIcons({ + add, close, colorFillOutline, colorPaletteOutline, imageOutline, + sunnyOutline, moonOutline, phonePortraitOutline, + }); } close(): void { @@ -62,9 +75,29 @@ export class ThemeEditorComponent { this.themeService.setAccent(accent); } + saveColour(settings: ThemeSettings): void { + this.themeService.saveColor(this.accentValue(settings)); + } + + removeColour(colour: string): void { + this.themeService.removeColor(colour); + } + + isSaved(settings: ThemeSettings): boolean { + return this.themeService.isColorSaved(this.accentValue(settings)); + } + + onShadeChange(event: Event): void { + const value = detailValue(event); + if (value) { + this.themeService.setCustomShade(value); + } + } + reset(): void { this.imageError = null; - this.themeService.resetCustom(); + this.themeService.resetToDefault(); + this.close(); } /** A template reads as its page colour beside the colour everything else is drawn in. */ @@ -87,8 +120,7 @@ export class ThemeEditorComponent { } backgroundValue(settings: ThemeSettings): string { - return settings.custom.background.color - ?? this.themeService.preview(settings.custom.seed)['--ion-background-color']; + return this.themeService.customPageColor(settings.custom); } /** The plain modes replace the custom theme rather than recolouring it. */ @@ -96,7 +128,6 @@ export class ThemeEditorComponent { const value = detailValue(event); if (value) { this.themeService.setMode(value); - this.close(); } } @@ -129,10 +160,6 @@ export class ThemeEditorComponent { this.themeService.setBackgroundColor((event.target as HTMLInputElement).value); } - clearBackgroundColor(): void { - this.themeService.setBackgroundColor(null); - } - pickImage(): void { this.imageError = null; this.fileInput?.nativeElement.click(); diff --git a/src/app/theme.service.ts b/src/app/theme.service.ts index 55be7a6..7116fbc 100644 --- a/src/app/theme.service.ts +++ b/src/app/theme.service.ts @@ -3,16 +3,19 @@ import {BehaviorSubject, Observable} from 'rxjs'; import {distinctUntilChanged, map} from 'rxjs/operators'; import {ulid} from 'ulid'; import {AuthService} from './auth.service'; -import {isValidColor} from './theme/color'; -import {buildThemeVariables, CssVariables} from './theme/palette'; +import {BLACK, isValidColor, parseColor, readableOn, toHex} from './theme/color'; +import {buildThemeVariables, CssVariables, shadeForColor} from './theme/palette'; import { BACKGROUND_FIT_OPTIONS, DEFAULT_BACKGROUND, defaultCustomTheme, defaultThemeSettings, findTemplate, - CUSTOM_SCHEME, + DEFAULT_CUSTOM_SHADE, + THEME_SHADES, NEUTRAL_SEED, + MAX_REMEMBERED_SHADES, + MAX_SAVED_COLORS, OWN_COLOR_INTENSITY, STANDARD_MODES, OWN_COLOR_TEMPLATE_ID, @@ -23,6 +26,9 @@ import {BackgroundImageStore, prepareBackgroundImage} from './theme/background-s import { BackgroundFit, ColorScheme, + ThemeShade, + ThemeTemplate, + ThemeVariables, CustomTheme, SchemePreference, ThemeBackground, @@ -35,13 +41,70 @@ import { /** Mirrors the last applied palette so index.html can paint it before Angular boots. */ export const APPLIED_THEME_KEY = 'flickThemeApplied'; export const THEME_STORAGE_KEY_PREFIX = 'flickTheme_'; +/** Colours the reader has kept. They stay on this device, like the background picture. */ +export const SAVED_COLORS_KEY_PREFIX = 'flickThemeColors_'; +/** The shade last chosen for each colour and template. Kept on the device, like the rest. */ +export const SHADE_MEMORY_KEY_PREFIX = 'flickThemeShades_'; export const BACKGROUND_IMAGE_CLASS = 'flick-has-background-image'; +/** + * Set on the document element for the length of a change of theme. The colour variables are + * interpolated while it is present, so every surface reading them moves together instead of + * each component fading on whatever timing its own stylesheet happens to carry. + */ +export const THEME_ANIMATING_CLASS = 'flick-theme-animating'; +/** Kept in step with --flick-theme-transition-duration in the global stylesheet. */ +export const THEME_TRANSITION_MS = 280; +/** Kept in step with --flick-theme-transition-easing in the global stylesheet. */ +const THEME_TRANSITION_EASING: [number, number, number, number] = [0.4, 0, 0.2, 1]; + +/** + * Ionic draws some borders and overlays as rgba(var(--…-rgb), alpha). A channel triplet is a + * comma list rather than a colour, so CSS cannot interpolate it and it would jump from black + * to white the instant a theme changed, drawing a hard line across a page whose every other + * colour was still moving. These are moved by hand instead, on the same curve and clock. + */ +function parseTriplet(value: string): [number, number, number] | null { + const parts = value.split(',').map(part => Number(part.trim())); + return parts.length === 3 && parts.every(part => Number.isFinite(part)) + ? [parts[0], parts[1], parts[2]] + : null; +} + +function bezierAxis(t: number, a: number, b: number): number { + return 3 * (1 - t) * (1 - t) * t * a + 3 * (1 - t) * t * t * b + t * t * t; +} + +/** The y of a cubic bezier at a given x, solved closely enough for a colour channel. */ +function easeWith(progress: number, [x1, y1, x2, y2]: [number, number, number, number]): number { + let low = 0; + let high = 1; + let t = progress; + for (let i = 0; i < 12; i++) { + const x = bezierAxis(t, x1, x2); + if (x < progress) { + low = t; + } else { + high = t; + } + t = (low + high) / 2; + } + return bezierAxis(t, y1, y2); +} const GUEST_ID = 'guest'; function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } +/** One spelling per colour, so the saved list cannot hold the same colour twice. */ +function normalizeColor(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + const parsed = parseColor(value); + return parsed ? toHex(parsed) : null; +} + function sanitizeColor(value: unknown): string | null { return typeof value === 'string' && isValidColor(value) ? value : null; } @@ -71,6 +134,9 @@ function sanitizeCustom(raw: unknown): CustomTheme { const storedBackground = !!value.background; return { templateId, + shade: THEME_SHADES.some(option => option.value === value.shade) + ? value.shade as ThemeShade + : fallback.shade, seed: template ? {...template.seed} : sanitizeSeed(value.seed), background: { color: storedBackground ? sanitizeColor(background.color) : (template?.background ?? null), @@ -106,15 +172,18 @@ export class ThemeService { readonly modes = THEME_MODES; readonly templates = THEME_TEMPLATES; readonly standardModes = STANDARD_MODES; + readonly shades = THEME_SHADES; readonly backgroundFitOptions = BACKGROUND_FIT_OPTIONS; private readonly settingsSubject = new BehaviorSubject(defaultThemeSettings()); private readonly schemeSubject = new BehaviorSubject('light'); private readonly imageUrlSubject = new BehaviorSubject(null); + private readonly savedColorsSubject = new BehaviorSubject([]); readonly settings$: Observable = this.settingsSubject.asObservable(); readonly scheme$: Observable = this.schemeSubject.asObservable(); readonly backgroundImageUrl$: Observable = this.imageUrlSubject.asObservable(); + readonly savedColors$: Observable = this.savedColorsSubject.asObservable(); readonly mode$: Observable = this.settings$.pipe( map(settings => settings.mode), distinctUntilChanged(), @@ -124,6 +193,10 @@ export class ThemeService { ); private userId = GUEST_ID; + private shadeMemory: [string, ThemeShade][] = []; + private painted = false; + private animationTimer: number | null = null; + private tweenFrame: number | null = null; private objectUrl: string | null = null; private darkQuery: MediaQueryList | null = null; @@ -134,10 +207,18 @@ export class ThemeService { this.darkQuery?.addEventListener('change', onSystemChange); destroyRef.onDestroy(() => { this.darkQuery?.removeEventListener('change', onSystemChange); + if (this.animationTimer !== null) { + clearTimeout(this.animationTimer); + } + if (this.tweenFrame !== null) { + cancelAnimationFrame(this.tweenFrame); + } this.releaseObjectUrl(); }); this.apply(this.readLocal(GUEST_ID), false); + this.savedColorsSubject.next(this.readSavedColors(GUEST_ID)); + this.shadeMemory = this.readShadeMemory(GUEST_ID); inject(AuthService).user.subscribe(user => { if (user?.uid) { @@ -164,23 +245,35 @@ export class ThemeService { this.update({mode}); } + /** + * A template starts light, the shade it states its own colours for, unless a shade has + * since been chosen for it. + */ selectTemplate(templateId: string): void { const template = findTemplate(templateId); - if (template) { - this.updateCustom({ - templateId: template.id, - seed: {...template.seed}, - background: {...this.settings.custom.background, color: template.background}, - }); + if (!template) { + return; } + const shade = this.rememberedShade(templateId) ?? DEFAULT_CUSTOM_SHADE; + this.updateCustom({ + templateId: template.id, + shade, + seed: {...template.seed}, + background: {...this.settings.custom.background, color: this.pageForTemplate(template, shade)}, + }); } + /** + * A colour lands on the shade last chosen for it, or the shade it belongs to when there is + * none, so it appears as it was last seen rather than on the previous theme's page. + */ setAccent(accent: string): void { if (!isValidColor(accent)) { return; } this.updateCustom({ templateId: OWN_COLOR_TEMPLATE_ID, + shade: this.rememberedShade(normalizeColor(accent)) ?? shadeForColor(accent), seed: { accent, companion: null, @@ -188,6 +281,21 @@ export class ThemeService { surfaceTint: null, intensity: OWN_COLOR_INTENSITY, }, + background: {...this.settings.custom.background, color: null}, + }); + } + + /** The page is let go with the shade, so it is derived for the new one. */ + setCustomShade(shade: ThemeShade): void { + const custom = this.settings.custom; + this.rememberShade(custom, shade); + const template = findTemplate(custom.templateId); + this.updateCustom({ + shade, + background: { + ...custom.background, + color: template ? this.pageForTemplate(template, shade) : null, + }, }); } @@ -227,13 +335,71 @@ export class ThemeService { this.updateCustom({background: {...this.settings.custom.background, imageId: null}}); } - resetCustom(): void { - this.update({custom: defaultCustomTheme()}); + get savedColors(): string[] { + return this.savedColorsSubject.value; + } + + isColorSaved(color: string): boolean { + const normalized = normalizeColor(color); + return !!normalized && this.savedColors.includes(normalized); + } + + /** Keep a colour for later. The newest sits first, and the oldest falls off the end. */ + saveColor(color: string): void { + const normalized = normalizeColor(color); + if (!normalized) { + return; + } + const kept = [normalized, ...this.savedColors.filter(saved => saved !== normalized)] + .slice(0, MAX_SAVED_COLORS); + this.savedColorsSubject.next(kept); + this.writeSavedColors(kept); + } + + removeColor(color: string): void { + const normalized = normalizeColor(color); + if (!normalized) { + return; + } + const kept = this.savedColors.filter(saved => saved !== normalized); + this.savedColorsSubject.next(kept); + this.writeSavedColors(kept); + } + + /** The page the custom theme renders, whether it was named or derived. */ + customPageColor(custom: CustomTheme): string { + return custom.background.color + ?? buildThemeVariables( + custom.seed, + this.schemeFor(custom, custom.background), + custom.background, + this.templateVariables(custom), + custom.shade, + )['--ion-background-color']; + } + + /** + * Which base palette the theme builds on. Light and dark say so; filled it follows the + * page itself, so a colour kept dark keeps dark surfaces and course colours. + */ + private schemeFor(custom: CustomTheme, background: ThemeBackground): ColorScheme { + if (custom.shade !== 'fill') { + return custom.shade; + } + const page = background.color ?? custom.seed.surfaceTint ?? custom.seed.accent; + const parsed = page ? parseColor(page) : null; + return parsed && readableOn(parsed) === BLACK ? 'light' : 'dark'; + } + + resetToDefault(): void { + this.update(defaultThemeSettings()); } /** Palette for a seed without applying it, used to preview a template in the picker. */ - preview(seed: ThemeSeed, scheme = this.scheme): CssVariables { - return buildThemeVariables(seed, scheme, DEFAULT_BACKGROUND); + preview(seed: ThemeSeed, shade: ThemeShade = this.settings.custom.shade): CssVariables { + const custom = {...this.settings.custom, seed, shade, background: {...DEFAULT_BACKGROUND}}; + return buildThemeVariables( + seed, this.schemeFor(custom, custom.background), custom.background, undefined, shade); } private updateCustom(change: Partial): void { @@ -245,30 +411,70 @@ export class ThemeService { } /** The standard modes deliberately carry no colour, picture or background of their own. */ - private resolve(settings: ThemeSettings): {scheme: ColorScheme, seed: ThemeSeed, background: ThemeBackground} { + private resolve(settings: ThemeSettings): { + scheme: ColorScheme, + shade: ThemeShade, + seed: ThemeSeed, + background: ThemeBackground, + variables?: ThemeVariables, + } { if (settings.mode === 'custom') { + const custom = settings.custom; return { - scheme: CUSTOM_SCHEME, - seed: settings.custom.seed, - background: settings.custom.background, + scheme: this.schemeFor(custom, custom.background), + shade: custom.shade, + seed: custom.seed, + background: custom.background, + variables: this.templateVariables(custom), }; } + const scheme = this.resolveScheme(settings.mode); return { - scheme: this.resolveScheme(settings.mode), + scheme, + shade: scheme, seed: {...NEUTRAL_SEED}, background: {...DEFAULT_BACKGROUND}, }; } + /** + * A template's stated colours hold while its own colours are still in place. Once the + * theme is adjusted in the editor they are left behind, so what was chosen is what the + * palette is derived from. + */ + private templateVariables(custom: CustomTheme): ThemeVariables | undefined { + const template = findTemplate(custom.templateId); + if (!template?.variables) { + return undefined; + } + const keys = Object.keys(template.seed) as (keyof ThemeSeed)[]; + const untouched = keys.every(key => custom.seed[key] === template.seed[key]) + && custom.background.color === template.background + && custom.shade === DEFAULT_CUSTOM_SHADE; + return untouched ? template.variables : undefined; + } + private apply(settings: ThemeSettings, persist: boolean): void { - const {scheme, seed, background} = this.resolve(settings); - const variables = buildThemeVariables(seed, scheme, background); + const resolved = this.resolve(settings); + const {scheme, seed, background} = resolved; + const variables = buildThemeVariables(seed, scheme, background, resolved.variables, resolved.shade); + + // The first paint is the colours arriving, not changing, so it is not animated. + const animate = this.painted; + if (animate) { + this.animateChange(); + } + this.painted = true; const root = document.documentElement; + const previous = animate ? this.readTriplets(root, variables) : null; root.setAttribute('data-theme', scheme); for (const [name, value] of Object.entries(variables)) { root.style.setProperty(name, value); } + if (previous) { + this.tweenTriplets(root, previous, variables); + } this.updateBrowserThemeColor(variables['--ion-toolbar-background']); this.schemeSubject.next(scheme); @@ -281,6 +487,64 @@ export class ThemeService { } } + /** + * Hold the transition class over the change so every element, whatever stylesheet it + * came with, moves to the new colours on one timing. + */ + private animateChange(): void { + const {classList} = document.documentElement; + classList.add(THEME_ANIMATING_CLASS); + if (this.animationTimer !== null) { + clearTimeout(this.animationTimer); + } + this.animationTimer = window.setTimeout(() => { + classList.remove(THEME_ANIMATING_CLASS); + this.animationTimer = null; + }, THEME_TRANSITION_MS); + } + + private readTriplets(root: HTMLElement, target: CssVariables): Map { + const previous = new Map(); + for (const name of Object.keys(target)) { + if (!name.endsWith('-rgb')) { + continue; + } + const from = parseTriplet(root.style.getPropertyValue(name)); + const to = parseTriplet(target[name]); + if (from && to && from.some((channel, index) => channel !== to[index])) { + previous.set(name, from); + } + } + return previous; + } + + private tweenTriplets( + root: HTMLElement, + previous: Map, + target: CssVariables, + ): void { + if (this.tweenFrame !== null) { + cancelAnimationFrame(this.tweenFrame); + this.tweenFrame = null; + } + const moves = [...previous].map(([name, from]) => ({name, from, to: parseTriplet(target[name])})); + if (!moves.length) { + return; + } + const started = performance.now(); + const step = (now: number) => { + const progress = Math.min(1, (now - started) / THEME_TRANSITION_MS); + const eased = easeWith(progress, THEME_TRANSITION_EASING); + for (const {name, from, to} of moves) { + const channels = from.map((channel, index) => + Math.round(channel + (to[index] - channel) * eased)); + root.style.setProperty(name, channels.join(', ')); + } + this.tweenFrame = progress < 1 ? requestAnimationFrame(step) : null; + }; + this.tweenFrame = requestAnimationFrame(step); + } + private resolveScheme(preference: SchemePreference): ColorScheme { if (preference === 'system') { return this.darkQuery?.matches ? 'dark' : 'light'; @@ -324,11 +588,91 @@ export class ThemeService { const stored = this.readLocal(uid); const adoptGuest = guestSettings && guestSettings.updatedAt > stored.updatedAt; this.apply(adoptGuest ? guestSettings : stored, adoptGuest); + this.savedColorsSubject.next(this.readSavedColors(uid)); + this.shadeMemory = this.readShadeMemory(uid); } private detachUser(): void { this.userId = GUEST_ID; this.apply(this.readLocal(GUEST_ID), false); + this.savedColorsSubject.next(this.readSavedColors(GUEST_ID)); + this.shadeMemory = this.readShadeMemory(GUEST_ID); + } + + /** + * A template states its colours for the light shade, so its own page belongs to that shade + * alone; any other derives the page from the colour instead. + */ + private pageForTemplate(template: ThemeTemplate, shade: ThemeShade): string | null { + return shade === DEFAULT_CUSTOM_SHADE ? template.background : null; + } + + /** What a colour or template is remembered under: templates keep their own identity. */ + private shadeKey(custom: CustomTheme): string | null { + return custom.templateId === OWN_COLOR_TEMPLATE_ID + ? normalizeColor(custom.seed.accent) + : custom.templateId; + } + + private rememberedShade(key: string | null): ThemeShade | null { + return key ? this.shadeMemory.find(([saved]) => saved === key)?.[1] ?? null : null; + } + + private rememberShade(custom: CustomTheme, shade: ThemeShade): void { + const key = this.shadeKey(custom); + if (!key) { + return; + } + this.shadeMemory = [[key, shade] as [string, ThemeShade], + ...this.shadeMemory.filter(([saved]) => saved !== key)] + .slice(0, MAX_REMEMBERED_SHADES); + try { + localStorage.setItem(SHADE_MEMORY_KEY_PREFIX + this.userId, JSON.stringify(this.shadeMemory)); + } catch { + // Storage may be unavailable; the shade still holds for this session. + } + } + + private readShadeMemory(uid: string): [string, ThemeShade][] { + try { + const raw = localStorage.getItem(SHADE_MEMORY_KEY_PREFIX + uid); + const parsed: unknown = raw ? JSON.parse(raw) : null; + if (!Array.isArray(parsed)) { + return []; + } + return parsed + .filter((entry): entry is [string, ThemeShade] => Array.isArray(entry) + && typeof entry[0] === 'string' + && THEME_SHADES.some(option => option.value === entry[1])) + .slice(0, MAX_REMEMBERED_SHADES); + } catch { + return []; + } + } + + private readSavedColors(uid: string): string[] { + try { + const raw = localStorage.getItem(SAVED_COLORS_KEY_PREFIX + uid); + const parsed: unknown = raw ? JSON.parse(raw) : null; + if (!Array.isArray(parsed)) { + return []; + } + const colors = parsed + // Entries kept while a colour also carried a page of its own are read as colours. + .map(entry => normalizeColor(typeof entry === 'string' ? entry : entry?.color)) + .filter((color): color is string => color !== null); + return [...new Set(colors)].slice(0, MAX_SAVED_COLORS); + } catch { + return []; + } + } + + private writeSavedColors(colors: string[]): void { + try { + localStorage.setItem(SAVED_COLORS_KEY_PREFIX + this.userId, JSON.stringify(colors)); + } catch { + // Storage may be unavailable; the colours still stand for this session. + } } private readLocal(uid: string): ThemeSettings { diff --git a/src/app/theme/color.ts b/src/app/theme/color.ts index 396908e..fd51b32 100644 --- a/src/app/theme/color.ts +++ b/src/app/theme/color.ts @@ -43,6 +43,11 @@ function clampChannel(value: number): number { return Math.min(255, Math.max(0, Math.round(value))); } +/** The colour as it will actually be written, so contrast is judged on what renders. */ +function round(color: Rgb): Rgb { + return {r: clampChannel(color.r), g: clampChannel(color.g), b: clampChannel(color.b)}; +} + export function toHex(color: Rgb): string { return '#' + [color.r, color.g, color.b] .map(channel => clampChannel(channel).toString(16).padStart(2, '0')) @@ -148,19 +153,26 @@ export function readableOn(background: Rgb): Rgb { * hue and saturation so a themed colour stays vivid rather than washing out to grey. */ export function adjustLightnessToContrast(color: Rgb, background: Rgb, minRatio: number, step = 0.04): Rgb { - if (contrastRatio(color, background) >= minRatio) { - return color; + // Contrast is judged on the colours as they will be written, so one that clears the ratio + // only before rounding cannot be handed back having quietly fallen under it. + const onto = round(background); + const start = round(color); + if (contrastRatio(start, onto) >= minRatio) { + return start; } const hsl = rgbToHsl(color); - const lighten = relativeLuminance(background) < 0.5; - let best = color; + // Which way to move is decided by whichever of black or white actually reads on the + // background, not by whether it is past halfway: those part company between a relative + // luminance of about 0.18 and 0.5, which is where a page filled with a colour sits. + const lighten = readableOn(onto) === WHITE; + let best = start; for (let offset = step; offset <= 1; offset += step) { const lightness = lighten ? hsl.l + offset : hsl.l - offset; if (lightness <= 0 || lightness >= 1) { break; } - best = hslToRgb({...hsl, l: lightness}); - if (contrastRatio(best, background) >= minRatio) { + best = round(hslToRgb({...hsl, l: lightness})); + if (contrastRatio(best, onto) >= minRatio) { return best; } } @@ -172,17 +184,21 @@ export function adjustLightnessToContrast(color: Rgb, background: Rgb, minRatio: * so a colour picked without regard for contrast still renders legibly. */ export function ensureContrast(color: Rgb, background: Rgb, minRatio: number): Rgb { - if (contrastRatio(color, background) >= minRatio) { - return color; + const onto = round(background); + if (contrastRatio(round(color), onto) >= minRatio) { + return round(color); } - const target = relativeLuminance(background) > 0.5 ? BLACK : WHITE; + const target = readableOn(onto); let low = 0; let high = 1; - let result = mix(target, color, high); + let result = round(mix(target, color, high)); + // The palette is written as 8-bit hex, so the search is run on the rounded colour: one + // that only just clears the ratio in continuous terms can fall back under it once + // rounded, leaving a palette that misses the contrast it was built to keep. for (let i = 0; i < 12; i++) { const middle = (low + high) / 2; - const candidate = mix(target, color, middle); - if (contrastRatio(candidate, background) >= minRatio) { + const candidate = round(mix(target, color, middle)); + if (contrastRatio(candidate, onto) >= minRatio) { result = candidate; high = middle; } else { diff --git a/src/app/theme/palette.ts b/src/app/theme/palette.ts index 579c662..4f09ffa 100644 --- a/src/app/theme/palette.ts +++ b/src/app/theme/palette.ts @@ -32,12 +32,18 @@ import { COLOR_STEPS, COMPANION_HUE_OFFSET, CONTRAST_LUMINANCE_THRESHOLD, + DARK_PAGE_LIGHTNESS, + DARK_PAGE_MAX_LUMINANCE, + LIGHT_PAGE_MIN_LUMINANCE, + DARK_PAGE_SATURATION, + FILLED_SURFACE_STEPS, HEATMAP_EMPTY_WEIGHT, HEATMAP_LEVEL_WEIGHTS, MIN_ACCENT_CONTRAST, MIN_ACCENT_ROLE_CONTRAST, MIN_HEATMAP_CONTRAST, MIN_MUTED_CONTRAST, + MIN_SERIES_CONTRAST, MUTED_TEXT_WEIGHT, MIN_TEXT_CONTRAST, ROLE_WEIGHTS, @@ -46,7 +52,16 @@ import { TERTIARY_HUE_OFFSET, TINT_AMOUNT, } from './theme-presets'; -import {BaseScheme, ColorScheme, SEMANTIC_ROLES, SemanticRole, ThemeBackground, ThemeSeed} from './theme.model'; +import { + BaseScheme, + ColorScheme, + SEMANTIC_ROLES, + SemanticRole, + ThemeBackground, + ThemeSeed, + ThemeShade, + ThemeVariables, +} from './theme.model'; export type CssVariables = Record; @@ -59,6 +74,16 @@ function contrastFor(color: Rgb): Rgb { return relativeLuminance(color) > CONTRAST_LUMINANCE_THRESHOLD ? BLACK : WHITE; } +/** + * The one of black or white that keeps the most contrast across every colour it has to + * label, so a row of related colours can share a single text colour. + */ +function uniformLabel(colors: Rgb[]): Rgb { + const worst = (label: Rgb) => + colors.reduce((lowest, color) => Math.min(lowest, contrastRatio(color, label)), Infinity); + return worst(WHITE) >= worst(BLACK) ? WHITE : BLACK; +} + function assignRole(variables: CssVariables, role: SemanticRole, color: Rgb): void { const contrast = contrastFor(color); variables[`--ion-color-${role}`] = toHex(color); @@ -78,6 +103,46 @@ interface AccentRoles { derived: boolean; } +/** The shade a colour belongs to, and so the one that draws it exactly as picked. */ +function shadeOf(color: Rgb): ThemeShade { + const luminance = relativeLuminance(color); + if (luminance >= LIGHT_PAGE_MIN_LUMINANCE) { + return 'light'; + } + return luminance <= DARK_PAGE_MAX_LUMINANCE ? 'dark' : 'fill'; +} + +export function shadeForColor(color: string | null): ThemeShade { + const parsed = color ? parseColor(color) : null; + return parsed ? shadeOf(parsed) : 'light'; +} + +/** + * The page a theme is drawn on when it does not name one. In the shade the colour belongs to, + * and whenever the page is filled, the colour is used as picked, so choosing that shade shows + * the colour itself. The other shades adapt it into a page they can carry. + */ +function derivePage( + surfaceTint: Rgb, + baseBackground: Rgb, + shade: ThemeShade, + intensity: number, + derived: boolean, +): Rgb { + if (derived && (shade === 'fill' || shade === shadeOf(surfaceTint))) { + return surfaceTint; + } + if (shade === 'dark' && derived) { + const {h, s} = rgbToHsl(surfaceTint); + return hslToRgb({ + h, + s: Math.min(DARK_PAGE_SATURATION.max, Math.max(DARK_PAGE_SATURATION.min, s)), + l: DARK_PAGE_LIGHTNESS, + }); + } + return mix(surfaceTint, baseBackground, ACCENT_WEIGHTS.background * intensity); +} + function seedColor(value: string | null): Rgb | null { return value ? parseColor(value) : null; } @@ -109,6 +174,8 @@ export function buildThemeVariables( seed: ThemeSeed, scheme: ColorScheme, background: ThemeBackground, + overrides?: ThemeVariables, + shade: ThemeShade = scheme, ): CssVariables { const base = BASE_SCHEMES[scheme]; const baseBackground = parseColor(base.background) ?? WHITE; @@ -117,10 +184,10 @@ export function buildThemeVariables( const {accent, companion, tertiary, surfaceTint, derived} = resolveAccents(seed, base); const chosenBackground = background.color ? parseColor(background.color) : null; - const pageBackground = chosenBackground ?? mix(surfaceTint, baseBackground, ACCENT_WEIGHTS.background * intensity); - // A hand-picked background may be far lighter or darker than the scheme's own, so the text - // starts from whichever of black or white reads on it rather than from the scheme's colour. - const textAnchor = chosenBackground ? readableOn(chosenBackground) : baseText; + const pageBackground = chosenBackground ?? derivePage(surfaceTint, baseBackground, shade, intensity, derived); + // A themed page may be far lighter or darker than the scheme's own, so the text starts + // from whichever of black or white reads on it rather than from the scheme's colour. + const textAnchor = chosenBackground || derived ? readableOn(pageBackground) : baseText; const tintedText = mix(surfaceTint, textAnchor, ACCENT_WEIGHTS.text * intensity); const pageText = ensureContrast(tintedText, pageBackground, MIN_TEXT_CONTRAST); @@ -153,7 +220,10 @@ export function buildThemeVariables( const surfaceScheme: ColorScheme = relativeLuminance(pageBackground) > CONTRAST_LUMINANCE_THRESHOLD ? 'light' : 'dark'; - const steps = SURFACE_STEPS[surfaceScheme]; + // A filled page is its own accent, so blending surfaces towards that accent moves them + // nowhere; they are stepped towards the text colour instead. + const filled = derived && toHex(pageBackground) === toHex(surfaceTint); + const steps = filled ? FILLED_SURFACE_STEPS : SURFACE_STEPS[surfaceScheme]; const surfaceOver = (step: number, weight: number) => mix(surfaceTint, mix(pageText, pageBackground, step), weight * intensity); @@ -208,21 +278,26 @@ export function buildThemeVariables( if (derived) { const {h, s: saturation} = rgbToHsl(roleColors.primary); const range = SERIES_LIGHTNESS_RANGE[scheme]; + const band = SERIES_SATURATION[scheme]; const seriesColor = (position: number) => hslToRgb({ h: h + (position - 0.5) * 2 * SERIES_HUE_DRIFT, - s: Math.max(saturation, SERIES_SATURATION[scheme]), + s: Math.min(band.max, Math.max(band.min, saturation)), l: range.from + (range.to - range.from) * position, }); + const groups = [...Array(SERIES_COUNT).keys()] + .map(index => seriesColor(index / Math.max(1, SERIES_COUNT - 1))) + .concat(mix(roleColors.medium, pageBackground, 0.85)); + // The groups share one label colour, so the row reads as a set rather than as a mix + // of light and dark text. Each group is then taken as far as that label needs. + const label = uniformLabel(groups); + const readable = groups.map(color => + adjustLightnessToContrast(color, label, MIN_SERIES_CONTRAST)); for (let index = 0; index < SERIES_COUNT; index++) { - const color = seriesColor(index / Math.max(1, SERIES_COUNT - 1)); - variables[`--flick-series-${index}`] = toHex(color); - // Whichever of black or white actually reads best: a mid-tone shade needs black, - // where Ionic's lightness rule would still hand it white. - variables[`--flick-series-${index}-contrast`] = toHex(readableOn(color)); + variables[`--flick-series-${index}`] = toHex(readable[index]); + variables[`--flick-series-${index}-contrast`] = toHex(label); } - const fallback = mix(roleColors.medium, pageBackground, 0.85); - variables['--flick-series-fallback'] = toHex(fallback); - variables['--flick-series-fallback-contrast'] = toHex(readableOn(fallback)); + variables['--flick-series-fallback'] = toHex(readable[SERIES_COUNT]); + variables['--flick-series-fallback-contrast'] = toHex(label); } else { DEFAULT_SERIES_COLORS.forEach((color, index) => { variables[`--flick-series-${index}`] = color; @@ -241,10 +316,10 @@ export function buildThemeVariables( const opacity = clamp01(background.imageOpacity); variables['--flick-background-scrim'] = `rgba(${toRgbString(pageBackground)}, ${(1 - opacity).toFixed(3)})`; variables['--flick-background-blur'] = `${Math.max(0, background.imageBlur)}px`; - variables['--flick-background-size'] = background.imageFit === 'tile' ? 'auto' : background.imageFit; - variables['--flick-background-repeat'] = background.imageFit === 'tile' ? 'repeat' : 'no-repeat'; + variables['--flick-background-size'] = background.imageFit; + variables['--flick-background-repeat'] = 'no-repeat'; - return variables; + return overrides ? {...variables, ...overrides} : variables; } /** Contrast of body text against the page background, used to verify a generated palette. */ diff --git a/src/app/theme/theme-presets.ts b/src/app/theme/theme-presets.ts index 4e9c1e3..68ed4fd 100644 --- a/src/app/theme/theme-presets.ts +++ b/src/app/theme/theme-presets.ts @@ -1,5 +1,7 @@ import { BackgroundFitOption, + ThemeShade, + ThemeShadeOption, BaseScheme, ColorScheme, CustomTheme, @@ -57,6 +59,13 @@ export const SURFACE_STEPS: Record = { dark: {card: 0.05, item: 0.033, toolbar: 0}, }; +/** + * A page drawn in the theme colour itself leaves the surfaces nothing to blend towards, so + * the scheme's own steps would lay cards, items and the toolbar flat against it. These place + * them by stepping towards the text colour instead, which separates them on any page. + */ +export const FILLED_SURFACE_STEPS: SurfaceSteps = {card: 0.07, item: 0.05, toolbar: 0.13}; + /** How far each surface is blended towards the accent at full intensity. */ export const ACCENT_WEIGHTS: Record = { background: 0.06, @@ -127,13 +136,23 @@ export const SERIES_COUNT = SERIES_NAMES.length; /** * A custom theme colours the course groups in shades of its own colour: the lightness runs * across this range while the hue drifts slightly, so the groups stay apart without - * introducing colours the theme never asked for. + * introducing colours the theme never asked for. The range stays clear of the very light + * end so a single label colour reads on every group. */ export const SERIES_LIGHTNESS_RANGE: Record = { - light: {from: 0.74, to: 0.26}, - dark: {from: 0.68, to: 0.3}, + light: {from: 0.52, to: 0.3}, + dark: {from: 0.56, to: 0.34}, +}; + +/** + * The groups are held to a softer saturation than the accent itself, so a vivid theme + * colour still renders the groups as muted shades rather than as eight vibrant blocks. + * The floor keeps a nearly grey accent from producing eight indistinguishable greys. + */ +export const SERIES_SATURATION: Record = { + light: {min: 0.35, max: 0.5}, + dark: {min: 0.32, max: 0.46}, }; -export const SERIES_SATURATION: Record = {light: 0.55, dark: 0.5}; export const SERIES_HUE_DRIFT = 14; /** How far the informational tag is blended into the page behind it. */ @@ -156,14 +175,37 @@ export const DEFAULT_MODE = THEME_MODES[2].value; /** The modes that are not a custom theme, offered inside the editor as the way back out. */ export const STANDARD_MODES = THEME_MODES.filter(option => option.value !== 'custom'); -/** A custom theme always renders light, so a chosen colour shows as itself. */ -export const CUSTOM_SCHEME: ColorScheme = 'light'; +export const DEFAULT_CUSTOM_SHADE: ThemeShade = 'light'; + +export const THEME_SHADES: ThemeShadeOption[] = [ + {value: 'light', label: 'Light', icon: 'sunny-outline'}, + {value: 'dark', label: 'Dark', icon: 'moon-outline'}, + {value: 'fill', label: 'Fill', icon: 'color-fill-outline'}, +]; + +/** + * Which shade a colour belongs to. A colour light enough to carry black body text at the + * contrast body text is held to belongs to 'light'; one dark enough to carry white text + * belongs to 'dark'; anything between can carry neither and belongs to 'fill'. Both follow + * from MIN_TEXT_CONTRAST, so a colour is only ever drawn as picked where it stays readable. + */ +export const LIGHT_PAGE_MIN_LUMINANCE = MIN_TEXT_CONTRAST * 0.05 - 0.05; +export const DARK_PAGE_MAX_LUMINANCE = 1.05 / MIN_TEXT_CONTRAST - 0.05; + +/** + * Drawn dark, a custom theme fills the page with the colour itself rather than tinting a + * near-black page, so the page reads as that colour. The lightness is kept low enough that + * body text still clears its contrast rule, and the saturation is banded so a nearly grey + * colour still gives a grey page rather than a colourful one. + */ +export const DARK_PAGE_LIGHTNESS = 0.2; +export const DARK_PAGE_SATURATION = {min: 0.2, max: 0.55}; /** Brand colours taken from the faculty's own sites. */ export const FACULTY_ACCENT = '#1f6241'; export const UNIVERSITY_ACCENT = '#e91e90'; /** The pages the brand colours are drawn on. */ -export const UNIVERSITY_PAGE = '#fdf0f5'; +export const UNIVERSITY_PAGE = '#fff0f5'; export const FACULTY_PAGE = '#f2f6f4'; /** @@ -177,6 +219,119 @@ export const THEME_TEMPLATES: ThemeTemplate[] = [ description: 'Pink page with pink buttons.', seed: {accent: UNIVERSITY_ACCENT, companion: null, tertiary: null, surfaceTint: null, intensity: 1}, background: UNIVERSITY_PAGE, + variables: { + '--ion-color-primary': '#E91E90', + '--ion-color-primary-rgb': '233, 30, 144', + '--ion-color-primary-contrast': '#ffffff', + '--ion-color-primary-contrast-rgb': '255, 255, 255', + '--ion-color-primary-shade': '#CD1A7F', + '--ion-color-primary-tint': '#EB359C', + '--ion-color-secondary': '#F06292', + '--ion-color-secondary-rgb': '240, 98, 146', + '--ion-color-secondary-contrast': '#ffffff', + '--ion-color-secondary-contrast-rgb': '255, 255, 255', + '--ion-color-secondary-shade': '#D35680', + '--ion-color-secondary-tint': '#F2729D', + '--ion-color-tertiary': '#AD1457', + '--ion-color-tertiary-rgb': '173, 20, 87', + '--ion-color-tertiary-contrast': '#ffffff', + '--ion-color-tertiary-contrast-rgb': '255, 255, 255', + '--ion-color-tertiary-shade': '#98124D', + '--ion-color-tertiary-tint': '#B52C68', + '--ion-color-success': '#E91E63', + '--ion-color-success-rgb': '233, 30, 99', + '--ion-color-success-contrast': '#ffffff', + '--ion-color-success-contrast-rgb': '255, 255, 255', + '--ion-color-success-shade': '#CD1A57', + '--ion-color-success-tint': '#EB3573', + '--ion-color-warning': '#FF8A80', + '--ion-color-warning-rgb': '255, 138, 128', + '--ion-color-warning-contrast': '#000000', + '--ion-color-warning-contrast-rgb': '0, 0, 0', + '--ion-color-warning-shade': '#E07970', + '--ion-color-warning-tint': '#FF968D', + '--ion-color-danger': '#C2185B', + '--ion-color-danger-rgb': '194, 24, 91', + '--ion-color-danger-contrast': '#ffffff', + '--ion-color-danger-contrast-rgb': '255, 255, 255', + '--ion-color-danger-shade': '#AB1550', + '--ion-color-danger-tint': '#C82F6B', + '--ion-color-dark': '#4A0E2B', + '--ion-color-dark-rgb': '74, 14, 43', + '--ion-color-dark-contrast': '#ffffff', + '--ion-color-dark-contrast-rgb': '255, 255, 255', + '--ion-color-dark-shade': '#410C26', + '--ion-color-dark-tint': '#5C2640', + '--ion-color-medium': '#B07090', + '--ion-color-medium-rgb': '176, 112, 144', + '--ion-color-medium-contrast': '#ffffff', + '--ion-color-medium-contrast-rgb': '255, 255, 255', + '--ion-color-medium-shade': '#9B637F', + '--ion-color-medium-tint': '#B87E9B', + '--ion-color-light': '#FFF0F5', + '--ion-color-light-rgb': '255, 240, 245', + '--ion-color-light-contrast': '#4A0E2B', + '--ion-color-light-contrast-rgb': '74, 14, 43', + '--ion-color-light-shade': '#E0D3D8', + '--ion-color-light-tint': '#FFF2F6', + '--ion-background-color': '#FFF0F5', + '--ion-background-color-rgb': '255, 240, 245', + '--ion-text-color': '#4A0E2B', + '--ion-text-color-rgb': '74, 14, 43', + '--ion-toolbar-background': '#F8BBD0', + '--ion-toolbar-color': '#4A0E2B', + '--ion-item-background': '#FCE4EC', + '--ion-item-background-activated': '#F8BBD0', + '--ion-border-color': '#F48FB1', + '--ion-color-step-50': '#FCE4EC', + '--ion-color-step-100': '#F8BBD0', + '--ion-color-step-150': '#F48FB1', + '--ion-color-step-200': '#F06292', + '--ion-color-step-250': '#EC407A', + '--ion-color-step-300': '#E91E63', + '--ion-color-step-350': '#D81B60', + '--ion-color-step-400': '#C2185B', + '--ion-color-step-450': '#AD1457', + '--ion-color-step-500': '#880E4F', + '--ion-color-step-550': '#7B0C47', + '--ion-color-step-600': '#6E0A3F', + '--ion-color-step-650': '#610937', + '--ion-color-step-700': '#54072F', + '--ion-color-step-750': '#470627', + '--ion-color-step-800': '#3A041F', + '--ion-color-step-850': '#2D0317', + '--ion-color-step-900': '#20020F', + '--ion-color-step-950': '#130107', + '--ion-card-background': '#FCE4EC', + '--flick-series-0': '#F48FB1', + '--flick-series-0-contrast': '#ffffff', + '--flick-series-1': '#E91E90', + '--flick-series-1-contrast': '#ffffff', + '--flick-series-2': '#AD1457', + '--flick-series-2-contrast': '#ffffff', + '--flick-series-3': '#C2185B', + '--flick-series-3-contrast': '#ffffff', + '--flick-series-4': '#D81B60', + '--flick-series-4-contrast': '#ffffff', + '--flick-series-5': '#880E4F', + '--flick-series-5-contrast': '#ffffff', + '--flick-series-6': '#EC407A', + '--flick-series-6-contrast': '#ffffff', + '--flick-series-7': '#F06292', + '--flick-series-7-contrast': '#ffffff', + '--flick-series-fallback': '#B07090', + '--flick-series-fallback-contrast': '#ffffff', + '--flick-accent': '#E91E90', + '--flick-accent-rgb': '233, 30, 144', + '--flick-accent-text': '#E91E90', + '--flick-companion-text': '#AD1457', + '--flick-muted-text': '#C48BA0', + '--flick-warning-text': '#E91E90', + '--flick-surface': '#FCE4EC', + '--flick-surface-muted': '#F8BBD0', + '--flick-tag-color': '#E91E90', + '--flick-tag-background': '#FCE4EC', + }, }, { id: 'faculty', @@ -229,10 +384,12 @@ export const ACCENT_SWATCHES: string[] = [ /** A colour picked in the editor tints the page as fully as a template does. */ export const OWN_COLOR_INTENSITY = 1; +export const MAX_SAVED_COLORS = 12; +export const MAX_REMEMBERED_SHADES = 60; + export const BACKGROUND_FIT_OPTIONS: BackgroundFitOption[] = [ {value: 'cover', label: 'Fill'}, {value: 'contain', label: 'Fit'}, - {value: 'tile', label: 'Tile'}, ]; export const DEFAULT_BACKGROUND: ThemeBackground = { @@ -258,6 +415,7 @@ export function defaultCustomTheme(): CustomTheme { templateId: template.id, seed: {...template.seed}, background: {...DEFAULT_BACKGROUND, color: template.background}, + shade: DEFAULT_CUSTOM_SHADE, }; } diff --git a/src/app/theme/theme.model.ts b/src/app/theme/theme.model.ts index 4bf4f0f..516b78a 100644 --- a/src/app/theme/theme.model.ts +++ b/src/app/theme/theme.model.ts @@ -2,7 +2,15 @@ export type ThemeMode = 'light' | 'dark' | 'system' | 'custom'; export type ColorScheme = 'light' | 'dark'; export type SchemePreference = ColorScheme | 'system'; -export type BackgroundFit = 'cover' | 'contain' | 'tile'; +export type BackgroundFit = 'cover' | 'contain'; + +/** + * How a chosen colour is drawn. Every colour belongs to one of these by how light it is, and + * in the shade it belongs to it is drawn exactly as picked. The other two adapt it: 'light' + * tints a light page with it, 'dark' fills a dark page with it, and 'fill' always gives the + * page over to the colour itself. + */ +export type ThemeShade = 'light' | 'dark' | 'fill'; export const SEMANTIC_ROLES = [ 'primary', 'secondary', 'tertiary', 'success', 'warning', 'danger', 'dark', 'medium', 'light', @@ -36,13 +44,14 @@ export interface ThemeBackground { } /** - * Everything the custom mode remembers. The standard modes deliberately carry none of it, - * and a custom theme is always drawn light so a chosen colour shows as itself. + * Everything the custom mode remembers. The standard modes deliberately carry none of it. */ export interface CustomTheme { templateId: string; seed: ThemeSeed; background: ThemeBackground; + /** How the chosen colours are drawn: light, dark, or the colour exactly as picked. */ + shade: ThemeShade; } export interface ThemeSettings { @@ -51,6 +60,12 @@ export interface ThemeSettings { updatedAt: number; } +/** + * Exact custom properties a theme states for itself. Anything left out stays derived, so a + * theme can pin as much or as little of the palette as it needs. + */ +export type ThemeVariables = Record; + export interface ThemeTemplate { id: string; name: string; @@ -58,6 +73,12 @@ export interface ThemeTemplate { seed: ThemeSeed; /** Page colour the template starts from; null lets it be derived from the accent. */ background: string | null; + /** + * Colours this template states rather than has derived, applied over the generated + * palette. They hold only while the template's own colours are still in place; adjusting + * the theme leaves them behind and the palette is derived from what was chosen instead. + */ + variables?: ThemeVariables; } export interface ThemeModeOption { @@ -72,6 +93,12 @@ export interface BackgroundFitOption { label: string; } +export interface ThemeShadeOption { + value: ThemeShade; + label: string; + icon: string; +} + /** Neutral palette each theme starts from; the accent is blended into it by intensity. */ export interface BaseScheme { background: string; diff --git a/src/global.scss b/src/global.scss index 5e551c8..aa9afaa 100644 --- a/src/global.scss +++ b/src/global.scss @@ -27,17 +27,80 @@ @import "video.js/dist/video-js.css"; -$theme-transition: 0.25s ease; - -body, -ion-content, -ion-header, -ion-toolbar, -ion-card, -ion-item, -ion-list, -.e-learning-tag { - transition: background-color $theme-transition, color $theme-transition, border-color $theme-transition; +/* Changing a theme rewrites the custom properties on the document, so every surface that + reads one fades to its new colour instead of snapping. The duration is a variable so a + reader who asks for less motion gets the same palette without the fade. */ +:root { + --flick-theme-transition-duration: 0.28s; + --flick-theme-transition-easing: cubic-bezier(0.4, 0, 0.2, 1); +} + +@media (prefers-reduced-motion: reduce) { + :root { + --flick-theme-transition-duration: 0s; + } +} + +/* + * The colours a theme writes onto the document. Transitioning each element that reads one + * cannot keep them together: a toolbar, a card and the content resolve different variables + * through different parts of Ionic, so each fades on its own clock and the change arrives in + * pieces. Registering the variables as colours instead lets the values themselves be + * interpolated, on one element and one clock, so every surface reading them repaints in step + * — through Ionic's shadow trees as well, which custom properties inherit into. + */ +$theme-color-vars: + "--ion-background-color", "--ion-text-color", + "--ion-item-background", "--ion-item-background-activated", + "--ion-card-background", "--ion-toolbar-background", "--ion-toolbar-color", + "--ion-border-color", + "--flick-accent", "--flick-accent-text", "--flick-companion-text", "--flick-muted-text", + "--flick-surface", "--flick-surface-muted", "--flick-tag-color", "--flick-tag-background", + "--flick-warning-text", "--flick-series-fallback", "--flick-series-fallback-contrast"; + +@each $step in 50, 100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800, 850, 900, 950 { + $theme-color-vars: append($theme-color-vars, "--ion-color-step-#{$step}", comma); +} + +@each $role in primary, secondary, tertiary, success, warning, danger, dark, medium, light { + @each $variant in "", "-shade", "-tint", "-contrast" { + $theme-color-vars: append($theme-color-vars, "--ion-color-#{$role}#{$variant}", comma); + } +} + +@for $index from 0 through 7 { + $theme-color-vars: append($theme-color-vars, "--flick-series-#{$index}", comma); + $theme-color-vars: append($theme-color-vars, "--flick-series-#{$index}-contrast", comma); +} + +@for $level from 0 through 4 { + $theme-color-vars: append($theme-color-vars, "--flick-heat-#{$level}", comma); +} + +@each $name in $theme-color-vars { + @property #{$name} { + syntax: ""; + inherits: true; + initial-value: transparent; + } +} + +/* + * Only while a theme is actually changing: left on, it would put a fade on every hover and + * press that happens to shift a colour. + */ +html.flick-theme-animating { + $moves: (); + + @each $name in $theme-color-vars { + $moves: append( + $moves, + unquote("#{$name} var(--flick-theme-transition-duration) var(--flick-theme-transition-easing)"), + comma + ); + } + + transition: $moves; } .small-text { @@ -59,6 +122,19 @@ ion-card:not(.ion-color) { color: var(--flick-muted-text); } +/* A list header takes the page's own text colour rather than Ionic's default, which is + fixed against the stock palette and stays pale whatever the theme sets. */ +ion-list-header { + --background: transparent; + --color: var(--ion-text-color); + + color: var(--ion-text-color); + + ion-label { + color: inherit; + } +} + /* An item inside a coloured card keeps its own background, so it needs the page's text colour rather than the card's contrast colour, which is meant for the card itself. */ ion-card.ion-color ion-item { @@ -124,3 +200,4 @@ body.flick-has-background-image { --background: transparent; } } + From 5ce10729b33f76b38924eaff4b769f3c8e132262 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Mon, 5 Oct 2026 21:55:13 +0700 Subject: [PATCH 03/14] feat: sync the theme across devices, except the background picture The theme and saved colours travel in the account's userSettings document; the newest device wins. The picture stays on the device that chose it. Edits are sent once they settle, so dragging a slider or a colour picker costs one write. Also show the selected shade and fit on iOS, where the chosen segment is filled with the same colour as its label. --- src/app/shared/theme-editor.component.html | 2 +- src/app/shared/theme-editor.component.scss | 5 + src/app/theme.service.ts | 93 ++++++-- src/app/user-sync.service.ts | 240 +++++++++++++++++++++ 4 files changed, 324 insertions(+), 16 deletions(-) create mode 100644 src/app/user-sync.service.ts diff --git a/src/app/shared/theme-editor.component.html b/src/app/shared/theme-editor.component.html index 526473c..8e45cc8 100644 --- a/src/app/shared/theme-editor.component.html +++ b/src/app/shared/theme-editor.component.html @@ -38,7 +38,7 @@

Pre-made templates

Saved colours

-

Colours you keep, on this device.

+

Colours you keep, to pick again later.

@for (colour of savedColors$ | async; track colour) { diff --git a/src/app/shared/theme-editor.component.scss b/src/app/shared/theme-editor.component.scss index 7c865ea..e13522f 100644 --- a/src/app/shared/theme-editor.component.scss +++ b/src/app/shared/theme-editor.component.scss @@ -151,6 +151,11 @@ ion-segment-button { --indicator-color: var(--flick-accent-text); } +/* iOS fills the chosen segment instead of underlining it, so the label needs the page behind it. */ +ion-segment-button.ios { + --indicator-color: var(--ion-background-color, #fff); +} + .saved-swatch { position: relative; display: inline-flex; diff --git a/src/app/theme.service.ts b/src/app/theme.service.ts index 7116fbc..8085804 100644 --- a/src/app/theme.service.ts +++ b/src/app/theme.service.ts @@ -3,6 +3,7 @@ import {BehaviorSubject, Observable} from 'rxjs'; import {distinctUntilChanged, map} from 'rxjs/operators'; import {ulid} from 'ulid'; import {AuthService} from './auth.service'; +import {UserSyncService} from './user-sync.service'; import {BLACK, isValidColor, parseColor, readableOn, toHex} from './theme/color'; import {buildThemeVariables, CssVariables, shadeForColor} from './theme/palette'; import { @@ -41,7 +42,7 @@ import { /** Mirrors the last applied palette so index.html can paint it before Angular boots. */ export const APPLIED_THEME_KEY = 'flickThemeApplied'; export const THEME_STORAGE_KEY_PREFIX = 'flickTheme_'; -/** Colours the reader has kept. They stay on this device, like the background picture. */ +/** Colours the reader has kept. They travel with the theme; the background picture does not. */ export const SAVED_COLORS_KEY_PREFIX = 'flickThemeColors_'; /** The shade last chosen for each colour and template. Kept on the device, like the rest. */ export const SHADE_MEMORY_KEY_PREFIX = 'flickThemeShades_'; @@ -105,6 +106,14 @@ function normalizeColor(value: unknown): string | null { return parsed ? toHex(parsed) : null; } +function sanitizeSavedColors(entries: unknown[]): string[] { + const colors = entries + // Entries kept while a colour also carried a page of its own are read as colours. + .map(entry => normalizeColor(typeof entry === 'string' ? entry : (entry as {color?: unknown})?.color)) + .filter((color): color is string => color !== null); + return [...new Set(colors)].slice(0, MAX_SAVED_COLORS); +} + function sanitizeColor(value: unknown): string | null { return typeof value === 'string' && isValidColor(value) ? value : null; } @@ -168,6 +177,7 @@ export function sanitizeSettings(raw: unknown): ThemeSettings { }) export class ThemeService { private imageStore = new BackgroundImageStore(); + private sync = inject(UserSyncService); readonly modes = THEME_MODES; readonly templates = THEME_TEMPLATES; @@ -352,8 +362,7 @@ export class ThemeService { } const kept = [normalized, ...this.savedColors.filter(saved => saved !== normalized)] .slice(0, MAX_SAVED_COLORS); - this.savedColorsSubject.next(kept); - this.writeSavedColors(kept); + this.setSavedColors(kept); } removeColor(color: string): void { @@ -361,9 +370,17 @@ export class ThemeService { if (!normalized) { return; } - const kept = this.savedColors.filter(saved => saved !== normalized); - this.savedColorsSubject.next(kept); - this.writeSavedColors(kept); + this.setSavedColors(this.savedColors.filter(saved => saved !== normalized)); + } + + /** The colours share the theme's timestamp, so the newest device wins for both at once. */ + private setSavedColors(colors: string[]): void { + this.savedColorsSubject.next(colors); + this.writeSavedColors(colors); + const settings = {...this.settings, updatedAt: Date.now()}; + this.settingsSubject.next(settings); + this.writeLocal(settings); + this.queueSync(settings); } /** The page the custom theme renders, whether it was named or derived. */ @@ -484,6 +501,7 @@ export class ThemeService { if (persist) { this.writeLocal(settings); + this.queueSync(settings); } } @@ -585,14 +603,66 @@ export class ThemeService { } const guestSettings = this.userId === GUEST_ID ? this.settings : null; this.userId = uid; + this.sync.attach(uid); const stored = this.readLocal(uid); const adoptGuest = guestSettings && guestSettings.updatedAt > stored.updatedAt; - this.apply(adoptGuest ? guestSettings : stored, adoptGuest); this.savedColorsSubject.next(this.readSavedColors(uid)); this.shadeMemory = this.readShadeMemory(uid); + this.apply(adoptGuest ? guestSettings : stored, adoptGuest); + void this.pullRemote(uid, !!adoptGuest); + } + + /** + * Adopt the theme another device set more recently. The picture never travels, so + * whatever this device shows stays in place. + */ + private async pullRemote(uid: string, sending: boolean): Promise { + const remote = await this.sync.read(); + if (this.userId !== uid || !remote?.theme) { + return; + } + const incoming = sanitizeSettings(remote.theme); + if (incoming.updatedAt <= this.settings.updatedAt) { + return; + } + const settings: ThemeSettings = { + ...incoming, + custom: { + ...incoming.custom, + background: {...incoming.custom.background, imageId: this.settings.custom.background.imageId}, + }, + }; + const colors = (remote.theme as {colors?: unknown}).colors; + if (Array.isArray(colors)) { + const kept = sanitizeSavedColors(colors); + this.savedColorsSubject.next(kept); + this.writeSavedColors(kept); + } + this.apply(settings, false); + this.writeLocal(settings); + // A guest theme already on its way out is older than this one, so it must not land. + if (sending) { + this.queueSync(settings); + } + } + + /** Everything but the picture, which is too large to send and stays where it was chosen. */ + private queueSync(settings: ThemeSettings): void { + if (this.userId === GUEST_ID) { + return; + } + const custom = settings.custom; + this.sync.queue({ + theme: { + ...settings, + custom: {...custom, background: {...custom.background, imageId: null}}, + colors: this.savedColors, + }, + }, true); } private detachUser(): void { + this.sync.detach(); this.userId = GUEST_ID; this.apply(this.readLocal(GUEST_ID), false); this.savedColorsSubject.next(this.readSavedColors(GUEST_ID)); @@ -654,14 +724,7 @@ export class ThemeService { try { const raw = localStorage.getItem(SAVED_COLORS_KEY_PREFIX + uid); const parsed: unknown = raw ? JSON.parse(raw) : null; - if (!Array.isArray(parsed)) { - return []; - } - const colors = parsed - // Entries kept while a colour also carried a page of its own are read as colours. - .map(entry => normalizeColor(typeof entry === 'string' ? entry : entry?.color)) - .filter((color): color is string => color !== null); - return [...new Set(colors)].slice(0, MAX_SAVED_COLORS); + return Array.isArray(parsed) ? sanitizeSavedColors(parsed) : []; } catch { return []; } diff --git a/src/app/user-sync.service.ts b/src/app/user-sync.service.ts new file mode 100644 index 0000000..b78a28d --- /dev/null +++ b/src/app/user-sync.service.ts @@ -0,0 +1,240 @@ +import {DestroyRef, inject, Injectable} from '@angular/core'; +import {BehaviorSubject, Observable} from 'rxjs'; +import {doc, Firestore, getDoc, setDoc} from '@angular/fire/firestore'; +import {getValue, RemoteConfig} from '@angular/fire/remote-config'; + +export const USER_SYNC_COLLECTION = 'userSettings'; +export const SYNC_CACHE_KEY_PREFIX = 'flickSync_'; +/** A remote config flag, so sync can be switched off without a release. */ +export const SYNC_ENABLED_KEY = 'userSyncEnabled'; + +/** + * Reads are billed per document, so the whole account lives in one document that is fetched + * at most once per this interval however many times the app is opened or reloaded. + */ +export const SYNC_READ_TTL_MS = 5 * 60 * 1000; +/** + * A preference is sent once it has been left alone this long, so dragging a slider or a colour + * picker costs one write rather than one per step. + */ +export const SYNC_URGENT_DELAY_MS = 2000; +/** + * Study time accumulates continuously, so it is sent at most once per this interval. Leaving + * the page does not break the limit either: what is held back is still on the device, and it + * goes out the next time the app opens there. + */ +export const SYNC_BACKGROUND_DELAY_MS = 15 * 60 * 1000; + +export interface RemoteUserSettings { + theme?: unknown; + study?: {devices?: Record}; +} + +interface CachedSettings { + data: RemoteUserSettings; + at: number; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +/** Deep merge, so a queued patch never discards another part of the document. */ +function mergePatch(target: Record, patch: Record): Record { + for (const [key, value] of Object.entries(patch)) { + const existing = target[key]; + target[key] = isRecord(value) && isRecord(existing) + ? mergePatch({...existing}, value) + : value; + } + return target; +} + +/** + * Keeps one document per account in step across devices, on a budget. + * + * There is no live listener: a listener bills a read for every change, and none of this needs + * to arrive mid-session. Reads come from a cache that survives reloads, writes are merged and + * sent on a delay, and anything unexpected turns sync off for the session rather than retrying. + */ +@Injectable({ + providedIn: 'root', +}) +export class UserSyncService { + private firestore = inject(Firestore); + + private readonly activeSubject = new BehaviorSubject(false); + /** True once the account document has actually been reached, so the app can say so. */ + readonly active$: Observable = this.activeSubject.asObservable(); + + private userId: string | null = null; + private enabled = true; + private pending: Record = {}; + private urgentPending = false; + private lastSentAt = 0; + private timer: ReturnType | null = null; + private dueAt = 0; + /** Every caller opening the app at once shares the one read in flight. */ + private reading: Promise | null = null; + + constructor() { + const remoteConfig = inject(RemoteConfig, {optional: true}); + if (remoteConfig) { + try { + const flag = getValue(remoteConfig, SYNC_ENABLED_KEY); + // A static source means nothing has been published for the key, which must + // leave sync on: the switch is there to turn it off, never to fail it closed. + this.enabled = flag.getSource() === 'static' || flag.asBoolean(); + } catch { + // Remote config is unavailable, so the switch simply does not apply. + } + } + + const leave = () => this.flush(false); + window.addEventListener('pagehide', leave); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + leave(); + } + }); + inject(DestroyRef).onDestroy(() => { + window.removeEventListener('pagehide', leave); + this.flush(); + }); + } + + attach(uid: string): void { + if (this.userId !== uid) { + this.flush(); + this.userId = uid; + } + } + + detach(): void { + this.flush(); + this.userId = null; + this.activeSubject.next(false); + } + + private disable(): void { + this.enabled = false; + this.activeSubject.next(false); + } + + /** The stored document, from the local cache when it is recent enough to trust. */ + async read(): Promise { + const uid = this.userId; + if (!uid || !this.enabled) { + return null; + } + const cached = this.readCache(uid); + if (cached && Date.now() - cached.at < SYNC_READ_TTL_MS) { + this.activeSubject.next(true); + return cached.data; + } + this.reading ??= this.fetch(uid, cached).finally(() => { + this.reading = null; + }); + return this.reading; + } + + private async fetch(uid: string, cached: CachedSettings | null): Promise { + try { + const snapshot = await getDoc(doc(this.firestore, USER_SYNC_COLLECTION, uid)); + const data = (snapshot.data() ?? {}) as RemoteUserSettings; + this.writeCache(uid, data); + this.activeSubject.next(true); + return data; + } catch { + this.disable(); + return cached?.data ?? null; + } + } + + /** + * Merge a change into the document. `urgent` sends it in a couple of seconds; everything + * else waits for the background cadence or for the page to go away. + */ + queue(patch: Record, urgent = false): void { + if (!this.userId || !this.enabled) { + return; + } + mergePatch(this.pending, patch); + if (urgent) { + this.urgentPending = true; + this.schedule(SYNC_URGENT_DELAY_MS, true); + } else { + this.schedule(SYNC_BACKGROUND_DELAY_MS); + } + } + + /** + * Send whatever is waiting. Leaving the page (`force` false) sends a preference straight + * away, but holds study time back while the last write is recent, so switching apps over + * and over never adds writes. + */ + flush(force = true): void { + const uid = this.userId; + if (!force && !this.urgentPending && Date.now() - this.lastSentAt < SYNC_BACKGROUND_DELAY_MS) { + return; + } + this.clearTimer(); + const patch = this.pending; + this.pending = {}; + this.urgentPending = false; + if (!uid || !this.enabled || !Object.keys(patch).length) { + return; + } + this.lastSentAt = Date.now(); + this.mergeCache(uid, patch); + setDoc(doc(this.firestore, USER_SYNC_COLLECTION, uid), patch, {merge: true}) + .catch(() => this.disable()); + } + + /** A `restart` waits for changes to stop; otherwise the earliest send already due holds. */ + private schedule(delay: number, restart = false): void { + const due = Date.now() + delay; + if (this.timer && this.dueAt <= due && !restart) { + return; + } + this.clearTimer(); + this.dueAt = due; + this.timer = setTimeout(() => this.flush(), delay); + } + + private clearTimer(): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + } + + private cacheKey(uid: string): string { + return SYNC_CACHE_KEY_PREFIX + uid; + } + + private readCache(uid: string): CachedSettings | null { + try { + const raw = localStorage.getItem(this.cacheKey(uid)); + const parsed = raw ? JSON.parse(raw) as CachedSettings : null; + return parsed && isRecord(parsed.data) ? parsed : null; + } catch { + return null; + } + } + + private writeCache(uid: string, data: RemoteUserSettings): void { + try { + localStorage.setItem(this.cacheKey(uid), JSON.stringify({data, at: Date.now()} as CachedSettings)); + } catch { + // Without the cache every load costs one read, which is still affordable. + } + } + + /** Keep the cache in step with what was just sent, so the next read stays local. */ + private mergeCache(uid: string, patch: Record): void { + const cached = this.readCache(uid); + const data = mergePatch({...(cached?.data ?? {})} as Record, patch); + this.writeCache(uid, data as RemoteUserSettings); + } +} From e8a9b9bc3c24679413683e34c4f94685fabbe4a3 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Mon, 5 Oct 2026 21:57:57 +0700 Subject: [PATCH 04/14] perf: hold study sync to one write per 30 minutes per device The interval is remembered on the device, so reloading the page no longer starts it again. That puts a hard ceiling of 48 study writes per device per day; what is held back is still sent on the next open. --- src/app/user-sync.service.ts | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/app/user-sync.service.ts b/src/app/user-sync.service.ts index b78a28d..ab2f2e2 100644 --- a/src/app/user-sync.service.ts +++ b/src/app/user-sync.service.ts @@ -19,11 +19,13 @@ export const SYNC_READ_TTL_MS = 5 * 60 * 1000; */ export const SYNC_URGENT_DELAY_MS = 2000; /** - * Study time accumulates continuously, so it is sent at most once per this interval. Leaving - * the page does not break the limit either: what is held back is still on the device, and it - * goes out the next time the app opens there. + * Study time accumulates continuously, so a device sends it at most once per this interval, + * which caps it at 48 writes a day. Leaving or reloading the page does not break the limit: + * what is held back is still on the device, and it goes out the next time the app opens there. */ -export const SYNC_BACKGROUND_DELAY_MS = 15 * 60 * 1000; +export const SYNC_BACKGROUND_DELAY_MS = 30 * 60 * 1000; +/** When this device last wrote, so the interval holds across reloads. */ +export const SYNC_SENT_AT_KEY = 'flickSyncSentAt'; export interface RemoteUserSettings { theme?: unknown; @@ -71,7 +73,7 @@ export class UserSyncService { private enabled = true; private pending: Record = {}; private urgentPending = false; - private lastSentAt = 0; + private lastSentAt = this.readSentAt(); private timer: ReturnType | null = null; private dueAt = 0; /** Every caller opening the app at once shares the one read in flight. */ @@ -185,7 +187,7 @@ export class UserSyncService { if (!uid || !this.enabled || !Object.keys(patch).length) { return; } - this.lastSentAt = Date.now(); + this.writeSentAt(Date.now()); this.mergeCache(uid, patch); setDoc(doc(this.firestore, USER_SYNC_COLLECTION, uid), patch, {merge: true}) .catch(() => this.disable()); @@ -209,6 +211,23 @@ export class UserSyncService { } } + private readSentAt(): number { + try { + return Number(localStorage.getItem(SYNC_SENT_AT_KEY)) || 0; + } catch { + return 0; + } + } + + private writeSentAt(at: number): void { + this.lastSentAt = at; + try { + localStorage.setItem(SYNC_SENT_AT_KEY, String(at)); + } catch { + // Without it the interval still holds within this page. + } + } + private cacheKey(uid: string): string { return SYNC_CACHE_KEY_PREFIX + uid; } From a2940d6264ee3eed141cacb8e879267e8f5955b7 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Tue, 6 Oct 2026 06:56:23 +0700 Subject: [PATCH 05/14] feat: return the theme to plain light when defaults are restored Restore default settings in the settings sheet now resets the theme as well: light mode, the default colours, and no background picture. The signal comes through RestoreDefaultsService, a file shared byte for byte with the settings branch, so neither branch depends on the other. --- src/app/restore-defaults.service.ts | 18 ++++++++++++++++++ src/app/theme.service.ts | 11 +++++++++++ 2 files changed, 29 insertions(+) create mode 100644 src/app/restore-defaults.service.ts diff --git a/src/app/restore-defaults.service.ts b/src/app/restore-defaults.service.ts new file mode 100644 index 0000000..adf57fc --- /dev/null +++ b/src/app/restore-defaults.service.ts @@ -0,0 +1,18 @@ +import {Injectable} from '@angular/core'; +import {Observable, Subject} from 'rxjs'; + +/** + * The app-wide "Restore default settings". Each feature that keeps preferences listens and puts + * its own back, so the button needs no knowledge of which features this build contains. + */ +@Injectable({ + providedIn: 'root', +}) +export class RestoreDefaultsService { + private readonly restoreSubject = new Subject(); + readonly restore$: Observable = this.restoreSubject.asObservable(); + + restore(): void { + this.restoreSubject.next(); + } +} diff --git a/src/app/theme.service.ts b/src/app/theme.service.ts index 8085804..cde0fe8 100644 --- a/src/app/theme.service.ts +++ b/src/app/theme.service.ts @@ -4,6 +4,7 @@ import {distinctUntilChanged, map} from 'rxjs/operators'; import {ulid} from 'ulid'; import {AuthService} from './auth.service'; import {UserSyncService} from './user-sync.service'; +import {RestoreDefaultsService} from './restore-defaults.service'; import {BLACK, isValidColor, parseColor, readableOn, toHex} from './theme/color'; import {buildThemeVariables, CssVariables, shadeForColor} from './theme/palette'; import { @@ -237,6 +238,8 @@ export class ThemeService { this.detachUser(); } }); + + inject(RestoreDefaultsService).restore$.subscribe(() => this.restorePlainLight()); } get settings(): ThemeSettings { @@ -412,6 +415,14 @@ export class ThemeService { this.update(defaultThemeSettings()); } + /** + * What "Restore default settings" means for the theme: plain light, with the default colours + * and no background picture. + */ + restorePlainLight(): void { + this.update({...defaultThemeSettings(), mode: 'light'}); + } + /** Palette for a seed without applying it, used to preview a template in the picker. */ preview(seed: ThemeSeed, shade: ThemeShade = this.settings.custom.shade): CssVariables { const custom = {...this.settings.custom, seed, shade, background: {...DEFAULT_BACKGROUND}}; From e5e764e81677321fa6c9f0490b19a3c90e857926 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Tue, 6 Oct 2026 06:56:23 +0700 Subject: [PATCH 06/14] feat: keep every background picture and add a slideshow Adding a picture no longer clears out the last one. Every picture stays in a folder on the device until the user deletes it, which the editor asks to confirm; restoring defaults or resetting the theme only stops showing it. The editor shows the folder as thumbnails, takes several pictures at once, and offers None, Picture or Slideshow, as Windows does for the desktop. The slideshow changes picture every 1 minute to 1 day, in the order the pictures were added or shuffled, with each picture shown once per round and never twice running. Timing is anchored to when it was started, so a reload lands on the same picture and a hidden tab catches up when it returns. Each change fades the old picture out and the new one in, except with reduced motion. Like the pictures, these choices stay on the device and are not synced. A theme saved with a picture before this change comes back showing it. --- src/app/shared/theme-editor.component.html | 63 ++++- src/app/shared/theme-editor.component.scss | 73 +++++- src/app/shared/theme-editor.component.ts | 115 +++++++-- src/app/theme.service.ts | 271 +++++++++++++++++++-- src/app/theme/background-store.ts | 16 +- src/app/theme/theme-presets.ts | 23 ++ src/app/theme/theme.model.ts | 18 ++ src/global.scss | 6 + 8 files changed, 513 insertions(+), 72 deletions(-) diff --git a/src/app/shared/theme-editor.component.html b/src/app/shared/theme-editor.component.html index 8e45cc8..b16134d 100644 --- a/src/app/shared/theme-editor.component.html +++ b/src/app/shared/theme-editor.component.html @@ -126,23 +126,64 @@

Plain theme

Background picture

-

Optional, and kept on this device only.

- -
- - - {{ (backgroundImageUrl$ | async) ? 'Replace' : 'Upload' }} - - @if (backgroundImageUrl$ | async; as imageUrl) { - Current background - Remove +

Kept on this device only. Pictures stay in the folder until you delete them.

+ + + @for (option of themeService.pictureModes; track option.value) { + + {{ option.label }} + + } + +

+ @switch (settings.custom.background.pictureMode) { + @case ('single') { Tap a picture to show it. } + @case ('slideshow') { Every picture in the folder takes a turn. } + @default { No picture on the page. Your pictures are still kept below. } + } +

+ +
+ @for (picture of pictures; track picture.id; let index = $index) { +
+ + +
} +
- + @if (imageError) {

{{ imageError }}

} + @if (settings.custom.background.pictureMode === 'slideshow') { + + + @for (option of themeService.slideshowIntervals; track option.minutes) { + {{ option.label }} + } + + + + Shuffle + + } + @if (backgroundImageUrl$ | async) { @for (option of themeService.backgroundFitOptions; track option.value) { diff --git a/src/app/shared/theme-editor.component.scss b/src/app/shared/theme-editor.component.scss index e13522f..353255e 100644 --- a/src/app/shared/theme-editor.component.scss +++ b/src/app/shared/theme-editor.component.scss @@ -109,19 +109,74 @@ padding-inline-end: 0; } -.image-row { - display: flex; - align-items: center; +.picture-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(5.5rem, 1fr)); gap: 0.5rem; - flex-wrap: wrap; + margin: 0.75rem 0 0.5rem; +} + +.picture-tile { + position: relative; + aspect-ratio: 16 / 10; + border-radius: 8px; + overflow: hidden; + border: 2px solid transparent; +} + +.picture-tile.selected { + border-color: var(--flick-accent-text); +} + +.picture-button { + display: block; + width: 100%; + height: 100%; + padding: 0; + border: 0; + background: none; + cursor: pointer; +} + +.picture-button:disabled { + cursor: default; } -.image-preview { - width: 4rem; - height: 2.5rem; +.picture-button img { + display: block; + width: 100%; + height: 100%; object-fit: cover; - border-radius: 6px; - border: 1px solid var(--ion-border-color, var(--ion-color-step-150)); +} + +/* Big enough to hit with a fingertip on a tablet, small enough to leave the picture visible. */ +.picture-delete { + position: absolute; + top: 0.2rem; + right: 0.2rem; + display: flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + padding: 0; + border: 0; + border-radius: 50%; + background: rgba(var(--ion-color-dark-rgb), 0.6); + color: var(--ion-color-dark-contrast); + font-size: 1rem; + cursor: pointer; +} + +.picture-add { + display: flex; + align-items: center; + justify-content: center; + border: 1px dashed var(--flick-accent-text); + background: transparent; + color: var(--flick-accent-text); + font-size: 1.5rem; + cursor: pointer; } .file-input { diff --git a/src/app/shared/theme-editor.component.ts b/src/app/shared/theme-editor.component.ts index 304c07f..19d06b0 100644 --- a/src/app/shared/theme-editor.component.ts +++ b/src/app/shared/theme-editor.component.ts @@ -1,6 +1,7 @@ -import {Component, ElementRef, inject, ViewChild} from '@angular/core'; +import {Component, ElementRef, inject, OnDestroy, ViewChild} from '@angular/core'; import {AsyncPipe} from '@angular/common'; import { + AlertController, IonButton, IonButtons, IonContent, @@ -11,8 +12,11 @@ import { IonRange, IonSegment, IonSegmentButton, + IonSelect, + IonSelectOption, IonText, IonTitle, + IonToggle, IonToolbar, ModalController, } from '@ionic/angular/standalone'; @@ -22,14 +26,20 @@ import { close, colorFillOutline, colorPaletteOutline, - imageOutline, moonOutline, phonePortraitOutline, sunnyOutline, } from 'ionicons/icons'; import {OWN_COLOR_TEMPLATE_ID, ThemeService} from '../theme.service'; import {ACCENT_SWATCHES} from '../theme/theme-presets'; -import {BackgroundFit, ThemeMode, ThemeSettings, ThemeShade, ThemeTemplate} from '../theme/theme.model'; +import { + BackgroundFit, + BackgroundPictureMode, + ThemeMode, + ThemeSettings, + ThemeShade, + ThemeTemplate, +} from '../theme/theme.model'; function detailValue(event: Event): T | undefined { return (event as CustomEvent<{value?: T}>).detail?.value; @@ -41,28 +51,46 @@ function detailValue(event: Event): T | undefined { styleUrls: ['./theme-editor.component.scss'], imports: [ IonHeader, IonToolbar, IonTitle, IonButtons, IonButton, IonIcon, IonContent, - IonSegment, IonSegmentButton, IonLabel, IonItem, IonRange, IonText, AsyncPipe, + IonSegment, IonSegmentButton, IonLabel, IonItem, IonRange, IonText, IonSelect, IonSelectOption, + IonToggle, AsyncPipe, ], }) -export class ThemeEditorComponent { +export class ThemeEditorComponent implements OnDestroy { protected themeService = inject(ThemeService); private modalCtrl = inject(ModalController); + private alertCtrl = inject(AlertController); @ViewChild('fileInput') fileInput: ElementRef; protected readonly accentSwatches = ACCENT_SWATCHES; protected readonly settings$ = this.themeService.settings$; protected readonly backgroundImageUrl$ = this.themeService.backgroundImageUrl$; + protected readonly shownPictureId$ = this.themeService.shownPictureId$; protected readonly savedColors$ = this.themeService.savedColors$; protected imageError: string | null = null; + /** The folder's pictures with a URL each, for the thumbnails. */ + protected pictures: {id: string, url: string}[] = []; + + private thumbnailUrls = new Map(); + private thumbnailTicket = 0; + private readonly pictureSubscription = this.themeService.pictureIds$ + .subscribe(ids => void this.loadThumbnails(ids)); constructor() { addIcons({ - add, close, colorFillOutline, colorPaletteOutline, imageOutline, + add, close, colorFillOutline, colorPaletteOutline, sunnyOutline, moonOutline, phonePortraitOutline, }); } + ngOnDestroy(): void { + this.pictureSubscription.unsubscribe(); + for (const url of this.thumbnailUrls.values()) { + URL.revokeObjectURL(url); + } + this.thumbnailUrls.clear(); + } + close(): void { this.modalCtrl.dismiss(); } @@ -167,20 +195,79 @@ export class ThemeEditorComponent { async onImageSelected(event: Event): Promise { const input = event.target as HTMLInputElement; - const file = input.files?.[0]; + const files = Array.from(input.files ?? []); input.value = ''; - if (!file) { + if (!files.length) { return; } - try { - await this.themeService.setBackgroundImage(file); - } catch { - this.imageError = 'That picture could not be used. Please try another one.'; + const failed = await this.themeService.addBackgroundImages(files); + if (failed === files.length) { + this.imageError = files.length === 1 + ? 'That picture could not be used. Please try another one.' + : 'Those pictures could not be used. Please try others.'; + } else if (failed) { + this.imageError = `${failed} of the ${files.length} pictures could not be used.`; } } - async removeImage(): Promise { + onPictureModeChange(event: Event): void { + const value = detailValue(event); + if (value) { + this.themeService.setPictureMode(value); + } + } + + onIntervalChange(event: Event): void { + const value = detailValue(event); + if (typeof value === 'number') { + this.themeService.setSlideshowInterval(value); + } + } + + onShuffleChange(event: Event): void { + this.themeService.setSlideshowShuffle((event as CustomEvent<{checked: boolean}>).detail.checked); + } + + showPicture(id: string): void { this.imageError = null; - await this.themeService.clearBackgroundImage(); + this.themeService.showPicture(id); + } + + /** Deleting is the one thing that takes a picture out of the folder, so it asks first. */ + async deletePicture(id: string): Promise { + const alert = await this.alertCtrl.create({ + header: 'Delete this picture?', + message: 'It will be removed from this device. This cannot be undone.', + buttons: [ + {text: 'Cancel', role: 'cancel'}, + {text: 'Delete', role: 'destructive', handler: () => void this.themeService.deletePicture(id)}, + ], + }); + await alert.present(); + } + + /** A later list supersedes one still loading, and each URL is made once and revoked once. */ + private async loadThumbnails(ids: string[]): Promise { + const ticket = ++this.thumbnailTicket; + for (const id of ids) { + if (!this.thumbnailUrls.has(id)) { + const blob = await this.themeService.loadPicture(id); + if (blob && !this.thumbnailUrls.has(id)) { + this.thumbnailUrls.set(id, URL.createObjectURL(blob)); + } + } + } + if (ticket !== this.thumbnailTicket) { + return; + } + for (const [id, url] of this.thumbnailUrls) { + if (!ids.includes(id)) { + URL.revokeObjectURL(url); + this.thumbnailUrls.delete(id); + } + } + this.pictures = ids + .filter(id => this.thumbnailUrls.has(id)) + .map(id => ({id, url: this.thumbnailUrls.get(id) as string})); } } diff --git a/src/app/theme.service.ts b/src/app/theme.service.ts index cde0fe8..9fcaac8 100644 --- a/src/app/theme.service.ts +++ b/src/app/theme.service.ts @@ -9,6 +9,7 @@ import {BLACK, isValidColor, parseColor, readableOn, toHex} from './theme/color' import {buildThemeVariables, CssVariables, shadeForColor} from './theme/palette'; import { BACKGROUND_FIT_OPTIONS, + BACKGROUND_PICTURE_MODES, DEFAULT_BACKGROUND, defaultCustomTheme, defaultThemeSettings, @@ -23,10 +24,12 @@ import { OWN_COLOR_TEMPLATE_ID, THEME_MODES, THEME_TEMPLATES, + SLIDESHOW_INTERVALS, } from './theme/theme-presets'; import {BackgroundImageStore, prepareBackgroundImage} from './theme/background-store'; import { BackgroundFit, + BackgroundPictureMode, ColorScheme, ThemeShade, ThemeTemplate, @@ -48,6 +51,12 @@ export const SAVED_COLORS_KEY_PREFIX = 'flickThemeColors_'; /** The shade last chosen for each colour and template. Kept on the device, like the rest. */ export const SHADE_MEMORY_KEY_PREFIX = 'flickThemeShades_'; export const BACKGROUND_IMAGE_CLASS = 'flick-has-background-image'; +/** Fades the picture out while the slideshow swaps it, for the length of BACKGROUND_FADE_MS. */ +export const BACKGROUND_FADING_CLASS = 'flick-background-fading'; +/** Kept in step with the opacity transition on the picture in the global stylesheet. */ +export const BACKGROUND_FADE_MS = 600; +/** The longest delay setTimeout honours; anything above it fires at once. */ +const MAX_TIMEOUT_MS = 2 ** 31 - 1; /** * Set on the document element for the length of a change of theme. The colour variables are * interpolated while it is present, so every surface reading them moves together instead of @@ -119,6 +128,64 @@ function sanitizeColor(value: unknown): string | null { return typeof value === 'string' && isValidColor(value) ? value : null; } +/** Which picture shows is decided on each device, since the pictures themselves stay there. */ +type DevicePicture = Pick; + +function devicePicture(background: ThemeBackground): DevicePicture { + const {imageId, pictureMode, slideshowMinutes, slideshowShuffle, slideshowSince} = background; + return {imageId, pictureMode, slideshowMinutes, slideshowShuffle, slideshowSince}; +} + +/** The same order every time for a given seed, so a reload lands on the same picture. */ +function shuffled(ids: string[], seed: number): string[] { + const order = [...ids]; + let state = seed >>> 0; + const random = () => { + state = (state + 0x6D2B79F5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + for (let i = order.length - 1; i > 0; i--) { + const j = Math.floor(random() * (i + 1)); + [order[i], order[j]] = [order[j], order[i]]; + } + return order; +} + +/** + * The slideshow picture at a moment, and when it next changes. The pictures go round in the + * order they were added, or with shuffle on, in a new order each time round so that every + * picture shows once before any repeats. + */ +export function slideshowPicture(ids: string[], background: ThemeBackground, now: number): { + id: string | null, + changesAt: number | null, +} { + if (!ids.length) { + return {id: null, changesAt: null}; + } + const interval = background.slideshowMinutes * 60000; + const since = Math.min(background.slideshowSince, now); + const step = Math.floor((now - since) / interval); + const round = Math.floor(step / ids.length); + // Two pictures can only alternate, and shuffling them would show one twice running. + let order = ids; + if (background.slideshowShuffle && ids.length > 2) { + order = shuffled(ids, since + round); + // A new round must not open on the picture the last one ended with. + if (round > 0 && order[0] === shuffled(ids, since + round - 1)[ids.length - 1]) { + order = [order[1], order[0], ...order.slice(2)]; + } + } + return { + id: order[step % ids.length], + changesAt: ids.length > 1 ? since + (step + 1) * interval : null, + }; +} + function sanitizeSeed(raw: unknown): ThemeSeed { const seed = (raw ?? {}) as Partial; return { @@ -156,6 +223,15 @@ function sanitizeCustom(raw: unknown): CustomTheme { imageFit: BACKGROUND_FIT_OPTIONS.some(option => option.value === background.imageFit) ? background.imageFit as BackgroundFit : DEFAULT_BACKGROUND.imageFit, + // Saved before there was a choice, a stored picture meant it was on show. + pictureMode: BACKGROUND_PICTURE_MODES.some(option => option.value === background.pictureMode) + ? background.pictureMode as BackgroundPictureMode + : (typeof background.imageId === 'string' ? 'single' : 'none'), + slideshowMinutes: SLIDESHOW_INTERVALS.some(option => option.minutes === background.slideshowMinutes) + ? background.slideshowMinutes as number + : DEFAULT_BACKGROUND.slideshowMinutes, + slideshowShuffle: background.slideshowShuffle === true, + slideshowSince: Math.max(0, Number(background.slideshowSince) || 0), }, }; } @@ -185,16 +261,24 @@ export class ThemeService { readonly standardModes = STANDARD_MODES; readonly shades = THEME_SHADES; readonly backgroundFitOptions = BACKGROUND_FIT_OPTIONS; + readonly pictureModes = BACKGROUND_PICTURE_MODES; + readonly slideshowIntervals = SLIDESHOW_INTERVALS; private readonly settingsSubject = new BehaviorSubject(defaultThemeSettings()); private readonly schemeSubject = new BehaviorSubject('light'); private readonly imageUrlSubject = new BehaviorSubject(null); private readonly savedColorsSubject = new BehaviorSubject([]); + private readonly pictureIdsSubject = new BehaviorSubject([]); + private readonly shownPictureSubject = new BehaviorSubject(null); readonly settings$: Observable = this.settingsSubject.asObservable(); readonly scheme$: Observable = this.schemeSubject.asObservable(); readonly backgroundImageUrl$: Observable = this.imageUrlSubject.asObservable(); readonly savedColors$: Observable = this.savedColorsSubject.asObservable(); + /** Every picture kept on this device, oldest first. */ + readonly pictureIds$: Observable = this.pictureIdsSubject.asObservable(); + /** The picture on the page right now, which the slideshow moves along. */ + readonly shownPictureId$: Observable = this.shownPictureSubject.asObservable(); readonly mode$: Observable = this.settings$.pipe( map(settings => settings.mode), distinctUntilChanged(), @@ -209,6 +293,9 @@ export class ThemeService { private animationTimer: number | null = null; private tweenFrame: number | null = null; private objectUrl: string | null = null; + private pictureTicket = 0; + private slideshowTimer: number | null = null; + private libraryLoaded = this.reloadLibrary(); private darkQuery: MediaQueryList | null = null; constructor() { @@ -216,8 +303,17 @@ export class ThemeService { this.darkQuery = window.matchMedia?.('(prefers-color-scheme: dark)') ?? null; const onSystemChange = () => this.apply(this.settingsSubject.value, false); this.darkQuery?.addEventListener('change', onSystemChange); + // A hidden tab's timers are held back, so a slideshow catches up when the page returns. + const onVisible = () => { + if (!document.hidden) { + void this.showBackgroundPicture(this.resolve(this.settings).background, true); + } + }; + document.addEventListener('visibilitychange', onVisible); destroyRef.onDestroy(() => { this.darkQuery?.removeEventListener('change', onSystemChange); + document.removeEventListener('visibilitychange', onVisible); + this.clearSlideshowTimer(); if (this.animationTimer !== null) { clearTimeout(this.animationTimer); } @@ -331,21 +427,89 @@ export class ThemeService { this.updateCustom({background: {...this.settings.custom.background, imageBlur: clamp(imageBlur, 0, 20)}}); } - /** Store an uploaded picture on this device and use it as the page background. */ - async setBackgroundImage(file: File): Promise { - const image = await prepareBackgroundImage(file); - const imageId = ulid(); - await this.imageStore.save(imageId, image); - await this.imageStore.prune(imageId); - this.updateCustom({background: {...this.settings.custom.background, imageId}}); + /** + * Add pictures to this device's folder. The last one goes on show, unless a slideshow is + * running, which takes it into the rotation instead. Returns how many could not be used. + */ + async addBackgroundImages(files: File[]): Promise { + let failed = 0; + let added: string | null = null; + for (const file of files) { + try { + const image = await prepareBackgroundImage(file); + const id = ulid(); + await this.imageStore.save(id, image); + added = id; + } catch { + failed++; + } + } + await (this.libraryLoaded = this.reloadLibrary()); + if (added) { + const background = this.settings.custom.background; + this.updateCustom({ + background: background.pictureMode === 'slideshow' + ? {...background} + : {...background, imageId: added, pictureMode: 'single'}, + }); + } + return failed; + } + + /** Show one picture from the folder, stopping a slideshow. */ + showPicture(imageId: string): void { + this.updateCustom({background: {...this.settings.custom.background, imageId, pictureMode: 'single'}}); } - async clearBackgroundImage(): Promise { - const {imageId} = this.settings.custom.background; - if (imageId) { - await this.imageStore.remove(imageId); + /** Delete a picture from this device's folder for good. The only way a picture is removed. */ + async deletePicture(id: string): Promise { + await this.imageStore.remove(id); + await (this.libraryLoaded = this.reloadLibrary()); + const ids = this.pictureIdsSubject.value; + const background = this.settings.custom.background; + this.update({ + custom: { + ...this.settings.custom, + background: { + ...background, + imageId: background.imageId === id ? ids[ids.length - 1] ?? null : background.imageId, + pictureMode: ids.length ? background.pictureMode : 'none', + }, + }, + }); + } + + loadPicture(id: string): Promise { + return this.imageStore.load(id); + } + + /** None hides the picture without forgetting it; a slideshow starts from its first picture. */ + setPictureMode(pictureMode: BackgroundPictureMode): void { + const background = this.settings.custom.background; + const ids = this.pictureIdsSubject.value; + this.updateCustom({ + background: { + ...background, + pictureMode, + imageId: background.imageId ?? ids[ids.length - 1] ?? null, + slideshowSince: pictureMode === 'slideshow' ? Date.now() : background.slideshowSince, + }, + }); + } + + setSlideshowInterval(slideshowMinutes: number): void { + if (!SLIDESHOW_INTERVALS.some(option => option.minutes === slideshowMinutes)) { + return; } - this.updateCustom({background: {...this.settings.custom.background, imageId: null}}); + this.updateCustom({ + background: {...this.settings.custom.background, slideshowMinutes, slideshowSince: Date.now()}, + }); + } + + setSlideshowShuffle(slideshowShuffle: boolean): void { + this.updateCustom({ + background: {...this.settings.custom.background, slideshowShuffle, slideshowSince: Date.now()}, + }); } get savedColors(): string[] { @@ -417,7 +581,7 @@ export class ThemeService { /** * What "Restore default settings" means for the theme: plain light, with the default colours - * and no background picture. + * and no picture on show. The pictures themselves stay in this device's folder. */ restorePlainLight(): void { this.update({...defaultThemeSettings(), mode: 'light'}); @@ -508,7 +672,7 @@ export class ThemeService { this.schemeSubject.next(scheme); this.settingsSubject.next(settings); this.writeAppliedMirror(scheme, variables); - void this.applyBackgroundImage(background.imageId); + void this.showBackgroundPicture(background, false); if (persist) { this.writeLocal(settings); @@ -589,16 +753,71 @@ export class ThemeService { } } - private async applyBackgroundImage(imageId: string | null): Promise { + private async reloadLibrary(): Promise { + this.pictureIdsSubject.next(await this.imageStore.keys()); + } + + /** + * Put the right picture on the page for this background and time the slideshow's next + * change. A later call supersedes an earlier one still loading, so a quick run of theme + * edits cannot leave an older picture on show. + */ + private async showBackgroundPicture(background: ThemeBackground, fade: boolean): Promise { + const ticket = ++this.pictureTicket; + this.clearSlideshowTimer(); + document.body.classList.remove(BACKGROUND_FADING_CLASS); + + let imageId = background.pictureMode === 'single' ? background.imageId : null; + if (background.pictureMode === 'slideshow') { + await this.libraryLoaded; + if (ticket !== this.pictureTicket) { + return; + } + const next = slideshowPicture(this.pictureIdsSubject.value, background, Date.now()); + imageId = next.id; + if (next.changesAt !== null) { + this.slideshowTimer = window.setTimeout( + () => void this.showBackgroundPicture(this.resolve(this.settings).background, true), + Math.min(next.changesAt - Date.now(), MAX_TIMEOUT_MS), + ); + } + } + if (imageId === this.shownPictureSubject.value && (imageId === null || this.objectUrl)) { + return; + } + const image = imageId ? await this.imageStore.load(imageId) : null; - this.releaseObjectUrl(); - if (image) { - this.objectUrl = URL.createObjectURL(image); + if (ticket !== this.pictureTicket) { + return; } + const url = image ? URL.createObjectURL(image) : null; + if (url && fade && this.objectUrl && !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) { + const decoder = new Image(); + decoder.src = url; + await decoder.decode().catch(() => undefined); + document.body.classList.add(BACKGROUND_FADING_CLASS); + await new Promise(resolve => setTimeout(resolve, BACKGROUND_FADE_MS)); + if (ticket !== this.pictureTicket) { + URL.revokeObjectURL(url); + return; + } + } + + this.releaseObjectUrl(); + this.objectUrl = url; const root = document.documentElement; - root.style.setProperty('--flick-background-image', this.objectUrl ? `url("${this.objectUrl}")` : 'none'); - document.body.classList.toggle(BACKGROUND_IMAGE_CLASS, !!this.objectUrl); - this.imageUrlSubject.next(this.objectUrl); + root.style.setProperty('--flick-background-image', url ? `url("${url}")` : 'none'); + document.body.classList.toggle(BACKGROUND_IMAGE_CLASS, !!url); + document.body.classList.remove(BACKGROUND_FADING_CLASS); + this.shownPictureSubject.next(url ? imageId : null); + this.imageUrlSubject.next(url); + } + + private clearSlideshowTimer(): void { + if (this.slideshowTimer !== null) { + clearTimeout(this.slideshowTimer); + this.slideshowTimer = null; + } } private releaseObjectUrl(): void { @@ -624,8 +843,8 @@ export class ThemeService { } /** - * Adopt the theme another device set more recently. The picture never travels, so - * whatever this device shows stays in place. + * Adopt the theme another device set more recently. Pictures never travel, so whatever + * this device shows, and how, stays in place. */ private async pullRemote(uid: string, sending: boolean): Promise { const remote = await this.sync.read(); @@ -640,7 +859,7 @@ export class ThemeService { ...incoming, custom: { ...incoming.custom, - background: {...incoming.custom.background, imageId: this.settings.custom.background.imageId}, + background: {...incoming.custom.background, ...devicePicture(this.settings.custom.background)}, }, }; const colors = (remote.theme as {colors?: unknown}).colors; @@ -657,7 +876,7 @@ export class ThemeService { } } - /** Everything but the picture, which is too large to send and stays where it was chosen. */ + /** Everything but the pictures, which are too large to send and stay where they were added. */ private queueSync(settings: ThemeSettings): void { if (this.userId === GUEST_ID) { return; @@ -666,7 +885,7 @@ export class ThemeService { this.sync.queue({ theme: { ...settings, - custom: {...custom, background: {...custom.background, imageId: null}}, + custom: {...custom, background: {...custom.background, ...devicePicture(DEFAULT_BACKGROUND)}}, colors: this.savedColors, }, }, true); diff --git a/src/app/theme/background-store.ts b/src/app/theme/background-store.ts index d8e8be9..a35b05e 100644 --- a/src/app/theme/background-store.ts +++ b/src/app/theme/background-store.ts @@ -28,8 +28,9 @@ function transact(mode: IDBTransactionMode, run: (store: IDBObjectStore) => I } /** - * Background images stay on the device that uploaded them. Only the small display settings - * travel between devices, so nothing large is ever written to the server. + * Every background picture the user adds, kept on the device that added it until they delete it. + * Only the small display settings travel between devices, so nothing large is ever written to + * the server. Identifiers are ULIDs, so sorting them puts the pictures in the order they came. */ export class BackgroundImageStore { save(id: string, image: Blob): Promise { @@ -48,18 +49,9 @@ export class BackgroundImageStore { keys(): Promise { return transact('readonly', store => store.getAllKeys()) - .then(keys => keys.map(String)) + .then(keys => keys.map(String).sort()) .catch(() => []); } - - /** Drop every stored image except the one still in use. */ - async prune(keepId: string | null): Promise { - for (const key of await this.keys()) { - if (key !== keepId) { - await this.remove(key); - } - } - } } function pickImageType(): string { diff --git a/src/app/theme/theme-presets.ts b/src/app/theme/theme-presets.ts index 68ed4fd..68e6a03 100644 --- a/src/app/theme/theme-presets.ts +++ b/src/app/theme/theme-presets.ts @@ -1,5 +1,7 @@ import { BackgroundFitOption, + BackgroundPictureModeOption, + SlideshowIntervalOption, ThemeShade, ThemeShadeOption, BaseScheme, @@ -392,12 +394,33 @@ export const BACKGROUND_FIT_OPTIONS: BackgroundFitOption[] = [ {value: 'contain', label: 'Fit'}, ]; +export const BACKGROUND_PICTURE_MODES: BackgroundPictureModeOption[] = [ + {value: 'none', label: 'None'}, + {value: 'single', label: 'Picture'}, + {value: 'slideshow', label: 'Slideshow'}, +]; + +/** The steps Windows offers for its desktop slideshow, plus two hours. */ +export const SLIDESHOW_INTERVALS: SlideshowIntervalOption[] = [ + {minutes: 1, label: '1 minute'}, + {minutes: 10, label: '10 minutes'}, + {minutes: 30, label: '30 minutes'}, + {minutes: 60, label: '1 hour'}, + {minutes: 120, label: '2 hours'}, + {minutes: 360, label: '6 hours'}, + {minutes: 1440, label: '1 day'}, +]; + export const DEFAULT_BACKGROUND: ThemeBackground = { color: null, imageId: null, imageOpacity: 0.35, imageBlur: 2, imageFit: 'cover', + pictureMode: 'none', + slideshowMinutes: 30, + slideshowShuffle: false, + slideshowSince: 0, }; /** The seed the standard modes use: no accent, so the base palette renders untouched. */ diff --git a/src/app/theme/theme.model.ts b/src/app/theme/theme.model.ts index 516b78a..45e813a 100644 --- a/src/app/theme/theme.model.ts +++ b/src/app/theme/theme.model.ts @@ -3,6 +3,8 @@ export type ThemeMode = 'light' | 'dark' | 'system' | 'custom'; export type ColorScheme = 'light' | 'dark'; export type SchemePreference = ColorScheme | 'system'; export type BackgroundFit = 'cover' | 'contain'; +/** No picture, one chosen picture, or every stored picture in turn. */ +export type BackgroundPictureMode = 'none' | 'single' | 'slideshow'; /** * How a chosen colour is drawn. Every colour belongs to one of these by how light it is, and @@ -37,10 +39,16 @@ export interface ThemeSeed { export interface ThemeBackground { color: string | null; + /** The picture shown in 'single' mode, kept while another mode is chosen. */ imageId: string | null; imageOpacity: number; imageBlur: number; imageFit: BackgroundFit; + pictureMode: BackgroundPictureMode; + slideshowMinutes: number; + slideshowShuffle: boolean; + /** When the slideshow's timing was last started, so it carries on across reloads. */ + slideshowSince: number; } /** @@ -93,6 +101,16 @@ export interface BackgroundFitOption { label: string; } +export interface BackgroundPictureModeOption { + value: BackgroundPictureMode; + label: string; +} + +export interface SlideshowIntervalOption { + minutes: number; + label: string; +} + export interface ThemeShadeOption { value: ThemeShade; label: string; diff --git a/src/global.scss b/src/global.scss index aa9afaa..36d1f6c 100644 --- a/src/global.scss +++ b/src/global.scss @@ -186,6 +186,12 @@ body.flick-has-background-image { background-position: center center; filter: blur(var(--flick-background-blur)); transform: scale(1.06); + /* Kept in step with BACKGROUND_FADE_MS, which waits this out before a slideshow swaps. */ + transition: opacity 0.6s ease; + } + + &.flick-background-fading ion-app::before { + opacity: 0; } ion-app::after { From 6356f4392a6328f95d2a1443dd8b270349a88d1a Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Tue, 6 Oct 2026 10:07:48 +0700 Subject: [PATCH 07/14] fix: let the theme editor scroll to its last control The editor opens as a sheet that stops at 90% of the screen, but its content was laid out for the full height, so the last part could never be scrolled into view. With expandToScroll off the content scrolls within the sheet at any height. --- src/app/shared/theme-menu.component.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/shared/theme-menu.component.ts b/src/app/shared/theme-menu.component.ts index 46676f1..b0305a0 100644 --- a/src/app/shared/theme-menu.component.ts +++ b/src/app/shared/theme-menu.component.ts @@ -51,6 +51,9 @@ export class ThemeMenuComponent { component: ThemeEditorComponent, breakpoints: [0, 0.9], initialBreakpoint: 0.9, + // Otherwise the content is laid out for the full height while the sheet stops at + // 0.9, and the last part of the editor can never be scrolled into view. + expandToScroll: false, }); await modal.present(); } From 7525f554e7c5e070ecb11ab333809db6dbb84bcc Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Tue, 6 Oct 2026 10:07:48 +0700 Subject: [PATCH 08/14] feat: give each theme colour one job and simplify the editor The colours did not do what they said. "Secondary" was the accent, and picking it also re-derived the page, flipped the shade and threw away the page colour; dragging the picker across light and dark made the page jump between shades. A dark page colour on the light shade was drawn with the light palette's roles and course colours. Each colour now changes only itself. Primary is the accent: buttons, links, switches and the heatmap. Secondary is its own colour, or Auto to follow the primary. Background is Light or Dark, tinted with the primary, or any colour used exactly. Whether text is dark or light follows the page, so a dark page always gets a dark palette. Light and Dark now always tint rather than turning the primary itself into the page when it happens to be that light or dark. The editor goes from eight sections to three: the ready-made themes, now named, one palette for whichever colour is being set, with the quick and saved colours together, and the background picture. The separate shade, plain-mode and saved-colour sections are gone, along with the per-colour shade memory that only the shade control used. --- src/app/shared/theme-editor.component.html | 147 ++++++++---------- src/app/shared/theme-editor.component.scss | 92 +++++++++-- src/app/shared/theme-editor.component.ts | 143 ++++++++++------- src/app/theme.service.ts | 171 ++++++--------------- src/app/theme/palette.ts | 8 +- src/app/theme/theme-presets.ts | 33 +++- src/app/theme/theme.model.ts | 9 ++ 7 files changed, 320 insertions(+), 283 deletions(-) diff --git a/src/app/shared/theme-editor.component.html b/src/app/shared/theme-editor.component.html index b16134d..ad0117b 100644 --- a/src/app/shared/theme-editor.component.html +++ b/src/app/shared/theme-editor.component.html @@ -12,118 +12,103 @@ @if (settings$ | async; as settings) {
-

Pre-made templates

-

Ready-made pairings first, then quick colours.

-
+

Ready-made

+

Tap one to start from it. Changing a colour below makes it your own.

+
@for (template of themeService.templates; track template.id) { - + (click)="selectTemplate(template.id)"> + + {{ template.name }} + } +
+
- +
+

Colours

+ + @for (option of colorRoles; track option.value) { + + + + {{ option.label }} + + + } + +

{{ roleHint }}

- @for (swatch of accentSwatches; track swatch) { - +
+ + @if (role === 'secondary') { + + } + @if (role === 'background') { + + }
-
-
-

Saved colours

-

Colours you keep, to pick again later.

+ @for (swatch of roleSwatches; track swatch) { + + } + @if ((savedColors$ | async)?.length) { + + } @for (colour of savedColors$ | async; track colour) { + (click)="pick(colour)"> - } @empty { -

Nothing saved yet.

} -
-
-

Primary colour

-

The page behind everything.

-
- -
-
- -
-

Secondary colour

-

Buttons, links, outlines, course cards and the heatmap.

-
- -
-
- -
-

Shade

-

- Your colour is shown just as you picked it in the shade it belongs to. The other two - adapt it: a tinted light page, or a filled dark one. -

- - @for (option of themeService.shades; track option.value) { - - - {{ option.label }} - - } - -
- -
-

Plain theme

-

Leaves your colours behind and goes back to the plain theme.

- - @for (option of themeService.standardModes; track option.value) { - - - {{ option.label }} - - } - -
-

Background picture

Kept on this device only. Pictures stay in the folder until you delete them.

diff --git a/src/app/shared/theme-editor.component.scss b/src/app/shared/theme-editor.component.scss index 353255e..927b547 100644 --- a/src/app/shared/theme-editor.component.scss +++ b/src/app/shared/theme-editor.component.scss @@ -72,17 +72,6 @@ box-shadow: 0 0 0 2px var(--ion-background-color), 0 0 0 4px var(--ion-color-primary); } -.swatch-picker { - display: inline-flex; - align-items: center; - justify-content: center; - background: var(--flick-surface-muted); - color: var(--ion-text-color); - position: relative; - overflow: hidden; -} - -.swatch-picker input, .colour-field input { position: absolute; inset: 0; @@ -247,7 +236,82 @@ ion-segment-button.ios { cursor: default; } -.saved-empty { - margin: 0; - align-self: center; +.template-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.template-option { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.3rem; + padding: 0; + border: 0; + background: none; + color: var(--flick-muted-text); + font: inherit; + font-size: 0.72rem; + cursor: pointer; +} + +.template-option .swatch { + width: 2.5rem; + height: 2.5rem; +} + +.template-option.selected { + color: var(--ion-text-color); + font-weight: 600; +} + +.template-option.selected .swatch { + box-shadow: 0 0 0 2px var(--ion-background-color), 0 0 0 4px var(--ion-color-primary); +} + +.role-segment ion-segment-button { + text-transform: none; +} + +.role-label { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.role-dot { + width: 0.8rem; + height: 0.8rem; + border: 1px solid var(--ion-border-color, var(--ion-color-step-150)); + border-radius: 50%; +} + +.role-hint { + margin-top: 0.5rem; +} + +.colour-row { + flex-wrap: wrap; + margin-bottom: 0.6rem; +} + +.choice-chip { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.35rem 0.75rem; + border: 1px solid var(--ion-border-color, var(--ion-color-step-150)); + border-radius: 999px; + background: none; + color: var(--flick-muted-text); + font: inherit; + font-size: 0.8rem; + cursor: pointer; +} + +.choice-chip.selected { + border-color: var(--flick-accent-text); + color: var(--flick-accent-text); + font-weight: 600; } diff --git a/src/app/shared/theme-editor.component.ts b/src/app/shared/theme-editor.component.ts index 19d06b0..428e20e 100644 --- a/src/app/shared/theme-editor.component.ts +++ b/src/app/shared/theme-editor.component.ts @@ -21,23 +21,16 @@ import { ModalController, } from '@ionic/angular/standalone'; import {addIcons} from 'ionicons'; -import { - add, - close, - colorFillOutline, - colorPaletteOutline, - moonOutline, - phonePortraitOutline, - sunnyOutline, -} from 'ionicons/icons'; -import {OWN_COLOR_TEMPLATE_ID, ThemeService} from '../theme.service'; -import {ACCENT_SWATCHES} from '../theme/theme-presets'; +import {add, close, moonOutline, sunnyOutline} from 'ionicons/icons'; +import {ThemeService} from '../theme.service'; +import {CssVariables} from '../theme/palette'; +import {ACCENT_SWATCHES, COLOR_ROLES, PAGE_SWATCHES} from '../theme/theme-presets'; import { BackgroundFit, BackgroundPictureMode, - ThemeMode, + ColorRole, + CustomTheme, ThemeSettings, - ThemeShade, ThemeTemplate, } from '../theme/theme.model'; @@ -62,7 +55,9 @@ export class ThemeEditorComponent implements OnDestroy { @ViewChild('fileInput') fileInput: ElementRef; - protected readonly accentSwatches = ACCENT_SWATCHES; + protected readonly colorRoles = COLOR_ROLES; + /** The colour the palette below sets. */ + protected role: ColorRole = 'primary'; protected readonly settings$ = this.themeService.settings$; protected readonly backgroundImageUrl$ = this.themeService.backgroundImageUrl$; protected readonly shownPictureId$ = this.themeService.shownPictureId$; @@ -71,16 +66,14 @@ export class ThemeEditorComponent implements OnDestroy { /** The folder's pictures with a URL each, for the thumbnails. */ protected pictures: {id: string, url: string}[] = []; + private paletteCache: {custom: CustomTheme, variables: CssVariables} | null = null; private thumbnailUrls = new Map(); private thumbnailTicket = 0; private readonly pictureSubscription = this.themeService.pictureIds$ .subscribe(ids => void this.loadThumbnails(ids)); constructor() { - addIcons({ - add, close, colorFillOutline, colorPaletteOutline, - sunnyOutline, moonOutline, phonePortraitOutline, - }); + addIcons({add, close, sunnyOutline, moonOutline}); } ngOnDestroy(): void { @@ -99,12 +92,73 @@ export class ThemeEditorComponent implements OnDestroy { this.themeService.selectTemplate(templateId); } - selectAccent(accent: string): void { - this.themeService.setAccent(accent); + get roleHint(): string { + return this.colorRoles.find(option => option.value === this.role)?.hint ?? ''; + } + + /** Pages need colours text can sit on; the other two roles take any accent. */ + get roleSwatches(): string[] { + return this.role === 'background' ? PAGE_SWATCHES : ACCENT_SWATCHES; + } + + onRoleChange(event: Event): void { + const value = detailValue(event); + if (value) { + this.role = value; + } + } + + /** Sets the chosen role's colour and nothing else. */ + pick(colour: string): void { + if (this.role === 'primary') { + this.themeService.setAccent(colour); + } else if (this.role === 'secondary') { + this.themeService.setSecondaryColor(colour); + } else { + this.themeService.setPage(colour); + } + } + + onColorInput(event: Event): void { + this.pick((event.target as HTMLInputElement).value); + } + + useAutoSecondary(): void { + this.themeService.setSecondaryColor(null); + } + + useDerivedPage(page: 'light' | 'dark'): void { + this.themeService.setPage(page); + } + + isDerivedPage(settings: ThemeSettings, page: 'light' | 'dark'): boolean { + return settings.custom.background.color === null && settings.custom.shade === page; + } + + /** The colour a role ends up as, which for Auto and a derived page is the one worked out. */ + roleColor(role: ColorRole, settings: ThemeSettings): string { + const custom = settings.custom; + const palette = this.palette(custom); + if (role === 'primary') { + return custom.seed.accent ?? palette['--ion-color-primary']; + } + if (role === 'secondary') { + return custom.seed.companion ?? palette['--ion-color-secondary']; + } + return custom.background.color ?? palette['--ion-background-color']; + } + + /** Only a colour the reader chose is marked, never one the theme worked out for them. */ + isChosen(settings: ThemeSettings, swatch: string): boolean { + const custom = settings.custom; + const chosen = this.role === 'primary' ? custom.seed.accent + : this.role === 'secondary' ? custom.seed.companion + : custom.background.color; + return chosen?.toLowerCase() === swatch.toLowerCase(); } saveColour(settings: ThemeSettings): void { - this.themeService.saveColor(this.accentValue(settings)); + this.themeService.saveColor(this.roleColor(this.role, settings)); } removeColour(colour: string): void { @@ -112,14 +166,7 @@ export class ThemeEditorComponent implements OnDestroy { } isSaved(settings: ThemeSettings): boolean { - return this.themeService.isColorSaved(this.accentValue(settings)); - } - - onShadeChange(event: Event): void { - const value = detailValue(event); - if (value) { - this.themeService.setCustomShade(value); - } + return this.themeService.isColorSaved(this.roleColor(this.role, settings)); } reset(): void { @@ -137,28 +184,6 @@ export class ThemeEditorComponent implements OnDestroy { }; } - isAccent(settings: ThemeSettings, swatch: string): boolean { - return settings.custom.templateId === OWN_COLOR_TEMPLATE_ID - && settings.custom.seed.accent?.toLowerCase() === swatch.toLowerCase(); - } - - accentValue(settings: ThemeSettings): string { - return settings.custom.seed.accent - ?? this.themeService.preview(settings.custom.seed)['--ion-color-primary']; - } - - backgroundValue(settings: ThemeSettings): string { - return this.themeService.customPageColor(settings.custom); - } - - /** The plain modes replace the custom theme rather than recolouring it. */ - onModeChange(event: Event): void { - const value = detailValue(event); - if (value) { - this.themeService.setMode(value); - } - } - onFitChange(event: Event): void { const value = detailValue(event); if (value) { @@ -180,14 +205,6 @@ export class ThemeEditorComponent implements OnDestroy { } } - onAccentInput(event: Event): void { - this.themeService.setAccent((event.target as HTMLInputElement).value); - } - - onBackgroundColorInput(event: Event): void { - this.themeService.setBackgroundColor((event.target as HTMLInputElement).value); - } - pickImage(): void { this.imageError = null; this.fileInput?.nativeElement.click(); @@ -246,6 +263,14 @@ export class ThemeEditorComponent implements OnDestroy { await alert.present(); } + /** Worked out once per change of theme, however many times the template asks. */ + private palette(custom: CustomTheme): CssVariables { + if (this.paletteCache?.custom !== custom) { + this.paletteCache = {custom, variables: this.themeService.customVariables(custom)}; + } + return this.paletteCache.variables; + } + /** A later list supersedes one still loading, and each URL is made once and revoked once. */ private async loadThumbnails(ids: string[]): Promise { const ticket = ++this.thumbnailTicket; diff --git a/src/app/theme.service.ts b/src/app/theme.service.ts index 9fcaac8..9d24da8 100644 --- a/src/app/theme.service.ts +++ b/src/app/theme.service.ts @@ -17,10 +17,8 @@ import { DEFAULT_CUSTOM_SHADE, THEME_SHADES, NEUTRAL_SEED, - MAX_REMEMBERED_SHADES, MAX_SAVED_COLORS, OWN_COLOR_INTENSITY, - STANDARD_MODES, OWN_COLOR_TEMPLATE_ID, THEME_MODES, THEME_TEMPLATES, @@ -32,7 +30,6 @@ import { BackgroundPictureMode, ColorScheme, ThemeShade, - ThemeTemplate, ThemeVariables, CustomTheme, SchemePreference, @@ -48,8 +45,6 @@ export const APPLIED_THEME_KEY = 'flickThemeApplied'; export const THEME_STORAGE_KEY_PREFIX = 'flickTheme_'; /** Colours the reader has kept. They travel with the theme; the background picture does not. */ export const SAVED_COLORS_KEY_PREFIX = 'flickThemeColors_'; -/** The shade last chosen for each colour and template. Kept on the device, like the rest. */ -export const SHADE_MEMORY_KEY_PREFIX = 'flickThemeShades_'; export const BACKGROUND_IMAGE_CLASS = 'flick-has-background-image'; /** Fades the picture out while the slideshow swaps it, for the length of BACKGROUND_FADE_MS. */ export const BACKGROUND_FADING_CLASS = 'flick-background-fading'; @@ -258,8 +253,6 @@ export class ThemeService { readonly modes = THEME_MODES; readonly templates = THEME_TEMPLATES; - readonly standardModes = STANDARD_MODES; - readonly shades = THEME_SHADES; readonly backgroundFitOptions = BACKGROUND_FIT_OPTIONS; readonly pictureModes = BACKGROUND_PICTURE_MODES; readonly slideshowIntervals = SLIDESHOW_INTERVALS; @@ -288,7 +281,6 @@ export class ThemeService { ); private userId = GUEST_ID; - private shadeMemory: [string, ThemeShade][] = []; private painted = false; private animationTimer: number | null = null; private tweenFrame: number | null = null; @@ -325,7 +317,6 @@ export class ThemeService { this.apply(this.readLocal(GUEST_ID), false); this.savedColorsSubject.next(this.readSavedColors(GUEST_ID)); - this.shadeMemory = this.readShadeMemory(GUEST_ID); inject(AuthService).user.subscribe(user => { if (user?.uid) { @@ -354,65 +345,53 @@ export class ThemeService { this.update({mode}); } - /** - * A template starts light, the shade it states its own colours for, unless a shade has - * since been chosen for it. - */ + /** A template brings its own page, on the light shade its colours are stated for. */ selectTemplate(templateId: string): void { const template = findTemplate(templateId); if (!template) { return; } - const shade = this.rememberedShade(templateId) ?? DEFAULT_CUSTOM_SHADE; this.updateCustom({ templateId: template.id, - shade, + shade: DEFAULT_CUSTOM_SHADE, seed: {...template.seed}, - background: {...this.settings.custom.background, color: this.pageForTemplate(template, shade)}, + background: {...this.settings.custom.background, color: template.background}, }); } - /** - * A colour lands on the shade last chosen for it, or the shade it belongs to when there is - * none, so it appears as it was last seen rather than on the previous theme's page. - */ + /** The primary colour: buttons, links and highlights. The page and secondary colour stay put. */ setAccent(accent: string): void { if (!isValidColor(accent)) { return; } - this.updateCustom({ - templateId: OWN_COLOR_TEMPLATE_ID, - shade: this.rememberedShade(normalizeColor(accent)) ?? shadeForColor(accent), - seed: { - accent, - companion: null, - tertiary: null, - surfaceTint: null, - intensity: OWN_COLOR_INTENSITY, - }, - background: {...this.settings.custom.background, color: null}, - }); + this.updateSeed({accent}); } - /** The page is let go with the shade, so it is derived for the new one. */ - setCustomShade(shade: ThemeShade): void { - const custom = this.settings.custom; - this.rememberShade(custom, shade); - const template = findTemplate(custom.templateId); - this.updateCustom({ - shade, - background: { - ...custom.background, - color: template ? this.pageForTemplate(template, shade) : null, - }, - }); + /** Null hands the secondary colour back to the primary, which picks one that goes with it. */ + setSecondaryColor(companion: string | null): void { + if (companion !== null && !isValidColor(companion)) { + return; + } + this.updateSeed({companion, tertiary: null}); } - setBackgroundColor(color: string | null): void { - if (color !== null && !isValidColor(color)) { + /** + * 'light' and 'dark' derive the page from the primary colour. Any colour is the page itself, + * used exactly, with the cards and toolbars on it drawn from the same colour. Either way the + * text is dark or light by what the page needs. + */ + setPage(page: string): void { + const custom = this.settings.custom; + const derived = page === 'light' || page === 'dark'; + if (!derived && !isValidColor(page)) { return; } - this.updateCustom({background: {...this.settings.custom.background, color}}); + this.updateCustom({ + templateId: OWN_COLOR_TEMPLATE_ID, + shade: derived ? page as ThemeShade : shadeForColor(page), + seed: {...custom.seed, surfaceTint: derived ? null : page, intensity: OWN_COLOR_INTENSITY}, + background: {...custom.background, color: derived ? null : page}, + }); } setBackgroundFit(imageFit: BackgroundFit): void { @@ -550,28 +529,29 @@ export class ThemeService { this.queueSync(settings); } - /** The page the custom theme renders, whether it was named or derived. */ - customPageColor(custom: CustomTheme): string { - return custom.background.color - ?? buildThemeVariables( - custom.seed, - this.schemeFor(custom, custom.background), - custom.background, - this.templateVariables(custom), - custom.shade, - )['--ion-background-color']; + /** The palette the custom theme renders, so the editor can show the colours it ends up with. */ + customVariables(custom: CustomTheme): CssVariables { + return buildThemeVariables( + custom.seed, + this.schemeFor(custom, custom.background), + custom.background, + this.templateVariables(custom), + custom.shade, + ); } /** - * Which base palette the theme builds on. Light and dark say so; filled it follows the - * page itself, so a colour kept dark keeps dark surfaces and course colours. + * Which base palette the theme builds on. A page with a colour of its own decides by how + * light it is, so a dark page always gets light text, dark surfaces and course colours + * made for it. A page derived from the primary colour follows the shade it was asked for. */ private schemeFor(custom: CustomTheme, background: ThemeBackground): ColorScheme { - if (custom.shade !== 'fill') { - return custom.shade; + const page = background.color + ?? (custom.shade === 'fill' ? custom.seed.surfaceTint ?? custom.seed.accent : null); + if (!page) { + return custom.shade === 'dark' ? 'dark' : 'light'; } - const page = background.color ?? custom.seed.surfaceTint ?? custom.seed.accent; - const parsed = page ? parseColor(page) : null; + const parsed = parseColor(page); return parsed && readableOn(parsed) === BLACK ? 'light' : 'dark'; } @@ -587,11 +567,13 @@ export class ThemeService { this.update({...defaultThemeSettings(), mode: 'light'}); } - /** Palette for a seed without applying it, used to preview a template in the picker. */ - preview(seed: ThemeSeed, shade: ThemeShade = this.settings.custom.shade): CssVariables { - const custom = {...this.settings.custom, seed, shade, background: {...DEFAULT_BACKGROUND}}; - return buildThemeVariables( - seed, this.schemeFor(custom, custom.background), custom.background, undefined, shade); + /** Every colour change leaves the template behind, so the theme is kept as the reader set it. */ + private updateSeed(change: Partial): void { + const custom = this.settings.custom; + this.updateCustom({ + templateId: OWN_COLOR_TEMPLATE_ID, + seed: {...custom.seed, ...change, intensity: OWN_COLOR_INTENSITY}, + }); } private updateCustom(change: Partial): void { @@ -837,7 +819,6 @@ export class ThemeService { const stored = this.readLocal(uid); const adoptGuest = guestSettings && guestSettings.updatedAt > stored.updatedAt; this.savedColorsSubject.next(this.readSavedColors(uid)); - this.shadeMemory = this.readShadeMemory(uid); this.apply(adoptGuest ? guestSettings : stored, adoptGuest); void this.pullRemote(uid, !!adoptGuest); } @@ -896,58 +877,6 @@ export class ThemeService { this.userId = GUEST_ID; this.apply(this.readLocal(GUEST_ID), false); this.savedColorsSubject.next(this.readSavedColors(GUEST_ID)); - this.shadeMemory = this.readShadeMemory(GUEST_ID); - } - - /** - * A template states its colours for the light shade, so its own page belongs to that shade - * alone; any other derives the page from the colour instead. - */ - private pageForTemplate(template: ThemeTemplate, shade: ThemeShade): string | null { - return shade === DEFAULT_CUSTOM_SHADE ? template.background : null; - } - - /** What a colour or template is remembered under: templates keep their own identity. */ - private shadeKey(custom: CustomTheme): string | null { - return custom.templateId === OWN_COLOR_TEMPLATE_ID - ? normalizeColor(custom.seed.accent) - : custom.templateId; - } - - private rememberedShade(key: string | null): ThemeShade | null { - return key ? this.shadeMemory.find(([saved]) => saved === key)?.[1] ?? null : null; - } - - private rememberShade(custom: CustomTheme, shade: ThemeShade): void { - const key = this.shadeKey(custom); - if (!key) { - return; - } - this.shadeMemory = [[key, shade] as [string, ThemeShade], - ...this.shadeMemory.filter(([saved]) => saved !== key)] - .slice(0, MAX_REMEMBERED_SHADES); - try { - localStorage.setItem(SHADE_MEMORY_KEY_PREFIX + this.userId, JSON.stringify(this.shadeMemory)); - } catch { - // Storage may be unavailable; the shade still holds for this session. - } - } - - private readShadeMemory(uid: string): [string, ThemeShade][] { - try { - const raw = localStorage.getItem(SHADE_MEMORY_KEY_PREFIX + uid); - const parsed: unknown = raw ? JSON.parse(raw) : null; - if (!Array.isArray(parsed)) { - return []; - } - return parsed - .filter((entry): entry is [string, ThemeShade] => Array.isArray(entry) - && typeof entry[0] === 'string' - && THEME_SHADES.some(option => option.value === entry[1])) - .slice(0, MAX_REMEMBERED_SHADES); - } catch { - return []; - } } private readSavedColors(uid: string): string[] { diff --git a/src/app/theme/palette.ts b/src/app/theme/palette.ts index 4f09ffa..89fed57 100644 --- a/src/app/theme/palette.ts +++ b/src/app/theme/palette.ts @@ -118,9 +118,9 @@ export function shadeForColor(color: string | null): ThemeShade { } /** - * The page a theme is drawn on when it does not name one. In the shade the colour belongs to, - * and whenever the page is filled, the colour is used as picked, so choosing that shade shows - * the colour itself. The other shades adapt it into a page they can carry. + * The page a theme is drawn on when it does not name one. Light and dark always adapt the + * colour into a page of that lightness, so the choice means the same whatever colour it is + * tinted with; only a filled page is the colour itself. */ function derivePage( surfaceTint: Rgb, @@ -129,7 +129,7 @@ function derivePage( intensity: number, derived: boolean, ): Rgb { - if (derived && (shade === 'fill' || shade === shadeOf(surfaceTint))) { + if (derived && shade === 'fill') { return surfaceTint; } if (shade === 'dark' && derived) { diff --git a/src/app/theme/theme-presets.ts b/src/app/theme/theme-presets.ts index 68e6a03..2941ca9 100644 --- a/src/app/theme/theme-presets.ts +++ b/src/app/theme/theme-presets.ts @@ -1,5 +1,6 @@ import { BackgroundFitOption, + ColorRoleOption, BackgroundPictureModeOption, SlideshowIntervalOption, ThemeShade, @@ -174,9 +175,6 @@ export const THEME_MODES: ThemeModeOption[] = [ export const DEFAULT_MODE = THEME_MODES[2].value; -/** The modes that are not a custom theme, offered inside the editor as the way back out. */ -export const STANDARD_MODES = THEME_MODES.filter(option => option.value !== 'custom'); - export const DEFAULT_CUSTOM_SHADE: ThemeShade = 'light'; export const THEME_SHADES: ThemeShadeOption[] = [ @@ -383,11 +381,38 @@ export const ACCENT_SWATCHES: string[] = [ '#be123c', '#0369a1', '#4d7c0f', '#475569', ]; +/** + * Pages offered for the background, light ones first. Each reads clearly under black or white + * text, so whichever the page calls for clears the contrast rule. + */ +export const PAGE_SWATCHES: string[] = [ + '#ffffff', '#f6f4ef', '#eef4fb', UNIVERSITY_PAGE, FACULTY_PAGE, + '#1b1d23', '#172033', '#25182b', +]; + +/** What each colour in the editor is for, in the words the editor uses. */ +export const COLOR_ROLES: ColorRoleOption[] = [ + { + value: 'primary', + label: 'Primary', + hint: 'Buttons, links, switches and the heatmap.', + }, + { + value: 'secondary', + label: 'Secondary', + hint: 'The sign-out button and the video you are watching. Auto picks one that goes with your primary colour.', + }, + { + value: 'background', + label: 'Background', + hint: 'The page behind everything. Light and Dark are tinted with your primary colour; a colour you pick is used exactly.', + }, +]; + /** A colour picked in the editor tints the page as fully as a template does. */ export const OWN_COLOR_INTENSITY = 1; export const MAX_SAVED_COLORS = 12; -export const MAX_REMEMBERED_SHADES = 60; export const BACKGROUND_FIT_OPTIONS: BackgroundFitOption[] = [ {value: 'cover', label: 'Fill'}, diff --git a/src/app/theme/theme.model.ts b/src/app/theme/theme.model.ts index 45e813a..a73e8e2 100644 --- a/src/app/theme/theme.model.ts +++ b/src/app/theme/theme.model.ts @@ -117,6 +117,15 @@ export interface ThemeShadeOption { icon: string; } +/** The three colours the editor lets the reader set, each with one job. */ +export type ColorRole = 'primary' | 'secondary' | 'background'; + +export interface ColorRoleOption { + value: ColorRole; + label: string; + hint: string; +} + /** Neutral palette each theme starts from; the accent is blended into it by intensity. */ export interface BaseScheme { background: string; From bd00a27aa4bb02ae2d4cbb8dfe4b13d91f3d8d4c Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Tue, 6 Oct 2026 10:56:53 +0700 Subject: [PATCH 09/14] feat: move custom Light/Dark under Primary and add seven colour templates Light and Dark now sit under the Primary colour with a hint that they only shape the custom theme; the app's own modes stay in the theme menu. Background offers Auto to hand the page back to the primary colour. Adds Red, Orange, Gold, Teal, Blue, Purple and Slate templates in their own row, with swatches showing page and accent. --- src/app/shared/theme-editor.component.html | 66 ++++++++++++++-------- src/app/shared/theme-editor.component.scss | 41 ++++++++++++++ src/app/shared/theme-editor.component.ts | 31 ++++++++-- src/app/theme.service.ts | 2 + src/app/theme/palette.spec.ts | 10 ++++ src/app/theme/theme-presets.ts | 42 +++++++++++++- src/app/theme/theme.model.ts | 9 +++ 7 files changed, 169 insertions(+), 32 deletions(-) diff --git a/src/app/shared/theme-editor.component.html b/src/app/shared/theme-editor.component.html index ad0117b..5b5b5ae 100644 --- a/src/app/shared/theme-editor.component.html +++ b/src/app/shared/theme-editor.component.html @@ -14,18 +14,21 @@

Ready-made

Tap one to start from it. Changing a colour below makes it your own.

-
- @for (template of themeService.templates; track template.id) { - - } -
+ @for (group of themeService.templateGroups; track group.value) { +

{{ group.label }}

+
+ @for (template of templatesIn(group.value); track template.id) { + + } +
+ }
@@ -57,22 +60,35 @@

Colours

} @if (role === 'background') { - + [class.selected]="isAutoPage(settings)" + [attr.aria-pressed]="isAutoPage(settings)" + (click)="useAutoPage(settings)">Auto }
+ @if (role === 'primary') { +
+ Custom theme shade +
+ + +
+

{{ customShadeHint }}

+
+ } +
@for (swatch of roleSwatches; track swatch) {
-

{{ customShadeHint }}

} diff --git a/src/app/shared/theme-editor.component.scss b/src/app/shared/theme-editor.component.scss index 6f55807..950eaf7 100644 --- a/src/app/shared/theme-editor.component.scss +++ b/src/app/shared/theme-editor.component.scss @@ -352,7 +352,3 @@ ion-segment-button.ios { flex-wrap: wrap; gap: 0.5rem; } - -.shade-hint { - margin: 0.45rem 0 0; -} diff --git a/src/app/shared/theme-editor.component.ts b/src/app/shared/theme-editor.component.ts index 962ac0d..48d89c3 100644 --- a/src/app/shared/theme-editor.component.ts +++ b/src/app/shared/theme-editor.component.ts @@ -24,7 +24,7 @@ import {addIcons} from 'ionicons'; import {add, close, moonOutline, sunnyOutline} from 'ionicons/icons'; import {ThemeService} from '../theme.service'; import {CssVariables} from '../theme/palette'; -import {ACCENT_SWATCHES, COLOR_ROLES, CUSTOM_SHADE_HINT, PAGE_SWATCHES} from '../theme/theme-presets'; +import {ACCENT_SWATCHES, COLOR_ROLES, PAGE_SWATCHES} from '../theme/theme-presets'; import { BackgroundFit, BackgroundPictureMode, @@ -57,7 +57,6 @@ export class ThemeEditorComponent implements OnDestroy { @ViewChild('fileInput') fileInput: ElementRef; protected readonly colorRoles = COLOR_ROLES; - protected readonly customShadeHint = CUSTOM_SHADE_HINT; /** The colour the palette below sets. */ protected role: ColorRole = 'primary'; protected readonly settings$ = this.themeService.settings$; @@ -190,7 +189,7 @@ export class ThemeEditorComponent implements OnDestroy { reset(): void { this.imageError = null; - this.themeService.resetToDefault(); + this.themeService.restorePlainLight(); this.close(); } diff --git a/src/app/theme.service.ts b/src/app/theme.service.ts index 0fa4a0b..3dccc66 100644 --- a/src/app/theme.service.ts +++ b/src/app/theme.service.ts @@ -557,12 +557,8 @@ export class ThemeService { return parsed && readableOn(parsed) === BLACK ? 'light' : 'dark'; } - resetToDefault(): void { - this.update(defaultThemeSettings()); - } - /** - * What "Restore default settings" means for the theme: plain light, with the default colours + * What "Restore default settings" and the editor's reset both mean for the theme: plain light, with the default colours * and no picture on show. The pictures themselves stay in this device's folder. */ restorePlainLight(): void { diff --git a/src/app/theme/theme-presets.ts b/src/app/theme/theme-presets.ts index f3c1c18..15331af 100644 --- a/src/app/theme/theme-presets.ts +++ b/src/app/theme/theme-presets.ts @@ -371,20 +371,6 @@ export const THEME_TEMPLATES: ThemeTemplate[] = [ }, background: UNIVERSITY_PAGE, }, - { - id: 'faculty-university', - name: 'Green & Pink', - description: 'Green page with pink buttons.', - group: 'brand', - seed: { - accent: UNIVERSITY_ACCENT, - companion: null, - tertiary: null, - surfaceTint: FACULTY_ACCENT, - intensity: 1, - }, - background: FACULTY_PAGE, - }, ...COLOUR_TEMPLATES.map(({id, name, accent, page}): ThemeTemplate => ({ id, name, @@ -396,7 +382,7 @@ export const THEME_TEMPLATES: ThemeTemplate[] = [ ]; export const THEME_TEMPLATE_GROUPS: ThemeTemplateGroupOption[] = [ - {value: 'brand', label: 'Faculty & university'}, + {value: 'brand', label: 'MDCU'}, {value: 'colour', label: 'Colours'}, ]; @@ -441,14 +427,6 @@ export const COLOR_ROLES: ColorRoleOption[] = [ }, ]; -/** - * Shown beside the Light and Dark choices under Primary, which only set how the custom theme - * draws its colour. The app's own light and dark modes live in the theme menu. - */ -export const CUSTOM_SHADE_HINT = - 'Only for this custom theme: draws your primary colour on a light or dark page. ' - + 'The app\'s own Light and Dark modes are in the theme menu.'; - /** A colour picked in the editor tints the page as fully as a template does. */ export const OWN_COLOR_INTENSITY = 1; From 12c12b9f0538817b34b7d3b2e0fc3a9ec94f17c5 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Tue, 6 Oct 2026 13:06:06 +0700 Subject: [PATCH 11/14] test: expect year colours from the theme Master's tests expected colorByFolderName to return fixed hex colours. On this branch the year colours come from the theme as var(--flick-series-N) tokens, so the tests check the token for each case instead, and that each colour comes with its text colour. The values the standard themes give those tokens are checked in palette.spec.ts. --- src/helpers.spec.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/helpers.spec.ts b/src/helpers.spec.ts index ea1526d..5aa97b6 100644 --- a/src/helpers.spec.ts +++ b/src/helpers.spec.ts @@ -1,16 +1,21 @@ -import {colorByFolderName} from './helpers'; +import {colorByFolderName, contrastByFolderName} from './helpers'; +// The colours themselves come from the theme; see palette.spec.ts for the values each mode gives. describe('colorByFolderName', () => { - it('returns the mapped color for a known folder name', () => { - expect(colorByFolderName('1st year')).toBe('#00BCD4'); - expect(colorByFolderName('NLE2')).toBe('#FDD835'); + it('returns the theme colour for a known folder name', () => { + expect(colorByFolderName('1st year')).toBe('var(--flick-series-0)'); + expect(colorByFolderName('NLE2')).toBe('var(--flick-series-7)'); }); - it('falls back to gray for an unknown folder name', () => { - expect(colorByFolderName('Unknown Folder')).toBe('gray'); + it('falls back to the theme fallback colour for an unknown folder name', () => { + expect(colorByFolderName('Unknown Folder')).toBe('var(--flick-series-fallback)'); }); - it('falls back to gray for an empty string', () => { - expect(colorByFolderName('')).toBe('gray'); + it('falls back to the theme fallback colour for an empty string', () => { + expect(colorByFolderName('')).toBe('var(--flick-series-fallback)'); + }); + + it('pairs each colour with a text colour that reads on it', () => { + expect(contrastByFolderName('1st year')).toBe('var(--flick-series-0-contrast)'); }); }); From ce0e041033f9a8837a0d58144392ef25439b3e93 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Tue, 6 Oct 2026 14:15:49 +0700 Subject: [PATCH 12/14] refactor(theme): align template and style edit anchors with settings branch - re-anchor theme menu button in home, list and course toolbars - adjust global theme rules to avoid overlaps with settings stylesheets --- src/app/home/course/course.page.html | 6 +++--- src/app/home/course/course.page.ts | 4 ++-- src/app/home/home.page.html | 4 +++- src/app/home/home.page.ts | 2 +- src/app/home/list/list.page.html | 6 +++--- src/app/home/list/list.page.ts | 4 +++- src/global.scss | 18 +++++++++--------- 7 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/app/home/course/course.page.html b/src/app/home/course/course.page.html index 94e8a85..4dd3275 100644 --- a/src/app/home/course/course.page.html +++ b/src/app/home/course/course.page.html @@ -1,15 +1,15 @@ + + + {{ course ?? 'Course' }} @if (courseProgress.duration > 0) { {{ (courseProgress.duration - courseProgress.viewed) / 3600 | number:'1.0-1' }} hours left ({{ (courseProgress.viewed / courseProgress.duration) * 100 | number:'1.0-1' }}%) } - - - diff --git a/src/app/home/course/course.page.ts b/src/app/home/course/course.page.ts index 231b931..dbba1e2 100644 --- a/src/app/home/course/course.page.ts +++ b/src/app/home/course/course.page.ts @@ -61,6 +61,7 @@ interface SeekBarPointerHandlers { IonHeader, IonToolbar, IonBackButton, + IonButtons, IonTitle, IonContent, IonGrid, @@ -78,14 +79,13 @@ interface SeekBarPointerHandlers { IonItem, IonIcon, IonSearchbar, + ThemeMenuComponent, NgClass, IonProgressBar, AsyncPipe, DecimalPipe, DatePipe, PomodoroTimerComponent, - IonButtons, - ThemeMenuComponent, ] }) export class CoursePage implements OnInit, AfterViewInit, OnDestroy { diff --git a/src/app/home/home.page.html b/src/app/home/home.page.html index 889e576..6e3df6a 100644 --- a/src/app/home/home.page.html +++ b/src/app/home/home.page.html @@ -1,10 +1,12 @@ + + + MDCU Recorded Lecture - Sign out diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts index 66426f1..1bfd1dd 100644 --- a/src/app/home/home.page.ts +++ b/src/app/home/home.page.ts @@ -47,8 +47,8 @@ export interface EnrichedSearchResult extends SearchVideoResult { IonHeader, IonToolbar, IonTitle, IonButtons, IonButton, IonIcon, IonContent, IonGrid, IonRow, IonCol, IonCard, RouterLink, NgStyle, IonCardHeader, IonCardTitle, AsyncPipe, IonCardContent, IonItem, - IonLabel, IonText, IonSpinner, IonSearchbar, IonList, ThemeMenuComponent, + IonLabel, IonText, IonSpinner, IonSearchbar, IonList, ] }) export class HomePage implements OnInit { diff --git a/src/app/home/list/list.page.html b/src/app/home/list/list.page.html index 90cf8ac..794a2d8 100644 --- a/src/app/home/list/list.page.html +++ b/src/app/home/list/list.page.html @@ -1,12 +1,12 @@ - - {{ year || 'Loading'}} - + + {{ year || 'Loading'}} + Date: Tue, 6 Oct 2026 14:41:11 +0700 Subject: [PATCH 13/14] refactor(theme): add card container and inset padding to background controls - wrap slideshow and image adjustment controls in .control-card with borders and rounded corners - restore generous horizontal inset padding to .control-item - set labelPlacement and justify attributes on select and toggle - align popover menu inward with popoverOptions - improve hint typography and spacing --- src/app/shared/theme-editor.component.html | 64 +++++++++++++--------- src/app/shared/theme-editor.component.scss | 41 ++++++++++++-- src/app/shared/theme-editor.component.ts | 2 + 3 files changed, 75 insertions(+), 32 deletions(-) diff --git a/src/app/shared/theme-editor.component.html b/src/app/shared/theme-editor.component.html index fd9a18d..1b17704 100644 --- a/src/app/shared/theme-editor.component.html +++ b/src/app/shared/theme-editor.component.html @@ -169,23 +169,31 @@

Background picture

} @if (settings.custom.background.pictureMode === 'slideshow') { - - - @for (option of themeService.slideshowIntervals; track option.minutes) { - {{ option.label }} - } - - - - Shuffle - +
+ + + @for (option of themeService.slideshowIntervals; track option.minutes) { + {{ option.label }} + } + + + + Shuffle + +
} @if (backgroundImageUrl$ | async) { - + @for (option of themeService.backgroundFitOptions; track option.value) { {{ option.label }} @@ -193,19 +201,23 @@

Background picture

}
- - Visibility - - +
+ + Visibility + + - - Blur - - + + Blur + + +
}
diff --git a/src/app/shared/theme-editor.component.scss b/src/app/shared/theme-editor.component.scss index 950eaf7..2ce8161 100644 --- a/src/app/shared/theme-editor.component.scss +++ b/src/app/shared/theme-editor.component.scss @@ -88,14 +88,42 @@ display: inline-block; } +.control-card { + margin: 0.75rem 0; + border-radius: 0.75rem; + border: 1px solid var(--ion-border-color, var(--ion-color-step-150)); + background: var(--flick-surface, var(--ion-color-step-50, rgba(0, 0, 0, 0.04))); + overflow: hidden; + + ion-item:not(:last-child) { + border-bottom: 1px solid var(--ion-border-color, var(--ion-color-step-150)); + } +} + .control-item { - --padding-start: 0; - --inner-padding-end: 0; + --background: transparent; + --padding-start: 1rem; + --inner-padding-end: 1rem; + --min-height: 3rem; + font-size: 0.88rem; + color: var(--ion-text-color); +} + +.control-label { + font-size: 0.88rem; + font-weight: 500; + color: var(--ion-text-color); + flex-shrink: 0; + min-width: 4.5rem; } .control-range { - max-width: 60%; - padding-inline-end: 0; + max-width: 58%; + padding-inline: 0.5rem 0.25rem; +} + +.fit-segment { + margin: 0.75rem 0; } .picture-grid { @@ -173,8 +201,9 @@ } .hint { - margin: 0.35rem 0 0; - font-size: 0.72rem; + margin: 0.4rem 0 0.5rem; + font-size: 0.78rem; + line-height: 1.35; color: var(--flick-muted-text); } diff --git a/src/app/shared/theme-editor.component.ts b/src/app/shared/theme-editor.component.ts index 48d89c3..d180f4c 100644 --- a/src/app/shared/theme-editor.component.ts +++ b/src/app/shared/theme-editor.component.ts @@ -66,6 +66,8 @@ export class ThemeEditorComponent implements OnDestroy { protected imageError: string | null = null; /** The folder's pictures with a URL each, for the thumbnails. */ protected pictures: {id: string, url: string}[] = []; + /** Menus open from the row's right edge towards the sheet, rather than out over its edge. */ + protected readonly popoverOptions = {side: 'bottom', alignment: 'end'}; private paletteCache: {custom: CustomTheme, variables: CssVariables} | null = null; private thumbnailUrls = new Map(); From eac0f9e950d882a522d85ac433841efc4a8e1923 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Wed, 7 Oct 2026 13:31:44 +0700 Subject: [PATCH 14/14] refactor(theme): sync through FlickMan instead of Firestore - read and save the synced settings through FlickMan's v1/user_settings, so no Firestore rule is needed - wait for the ID token before the first read, so sync is not switched off while sign-in finishes - add getUserSettings and saveUserSettings to ManService --- src/app/man.service.ts | 19 ++++++++++-- src/app/user-sync.service.spec.ts | 51 +++++++++++++++++++++++++++++++ src/app/user-sync.service.ts | 37 +++++++++++++--------- 3 files changed, 91 insertions(+), 16 deletions(-) create mode 100644 src/app/user-sync.service.spec.ts diff --git a/src/app/man.service.ts b/src/app/man.service.ts index 21f5caa..70ae71c 100644 --- a/src/app/man.service.ts +++ b/src/app/man.service.ts @@ -171,6 +171,21 @@ export class ManService { return this.get('v1/auth_check').pipe(timeout(8000), map(a => a.hasOwnProperty('success'))); } + /** The settings synced across the user's devices (see UserSyncService), or null when they can't be fetched. */ + getUserSettings(): Observable { + return this.get>('v1/user_settings').pipe(map(response => response?.data ?? null)); + } + + /** Merge a change into the synced settings. `keepalive` lets the request finish while the page closes. */ + saveUserSettings(patch: object): Observable> { + return this.post>('v1/user_settings', patch, {keepalive: true}).pipe(map(response => { + if (!response) { + throw new Error('ManService could not send the user settings.'); + } + return response; + })); + } + changeEndpoint() { this.endpoint.push(this.endpoint.shift()); } @@ -186,13 +201,13 @@ export class ManService { return of(null); } - post(path: string, body: object): Observable { + post(path: string, body: object, options?: object): Observable { if (this.httpOptions.headers.get('Authorization').length < 30) { console.error('ManService ID token is not set.'); } else if (!this.getEndpointLocation()) { console.error('ManService endpoint is not set.'); } else { - return this.http.post(this.getEndpointLocation() + path, body, this.httpOptions); + return this.http.post(this.getEndpointLocation() + path, body, {...this.httpOptions, ...options}); } return of(null); } diff --git a/src/app/user-sync.service.spec.ts b/src/app/user-sync.service.spec.ts new file mode 100644 index 0000000..2772876 --- /dev/null +++ b/src/app/user-sync.service.spec.ts @@ -0,0 +1,51 @@ +import {TestBed} from '@angular/core/testing'; +import {BehaviorSubject, of} from 'rxjs'; +import {AuthService} from './auth.service'; +import {ManService} from './man.service'; +import {UserSyncService} from './user-sync.service'; + +describe('UserSyncService', () => { + let idToken: BehaviorSubject; + let manService: jasmine.SpyObj>; + let sync: UserSyncService; + + beforeEach(() => { + localStorage.clear(); + idToken = new BehaviorSubject(null); + manService = jasmine.createSpyObj('ManService', ['getUserSettings', 'saveUserSettings']); + manService.getUserSettings.and.returnValue(of({theme: {}})); + + TestBed.configureTestingModule({ + providers: [ + {provide: AuthService, useValue: {idToken}}, + {provide: ManService, useValue: manService}, + ], + }); + sync = TestBed.inject(UserSyncService); + }); + + afterEach(() => localStorage.clear()); + + it('waits for the ID token before asking FlickMan, so signing in does not switch sync off', async () => { + sync.attach('user-1'); + const reading = sync.read(); + await Promise.resolve(); + expect(manService.getUserSettings).not.toHaveBeenCalled(); + + idToken.next('token'); + + expect(await reading).toEqual({theme: {}}); + expect(manService.getUserSettings).toHaveBeenCalledTimes(1); + }); + + it('drops a read still waiting for the token when the user signs out', async () => { + sync.attach('user-1'); + const reading = sync.read(); + sync.detach(); + + idToken.next('token'); + + expect(await reading).toBeNull(); + expect(manService.getUserSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/user-sync.service.ts b/src/app/user-sync.service.ts index ab2f2e2..dd4faaa 100644 --- a/src/app/user-sync.service.ts +++ b/src/app/user-sync.service.ts @@ -1,16 +1,17 @@ import {DestroyRef, inject, Injectable} from '@angular/core'; -import {BehaviorSubject, Observable} from 'rxjs'; -import {doc, Firestore, getDoc, setDoc} from '@angular/fire/firestore'; +import {BehaviorSubject, firstValueFrom, Observable} from 'rxjs'; +import {filter} from 'rxjs/operators'; import {getValue, RemoteConfig} from '@angular/fire/remote-config'; +import {AuthService} from './auth.service'; +import {ManService} from './man.service'; -export const USER_SYNC_COLLECTION = 'userSettings'; export const SYNC_CACHE_KEY_PREFIX = 'flickSync_'; /** A remote config flag, so sync can be switched off without a release. */ export const SYNC_ENABLED_KEY = 'userSyncEnabled'; /** - * Reads are billed per document, so the whole account lives in one document that is fetched - * at most once per this interval however many times the app is opened or reloaded. + * The whole account lives in one document on FlickMan (`v1/user_settings`), fetched at most + * once per this interval however many times the app is opened or reloaded. */ export const SYNC_READ_TTL_MS = 5 * 60 * 1000; /** @@ -29,6 +30,7 @@ export const SYNC_SENT_AT_KEY = 'flickSyncSentAt'; export interface RemoteUserSettings { theme?: unknown; + pomodoro?: unknown; study?: {devices?: Record}; } @@ -41,7 +43,7 @@ function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } -/** Deep merge, so a queued patch never discards another part of the document. */ +/** Deep merge, so a queued patch never discards another part of the document. FlickMan merges the same way. */ function mergePatch(target: Record, patch: Record): Record { for (const [key, value] of Object.entries(patch)) { const existing = target[key]; @@ -55,15 +57,16 @@ function mergePatch(target: Record, patch: Record(false); /** True once the account document has actually been reached, so the app can say so. */ @@ -134,6 +137,11 @@ export class UserSyncService { this.activeSubject.next(true); return cached.data; } + // Callers read as soon as the user signs in, a moment before ManService has the ID token + await firstValueFrom(this.authService.idToken.pipe(filter(Boolean))); + if (this.userId !== uid) { + return null; + } this.reading ??= this.fetch(uid, cached).finally(() => { this.reading = null; }); @@ -142,8 +150,10 @@ export class UserSyncService { private async fetch(uid: string, cached: CachedSettings | null): Promise { try { - const snapshot = await getDoc(doc(this.firestore, USER_SYNC_COLLECTION, uid)); - const data = (snapshot.data() ?? {}) as RemoteUserSettings; + const data = await firstValueFrom(this.manService.getUserSettings()) as RemoteUserSettings | null; + if (!data) { + throw new Error('No user settings'); + } this.writeCache(uid, data); this.activeSubject.next(true); return data; @@ -189,8 +199,7 @@ export class UserSyncService { } this.writeSentAt(Date.now()); this.mergeCache(uid, patch); - setDoc(doc(this.firestore, USER_SYNC_COLLECTION, uid), patch, {merge: true}) - .catch(() => this.disable()); + this.manService.saveUserSettings(patch).subscribe({error: () => this.disable()}); } /** A `restart` waits for changes to stop; otherwise the earliest send already due holds. */