diff --git a/.gitignore b/.gitignore index dcf7a7a..56e7efd 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,7 @@ npm-debug.log* # Local editor and tool settings .claude/ +/.agents +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 d2fb4d4..4dd3275 100644 --- a/src/app/home/course/course.page.html +++ b/src/app/home/course/course.page.html @@ -1,6 +1,9 @@ + + + {{ course ?? 'Course' }} @if (courseProgress.duration > 0) { 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 358942f..ad40d3d 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, @@ -41,6 +42,7 @@ import {AsyncPipe, DatePipe, DecimalPipe, NgClass} from '@angular/common'; import {ModalEvaluationComponent} from './modal-evaluation.component'; import {confirmAiDisclaimer, ModalDocumentComponent} from './modal-document.component'; import {PomodoroTimerComponent} from '../../shared/pomodoro-timer.component'; +import {ThemeMenuComponent} from '../../shared/theme-menu.component'; import {Analytics, logEvent} from '@angular/fire/analytics'; import {ConsentService} from '../../consent.service'; @@ -59,6 +61,7 @@ interface SeekBarPointerHandlers { IonHeader, IonToolbar, IonBackButton, + IonButtons, IonTitle, IonContent, IonGrid, @@ -76,6 +79,7 @@ interface SeekBarPointerHandlers { IonItem, IonIcon, IonSearchbar, + ThemeMenuComponent, NgClass, IonProgressBar, AsyncPipe, 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 d5f13e1..09a3afa 100644 --- a/src/app/home/home.page.html +++ b/src/app/home/home.page.html @@ -1,5 +1,8 @@ + + + MDCU Recorded Lecture @@ -115,7 +118,7 @@

{{ result.title }}

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

@@ -145,7 +148,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 882a955..bcaf9a6 100644 --- a/src/app/home/home.page.scss +++ b/src/app/home/home.page.scss @@ -12,18 +12,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 { @@ -36,18 +33,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); } } @@ -62,12 +51,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 { @@ -195,10 +184,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 { @@ -219,21 +204,17 @@ 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; font-weight: 600; letter-spacing: 0.02em; } + .heading-path { font-size: 0.8rem; color: var(--ion-color-medium) !important; diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts index 8d558d0..0e55b82 100644 --- a/src/app/home/home.page.ts +++ b/src/app/home/home.page.ts @@ -3,7 +3,7 @@ import {Observable, of, Subject, timer} from 'rxjs'; import {CourseListResponse, DocumentSearchHit, Lecture, ManService, PlayStats, SearchVideoResult} from '../man.service'; import {Router, RouterLink} from '@angular/router'; import {AuthService} from '../auth.service'; -import {colorByFolderName, formatDuration, snippetToHtml} from '../../helpers'; +import {colorByFolderName, contrastByFolderName, formatDuration, snippetToHtml} from '../../helpers'; import {addIcons} from "ionicons"; import {documentTextOutline, filmOutline, logOutOutline, playOutline, searchOutline} from "ionicons/icons"; import {catchError, debounce, distinctUntilChanged, map, switchMap, tap} from 'rxjs/operators'; @@ -34,6 +34,7 @@ import { ModalController, } from '@ionic/angular'; import {AsyncPipe, DatePipe, NgStyle} from '@angular/common'; +import {ThemeMenuComponent} from '../shared/theme-menu.component'; import {Analytics, logEvent} from '@angular/fire/analytics'; import {ConsentService} from '../consent.service'; import {confirmAiDisclaimer, ModalDocumentComponent} from './course/modal-document.component'; @@ -66,6 +67,7 @@ export type SearchResults = IonHeader, IonToolbar, IonTitle, IonButtons, IonButton, IonIcon, IonContent, IonGrid, IonRow, IonCol, IonCard, RouterLink, NgStyle, IonCardHeader, IonCardTitle, AsyncPipe, DatePipe, IonCardContent, IonItem, + ThemeMenuComponent, IonLabel, IonText, IonSpinner, IonSearchbar, IonList, IonSegment, IonSegmentButton, ] }) @@ -223,6 +225,7 @@ export class HomePage implements OnInit { } protected readonly colorByFolderName = colorByFolderName; + protected readonly contrastByFolderName = contrastByFolderName; protected readonly formatDay = formatDay; protected readonly Object = Object; diff --git a/src/app/home/list/list.page.html b/src/app/home/list/list.page.html index c3bbd09..794a2d8 100644 --- a/src/app/home/list/list.page.html +++ b/src/app/home/list/list.page.html @@ -1,8 +1,11 @@ + + + - {{ year || 'Loading'}} + {{ year || 'Loading'}} @@ -47,7 +50,7 @@

{{ 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 301ca8f..27a04c5 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, of, Subject} 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, @@ -20,6 +21,7 @@ import { import {AsyncPipe, NgStyle} from '@angular/common'; import {Analytics, logEvent} from '@angular/fire/analytics'; import {ConsentService} from '../../consent.service'; +import {ThemeMenuComponent} from '../../shared/theme-menu.component'; export interface EnrichedSearchResult extends SearchVideoResult { courseName?: string; @@ -32,7 +34,9 @@ export interface EnrichedSearchResult extends SearchVideoResult { styleUrls: ['./list.page.scss'], imports: [ IonHeader, IonToolbar, RouterLink, IonBackButton, IonTitle, NgStyle, + IonButtons, IonContent, IonList, IonListHeader, IonItem, IonLabel, AsyncPipe, + ThemeMenuComponent, IonSearchbar, IonSpinner, ] }) @@ -129,6 +133,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/man.service.ts b/src/app/man.service.ts index a113ee9..1a94db1 100644 --- a/src/app/man.service.ts +++ b/src/app/man.service.ts @@ -189,6 +189,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()); } @@ -204,6 +219,17 @@ export class ManService { return of(null); } + 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, ...options}); + } + return of(null); + } + /** * Runs a GraphQL operation. Emits null when no ID token is set, and errors when the response has errors and no data. */ 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/shared/theme-editor.component.html b/src/app/shared/theme-editor.component.html new file mode 100644 index 0000000..1b17704 --- /dev/null +++ b/src/app/shared/theme-editor.component.html @@ -0,0 +1,228 @@ + + + Custom theme + + + + + + + + + + @if (settings$ | async; as settings) { +
+

Ready-made

+

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

+ @for (group of themeService.templateGroups; track group.value) { +

{{ group.label }}

+
+ @for (template of templatesIn(group.value); track template.id) { + + } +
+ } +
+ +
+

Colours

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

{{ roleHint }}

+ +
+ + @if (role === 'secondary') { + + } + @if (role === 'background') { + + } +
+ + @if (role === 'primary') { +
+ Custom theme shade +
+ + +
+
+ } + +
+ @for (swatch of roleSwatches; track swatch) { + + } + @if ((savedColors$ | async)?.length) { + + } + @for (colour of savedColors$ | async; track colour) { + + + + + } + +
+
+ +
+

Background picture

+

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) { + + {{ option.label }} + + } + + +
+ + Visibility + + + + + Blur + + +
+ } +
+ + + Reset theme preference + + } +
diff --git a/src/app/shared/theme-editor.component.scss b/src/app/shared/theme-editor.component.scss new file mode 100644 index 0000000..2ce8161 --- /dev/null +++ b/src/app/shared/theme-editor.component.scss @@ -0,0 +1,383 @@ +.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); +} + +.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-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 { + --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: 58%; + padding-inline: 0.5rem 0.25rem; +} + +.fit-segment { + margin: 0.75rem 0; +} + +.picture-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(5.5rem, 1fr)); + gap: 0.5rem; + 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; +} + +.picture-button img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +/* 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 { + display: none; +} + +.hint { + margin: 0.4rem 0 0.5rem; + font-size: 0.78rem; + line-height: 1.35; + 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); +} + +/* 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; +} + +.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; +} + +.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; +} + +.template-group-label { + margin: 0.75rem 0 0.45rem; + font-size: 0.7rem; + font-weight: 600; + color: var(--flick-muted-text); +} + +.template-group-label:first-of-type { + margin-top: 0; +} + +/* Set apart from the colour picker so the Light and Dark choices read as the custom theme's + own setting rather than the app's light and dark modes. */ +.shade-block { + margin: 0 0 0.75rem; + padding: 0.6rem 0.75rem; + border: 1px solid var(--ion-border-color, var(--ion-color-step-150)); + border-radius: 10px; + background: var(--flick-surface); +} + +.shade-label { + display: block; + margin-bottom: 0.4rem; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--flick-muted-text); +} + +.shade-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} diff --git a/src/app/shared/theme-editor.component.ts b/src/app/shared/theme-editor.component.ts new file mode 100644 index 0000000..a4ebbd7 --- /dev/null +++ b/src/app/shared/theme-editor.component.ts @@ -0,0 +1,318 @@ +import {Component, ElementRef, inject, OnDestroy, ViewChild} from '@angular/core'; +import {AsyncPipe} from '@angular/common'; +import { + AlertController, + IonButton, + IonButtons, + IonContent, + IonHeader, + IonIcon, + IonItem, + IonLabel, + IonRange, + IonSegment, + IonSegmentButton, + IonSelect, + IonSelectOption, + IonText, + IonTitle, + IonToggle, + IonToolbar, + ModalController, +} from '@ionic/angular'; +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, PAGE_SWATCHES} from '../theme/theme-presets'; +import { + BackgroundFit, + BackgroundPictureMode, + ColorRole, + CustomTheme, + ThemeSettings, + ThemeTemplate, + ThemeTemplateGroup, +} 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, IonSelect, IonSelectOption, + IonToggle, AsyncPipe, + ], +}) +export class ThemeEditorComponent implements OnDestroy { + protected themeService = inject(ThemeService); + private modalCtrl = inject(ModalController); + private alertCtrl = inject(AlertController); + + @ViewChild('fileInput') fileInput: ElementRef; + + 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$; + 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}[] = []; + /** 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(); + private thumbnailTicket = 0; + private readonly pictureSubscription = this.themeService.pictureIds$ + .subscribe(ids => void this.loadThumbnails(ids)); + + constructor() { + addIcons({add, close, sunnyOutline, moonOutline}); + } + + ngOnDestroy(): void { + this.pictureSubscription.unsubscribe(); + for (const url of this.thumbnailUrls.values()) { + URL.revokeObjectURL(url); + } + this.thumbnailUrls.clear(); + } + + close(): void { + this.modalCtrl.dismiss(); + } + + selectTemplate(templateId: string): void { + this.themeService.selectTemplate(templateId); + } + + templatesIn(group: ThemeTemplateGroup): ThemeTemplate[] { + return this.themeService.templates.filter(template => template.group === group); + } + + 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); + } + + /** + * Light and Dark set how the custom theme draws the primary colour, on a light page tinted + * with it or a dark page filled with it. They are not the app's own light and dark modes. + */ + useShade(shade: 'light' | 'dark'): void { + this.themeService.setPage(shade); + } + + isShade(settings: ThemeSettings, shade: 'light' | 'dark'): boolean { + return settings.custom.background.color === null && settings.custom.shade === shade; + } + + /** Hands the page back to the primary colour, on the shade chosen under Primary. */ + useAutoPage(settings: ThemeSettings): void { + this.themeService.setPage(settings.custom.shade === 'dark' ? 'dark' : 'light'); + } + + isAutoPage(settings: ThemeSettings): boolean { + return settings.custom.background.color === null; + } + + /** 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.roleColor(this.role, settings)); + } + + removeColour(colour: string): void { + this.themeService.removeColor(colour); + } + + isSaved(settings: ThemeSettings): boolean { + return this.themeService.isColorSaved(this.roleColor(this.role, settings)); + } + + reset(): void { + this.imageError = null; + this.themeService.restorePlainLight(); + this.close(); + } + + /** 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 ?? template.background ?? accent, + '--swatch-to': accent, + }; + } + + 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); + } + } + + pickImage(): void { + this.imageError = null; + this.fileInput?.nativeElement.click(); + } + + async onImageSelected(event: Event): Promise { + const input = event.target as HTMLInputElement; + const files = Array.from(input.files ?? []); + input.value = ''; + if (!files.length) { + return; + } + 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.`; + } + } + + 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; + 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(); + } + + /** 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; + 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/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..d51dd0f --- /dev/null +++ b/src/app/shared/theme-menu.component.ts @@ -0,0 +1,60 @@ +import {Component, inject} from '@angular/core'; +import {AsyncPipe} from '@angular/common'; +import { + IonButton, + IonIcon, + IonItem, + IonLabel, + IonList, + IonPopover, + ModalController, +} from '@ionic/angular'; +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, + // 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(); + } +} diff --git a/src/app/theme.service.ts b/src/app/theme.service.ts new file mode 100644 index 0000000..3dccc66 --- /dev/null +++ b/src/app/theme.service.ts @@ -0,0 +1,924 @@ +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 {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 { + BACKGROUND_FIT_OPTIONS, + BACKGROUND_PICTURE_MODES, + DEFAULT_BACKGROUND, + defaultCustomTheme, + defaultThemeSettings, + findTemplate, + DEFAULT_CUSTOM_SHADE, + THEME_SHADES, + NEUTRAL_SEED, + MAX_SAVED_COLORS, + OWN_COLOR_INTENSITY, + OWN_COLOR_TEMPLATE_ID, + THEME_MODES, + THEME_TEMPLATES, + THEME_TEMPLATE_GROUPS, + SLIDESHOW_INTERVALS, +} from './theme/theme-presets'; +import {BackgroundImageStore, prepareBackgroundImage} from './theme/background-store'; +import { + BackgroundFit, + BackgroundPictureMode, + ColorScheme, + ThemeShade, + ThemeVariables, + 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_'; +/** Colours the reader has kept. They travel with the theme; the background picture does not. */ +export const SAVED_COLORS_KEY_PREFIX = 'flickThemeColors_'; +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 + * 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 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; +} + +/** 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 { + 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, + 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), + 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, + // 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), + }, + }; +} + +/** 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(); + private sync = inject(UserSyncService); + + readonly modes = THEME_MODES; + readonly templates = THEME_TEMPLATES; + readonly templateGroups = THEME_TEMPLATE_GROUPS; + 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(), + ); + readonly modeOption$: Observable = this.mode$.pipe( + map(mode => this.modes.find(option => option.value === mode) ?? this.modes[0]), + ); + + private userId = GUEST_ID; + private painted = false; + 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() { + 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); + // 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); + } + if (this.tweenFrame !== null) { + cancelAnimationFrame(this.tweenFrame); + } + this.releaseObjectUrl(); + }); + + this.apply(this.readLocal(GUEST_ID), false); + this.savedColorsSubject.next(this.readSavedColors(GUEST_ID)); + + inject(AuthService).user.subscribe(user => { + if (user?.uid) { + this.attachUser(user.uid); + } else if (this.userId !== GUEST_ID) { + this.detachUser(); + } + }); + + inject(RestoreDefaultsService).restore$.subscribe(() => this.restorePlainLight()); + } + + 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}); + } + + /** 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; + } + this.updateCustom({ + templateId: template.id, + shade: DEFAULT_CUSTOM_SHADE, + seed: {...template.seed}, + background: {...this.settings.custom.background, color: template.background}, + }); + } + + /** The primary colour: buttons, links and highlights. The page and secondary colour stay put. */ + setAccent(accent: string): void { + if (!isValidColor(accent)) { + return; + } + this.updateSeed({accent}); + } + + /** 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}); + } + + /** + * '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({ + 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 { + 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)}}); + } + + /** + * 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'}}); + } + + /** 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, slideshowMinutes, slideshowSince: Date.now()}, + }); + } + + setSlideshowShuffle(slideshowShuffle: boolean): void { + this.updateCustom({ + background: {...this.settings.custom.background, slideshowShuffle, slideshowSince: Date.now()}, + }); + } + + 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.setSavedColors(kept); + } + + removeColor(color: string): void { + const normalized = normalizeColor(color); + if (!normalized) { + return; + } + 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 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. 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 { + const page = background.color + ?? (custom.shade === 'fill' ? custom.seed.surfaceTint ?? custom.seed.accent : null); + if (!page) { + return custom.shade === 'dark' ? 'dark' : 'light'; + } + const parsed = parseColor(page); + return parsed && readableOn(parsed) === BLACK ? 'light' : 'dark'; + } + + /** + * 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 { + this.update({...defaultThemeSettings(), mode: 'light'}); + } + + /** 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 { + 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, + shade: ThemeShade, + seed: ThemeSeed, + background: ThemeBackground, + variables?: ThemeVariables, + } { + if (settings.mode === 'custom') { + const custom = settings.custom; + return { + 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, + 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 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); + this.settingsSubject.next(settings); + this.writeAppliedMirror(scheme, variables); + void this.showBackgroundPicture(background, false); + + if (persist) { + this.writeLocal(settings); + this.queueSync(settings); + } + } + + /** + * 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'; + } + 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 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; + 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', 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 { + 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; + this.sync.attach(uid); + const stored = this.readLocal(uid); + const adoptGuest = guestSettings && guestSettings.updatedAt > stored.updatedAt; + this.savedColorsSubject.next(this.readSavedColors(uid)); + this.apply(adoptGuest ? guestSettings : stored, adoptGuest); + void this.pullRemote(uid, !!adoptGuest); + } + + /** + * 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(); + 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, ...devicePicture(this.settings.custom.background)}, + }, + }; + 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 pictures, which are too large to send and stay where they were added. */ + 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, ...devicePicture(DEFAULT_BACKGROUND)}}, + 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)); + } + + private readSavedColors(uid: string): string[] { + try { + const raw = localStorage.getItem(SAVED_COLORS_KEY_PREFIX + uid); + const parsed: unknown = raw ? JSON.parse(raw) : null; + return Array.isArray(parsed) ? sanitizeSavedColors(parsed) : []; + } 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 { + 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..a35b05e --- /dev/null +++ b/src/app/theme/background-store.ts @@ -0,0 +1,108 @@ +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(); + })); +} + +/** + * 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 { + 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).sort()) + .catch(() => []); + } +} + +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..fd51b32 --- /dev/null +++ b/src/app/theme/color.ts @@ -0,0 +1,209 @@ +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))); +} + +/** 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')) + .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 { + // 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); + // 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 = round(hslToRgb({...hsl, l: lightness})); + if (contrastRatio(best, onto) >= 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 { + const onto = round(background); + if (contrastRatio(round(color), onto) >= minRatio) { + return round(color); + } + const target = readableOn(onto); + let low = 0; + let high = 1; + 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 = round(mix(target, color, middle)); + if (contrastRatio(candidate, onto) >= 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..d6c0638 --- /dev/null +++ b/src/app/theme/palette.spec.ts @@ -0,0 +1,132 @@ +import {contrastRatio, parseColor, rgbToHsl} from './color'; +import {buildThemeVariables} from './palette'; +import { + BASE_SCHEMES, + DEFAULT_BACKGROUND, + DEFAULT_SERIES_COLORS, + MIN_ACCENT_CONTRAST, + 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']); + }); + + it('should keep every template readable on its own page', () => { + for (const template of THEME_TEMPLATES) { + const variables = build(template.seed, 'light', {...DEFAULT_BACKGROUND, color: template.background}); + const page = variables['--ion-background-color']; + expect(ratio(variables['--ion-text-color'], page)).toBeGreaterThanOrEqual(MIN_TEXT_CONTRAST); + expect(ratio(variables['--flick-accent-text'], page)).toBeGreaterThanOrEqual(MIN_ACCENT_CONTRAST); + } + }); +}); diff --git a/src/app/theme/palette.ts b/src/app/theme/palette.ts new file mode 100644 index 0000000..89fed57 --- /dev/null +++ b/src/app/theme/palette.ts @@ -0,0 +1,330 @@ +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, + 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, + SHADE_AMOUNT, + SURFACE_STEPS, + TERTIARY_HUE_OFFSET, + TINT_AMOUNT, +} from './theme-presets'; +import { + BaseScheme, + ColorScheme, + SEMANTIC_ROLES, + SemanticRole, + ThemeBackground, + ThemeSeed, + ThemeShade, + ThemeVariables, +} 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; +} + +/** + * 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); + 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; +} + +/** 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. 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, + baseBackground: Rgb, + shade: ThemeShade, + intensity: number, + derived: boolean, +): Rgb { + if (derived && shade === 'fill') { + 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; +} + +/** + * 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, + overrides?: ThemeVariables, + shade: ThemeShade = scheme, +): 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 ?? 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); + + 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'; + // 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); + + 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 band = SERIES_SATURATION[scheme]; + const seriesColor = (position: number) => hslToRgb({ + h: h + (position - 0.5) * 2 * SERIES_HUE_DRIFT, + 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++) { + variables[`--flick-series-${index}`] = toHex(readable[index]); + variables[`--flick-series-${index}-contrast`] = toHex(label); + } + 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; + 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; + variables['--flick-background-repeat'] = 'no-repeat'; + + return overrides ? {...variables, ...overrides} : 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..15331af --- /dev/null +++ b/src/app/theme/theme-presets.ts @@ -0,0 +1,502 @@ +import { + BackgroundFitOption, + ColorRoleOption, + BackgroundPictureModeOption, + SlideshowIntervalOption, + ThemeShade, + ThemeShadeOption, + BaseScheme, + ColorScheme, + CustomTheme, + SemanticRole, + SurfaceSteps, + ThemeBackground, + ThemeModeOption, + ThemeSettings, + ThemeTemplate, + ThemeTemplateGroupOption, +} 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}, +}; + +/** + * 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, + 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. 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.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_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; + +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 = '#fff0f5'; +export const FACULTY_PAGE = '#f2f6f4'; + +/** + * Single-colour templates, each a button colour on a pale page of the same hue. The accents + * are deep enough to read as text on their own page, so they are drawn exactly as listed. + */ +const COLOUR_TEMPLATES: {id: string, name: string, accent: string, page: string}[] = [ + {id: 'red', name: 'Red', accent: '#c62828', page: '#fdf0f0'}, + {id: 'orange', name: 'Orange', accent: '#c2410c', page: '#fff4ec'}, + {id: 'gold', name: 'Gold', accent: '#a16207', page: '#fdf8e7'}, + {id: 'teal', name: 'Teal', accent: '#0f766e', page: '#eef8f6'}, + {id: 'blue', name: 'Blue', accent: '#1d5fd1', page: '#eef4fb'}, + {id: 'purple', name: 'Purple', accent: '#7c3aed', page: '#f5f0fe'}, + {id: 'slate', name: 'Slate', accent: '#475569', page: '#f3f5f8'}, +]; + +/** + * 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.', + group: 'brand', + 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', + name: 'Green', + description: 'Green page with green buttons.', + group: 'brand', + 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.', + group: 'brand', + seed: { + accent: FACULTY_ACCENT, + companion: null, + tertiary: null, + surfaceTint: UNIVERSITY_ACCENT, + intensity: 1, + }, + background: UNIVERSITY_PAGE, + }, + ...COLOUR_TEMPLATES.map(({id, name, accent, page}): ThemeTemplate => ({ + id, + name, + description: `${name} page with ${name.toLowerCase()} buttons.`, + group: 'colour', + seed: {accent, companion: null, tertiary: null, surfaceTint: null, intensity: 1}, + background: page, + })), +]; + +export const THEME_TEMPLATE_GROUPS: ThemeTemplateGroupOption[] = [ + {value: 'brand', label: 'MDCU'}, + {value: 'colour', label: 'Colours'}, +]; + +/** 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', +]; + +/** + * 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. Auto works it out from your primary colour, light or dark as set under Primary; 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 BACKGROUND_FIT_OPTIONS: BackgroundFitOption[] = [ + {value: 'cover', label: 'Fill'}, + {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. */ +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}, + shade: DEFAULT_CUSTOM_SHADE, + }; +} + +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..39f62a5 --- /dev/null +++ b/src/app/theme/theme.model.ts @@ -0,0 +1,150 @@ +/** 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'; +/** 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 + * 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', +] 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; + /** 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; +} + +/** + * 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 { + mode: ThemeMode; + custom: CustomTheme; + 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; + +/** The rows the ready-made templates are listed in: the brand pairings, then single colours. */ +export type ThemeTemplateGroup = 'brand' | 'colour'; + +export interface ThemeTemplateGroupOption { + value: ThemeTemplateGroup; + label: string; +} + +export interface ThemeTemplate { + id: string; + name: string; + description: string; + group: ThemeTemplateGroup; + 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 { + value: ThemeMode; + label: string; + description: string; + icon: string; +} + +export interface BackgroundFitOption { + value: BackgroundFit; + label: string; +} + +export interface BackgroundPictureModeOption { + value: BackgroundPictureMode; + label: string; +} + +export interface SlideshowIntervalOption { + minutes: number; + label: string; +} + +export interface ThemeShadeOption { + value: ThemeShade; + label: string; + 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; + 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/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 new file mode 100644 index 0000000..dd4faaa --- /dev/null +++ b/src/app/user-sync.service.ts @@ -0,0 +1,268 @@ +import {DestroyRef, inject, Injectable} from '@angular/core'; +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 SYNC_CACHE_KEY_PREFIX = 'flickSync_'; +/** A remote config flag, so sync can be switched off without a release. */ +export const SYNC_ENABLED_KEY = 'userSyncEnabled'; + +/** + * 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; +/** + * 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 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 = 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; + pomodoro?: 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. FlickMan merges the same way. */ +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 updating: 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 authService = inject(AuthService); + private manService = inject(ManService); + + 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 = this.readSentAt(); + 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; + } + // 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; + }); + return this.reading; + } + + private async fetch(uid: string, cached: CachedSettings | null): Promise { + try { + 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; + } 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.writeSentAt(Date.now()); + this.mergeCache(uid, patch); + this.manService.saveUserSettings(patch).subscribe({error: () => 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 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; + } + + 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); + } +} diff --git a/src/app/welcome/welcome.page.html b/src/app/welcome/welcome.page.html index 28a2a3d..47e229f 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 127b53e..2cfc4ad 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, RouterLink, ], }) diff --git a/src/global.scss b/src/global.scss index 3c81298..c7cf5ff 100644 --- a/src/global.scss +++ b/src/global.scss @@ -27,6 +27,82 @@ @import "video.js/dist/video-js.css"; +/* 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 { font-size: 0.8em; } @@ -35,18 +111,27 @@ 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); +} + +/* 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; } } @@ -105,3 +190,51 @@ line-height: 2.9333; /* 1.5em digits x 2.9333 */ } } + +/* 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); +} + +/* + * 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); + /* 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 { + background-color: var(--flick-background-scrim); + } + + ion-router-outlet { + z-index: 1; + } + + ion-content { + --background: transparent; + } +} + diff --git a/src/helpers.spec.ts b/src/helpers.spec.ts index 4ebeb38..68fd589 100644 --- a/src/helpers.spec.ts +++ b/src/helpers.spec.ts @@ -1,17 +1,22 @@ -import {colorByFolderName, formatDuration, snippetToHtml} from './helpers'; +import {colorByFolderName, contrastByFolderName, formatDuration, snippetToHtml} 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)'); }); }); diff --git a/src/helpers.ts b/src/helpers.ts index 301577d..85bdec9 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,15 +1,17 @@ +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)`; } // "45 min", "2h 5m" or "3h"; empty for zero. 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 127a1fd..4610a2c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import {enableProdMode, importProvidersFrom, provideZoneChangeDetection } from '@angular/core'; +import {enableProdMode, importProvidersFrom, inject, provideAppInitializer, provideZoneChangeDetection} 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(); @@ -31,6 +32,10 @@ bootstrapApplication(AppComponent, { provideZoneChangeDetection(), 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); + }), {provide: INSTRUMENTATION_ENABLED, useValue: environment.production}, {provide: DEBUG_MODE, useValue: !environment.production}, {provide: DEFAULTS, useValue: environment.defaultRemoteConfig}, 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); }