diff --git a/frontend/app/package.json b/frontend/app/package.json index f2190b6a..29e7ded1 100644 --- a/frontend/app/package.json +++ b/frontend/app/package.json @@ -82,6 +82,7 @@ "@ionic/angular": "7.8.0", "@ionic/storage-angular": "4.0.0", "@maplibre/ngx-maplibre-gl": "17.4.1", + "@material/material-color-utilities": "0.3.0", "@ngx-translate/core": "15.0.0", "@ngx-translate/http-loader": "8.0.0", "@openid/appauth": "1.3.1", diff --git a/frontend/app/src/app/app.module.ts b/frontend/app/src/app/app.module.ts index 80c0fcd6..3d4a5fd7 100644 --- a/frontend/app/src/app/app.module.ts +++ b/frontend/app/src/app/app.module.ts @@ -71,6 +71,7 @@ import {Capacitor} from '@capacitor/core'; import {SplashScreen} from '@capacitor/splash-screen'; import maplibregl from 'maplibre-gl'; import {Protocol} from 'pmtiles'; +import {ThemeProvider} from './util/theme.provider'; registerLocaleData(localeDe); @@ -89,6 +90,7 @@ export function initializerFactory( defaultAuthService: DefaultAuthService, paiaAuthService: PAIAAuthService, dateFnsConfigurationService: DateFnsConfigurationService, + _themeProvider: ThemeProvider, ) { return async () => { try { @@ -213,6 +215,7 @@ export function createTranslateLoader(http: HttpClient) { DefaultAuthService, PAIAAuthService, DateFnsConfigurationService, + ThemeProvider, ], useFactory: initializerFactory, }, diff --git a/frontend/app/src/app/util/media-query.pipe.ts b/frontend/app/src/app/util/media-query.pipe.ts index b4a98bcb..597be2fc 100644 --- a/frontend/app/src/app/util/media-query.pipe.ts +++ b/frontend/app/src/app/util/media-query.pipe.ts @@ -1,6 +1,7 @@ import {PipeTransform} from '@angular/core'; import {Pipe} from '@angular/core'; -import {Observable, fromEvent, map, startWith} from 'rxjs'; +import {Observable} from 'rxjs'; +import {fromMediaQuery} from './rxjs/from-media-query'; @Pipe({ name: 'mediaQuery', @@ -9,10 +10,6 @@ import {Observable, fromEvent, map, startWith} from 'rxjs'; }) export class MediaQueryPipe implements PipeTransform { transform(query: string): Observable { - const match = window.matchMedia(query); - return fromEvent(match, 'change').pipe( - map(event => event.matches), - startWith(match.matches), - ); + return fromMediaQuery(query); } } diff --git a/frontend/app/src/app/util/rxjs/from-media-query.ts b/frontend/app/src/app/util/rxjs/from-media-query.ts new file mode 100644 index 00000000..66d6f02f --- /dev/null +++ b/frontend/app/src/app/util/rxjs/from-media-query.ts @@ -0,0 +1,38 @@ +import {Observable, combineLatest, defer, distinctUntilChanged, fromEvent, map, startWith} from 'rxjs'; + +/** + * Lazily creates an Observable that emits whether the given media query matches. + * @example + * fromMediaQuery('(prefers-color-scheme: dark)').subscribe(matches => console.log(matches)) + */ +export function fromMediaQuery(query: string): Observable { + return defer(() => { + const match = window.matchMedia(query); + return fromEvent(match, 'change').pipe( + map(event => event.matches), + startWith(match.matches), + ); + }); +} + +/** + * Like `fromMediaQuery`, but combines multiple matches. + * Since the list of values may not be exhaustive, the result can be `undefined` + * @example + * fromMediaQueryValues('prefers-color-scheme', ['dark', 'light']).subscribe(value => console.log(value)) + */ +export function fromMediaQueryValues( + property: string, + values: T[], +): Observable { + return defer(() => + combineLatest( + values.map(value => + fromMediaQuery(`(${property}: ${value})`).pipe(map(matches => [value, matches] as const)), + ), + ).pipe( + map(matches => matches.find(([, matches]) => matches)?.[0]), + distinctUntilChanged(), + ), + ); +} diff --git a/frontend/app/src/app/util/theme-ionic-utils.ts b/frontend/app/src/app/util/theme-ionic-utils.ts new file mode 100644 index 00000000..4a00738a --- /dev/null +++ b/frontend/app/src/app/util/theme-ionic-utils.ts @@ -0,0 +1,288 @@ +import {Hct, argbFromHex, hexFromArgb} from '@material/material-color-utilities'; +import { + IonTheme, + IonThemeColorDarkLight, + IonThemeOptions, + ThemeCustomColor, + ThemeCustomColorOptions, +} from './theme-types'; +import {dynamicScheme, makeCustomColor} from './theme-utils'; + +/** + * Turn a custom material color to a color that can be used in Ionic + */ +export function ionColorFromCustomColor(color: ThemeCustomColor): IonThemeColorDarkLight { + return { + palette: color.palette, + light: { + color: color.light.color, + colorContrast: color.light.onColor, + colorShade: color.fixed.dim, + colorTint: color.fixed.color, + }, + dark: { + color: color.dark.color, + colorContrast: color.dark.onColor, + colorShade: color.fixed.dim, + colorTint: color.fixed.color, + }, + }; +} + +/** + * Create an Ionic theme + */ +export function makeIonicTheme(options: IonThemeOptions): IonTheme { + const light = dynamicScheme( + options.variant, + Hct.fromInt(options.sourceColor), + false, + options.contrastLevel, + ); + const dark = dynamicScheme(options.variant, Hct.fromInt(options.sourceColor), true, options.contrastLevel); + const customColorOptions: Omit = { + blend: true, + sourceColor: options.sourceColor, + variant: options.variant, + contrastLevel: options.contrastLevel, + }; + + return { + success: ionColorFromCustomColor(makeCustomColor({color: argbFromHex('#00ff00'), ...customColorOptions})), + warning: ionColorFromCustomColor(makeCustomColor({color: argbFromHex('#ffdd00'), ...customColorOptions})), + danger: ionColorFromCustomColor(makeCustomColor({color: argbFromHex('#ff4444'), ...customColorOptions})), + primary: { + palette: light.primaryPalette, + light: { + color: light.primary, + colorContrast: light.onPrimary, + colorShade: light.primaryFixedDim, + colorTint: light.primaryFixed, + }, + dark: { + color: dark.primary, + colorContrast: dark.onPrimary, + colorShade: dark.primaryFixedDim, + colorTint: dark.primaryFixed, + }, + }, + secondary: { + palette: light.secondaryPalette, + light: { + color: light.secondary, + colorContrast: light.onSecondary, + colorShade: light.secondaryFixedDim, + colorTint: light.secondaryFixed, + }, + dark: { + color: dark.secondary, + colorContrast: dark.onSecondary, + colorShade: dark.secondaryFixedDim, + colorTint: dark.secondaryFixed, + }, + }, + tertiary: { + palette: light.tertiaryPalette, + light: { + color: light.tertiary, + colorContrast: light.onTertiary, + colorShade: light.tertiaryFixedDim, + colorTint: light.tertiaryFixed, + }, + dark: { + color: dark.tertiary, + colorContrast: dark.onTertiary, + colorShade: dark.tertiaryFixedDim, + colorTint: dark.tertiaryFixed, + }, + }, + light: { + palette: light.neutralPalette, + light: { + color: light.neutralPalette.tone(90), + colorContrast: light.onSurface, + colorShade: light.neutralPalette.tone(85), + colorTint: light.neutralPalette.tone(95), + }, + dark: { + color: dark.surfaceContainerLowest, + colorContrast: dark.onSurface, + // TODO: find a better color for these + colorShade: dark.surfaceBright, + colorTint: dark.surfaceBright, + }, + }, + medium: { + palette: light.neutralVariantPalette, + light: { + color: light.neutralPalette.tone(50), + colorContrast: light.onSurfaceVariant, + colorShade: light.neutralPalette.tone(45), + colorTint: light.neutralPalette.tone(55), + }, + dark: { + color: dark.surfaceVariant, + colorContrast: dark.onSurfaceVariant, + // TODO: find a better color for these + colorShade: dark.surfaceContainerLow, + colorTint: dark.surfaceContainerHigh, + }, + }, + dark: { + palette: light.neutralPalette, + light: { + color: light.surfaceContainerHigh, + colorContrast: light.onSurface, + colorShade: light.surfaceContainerHighest, + colorTint: light.surfaceContainer, + }, + dark: { + color: dark.surfaceContainerHighest, + colorContrast: dark.onSurface, + // TODO: find a better color for these + colorShade: dark.surfaceDim, + colorTint: dark.surfaceDim, + }, + }, + background: { + palette: light.neutralPalette, + light: { + backgroundColor: light.background, + textColor: light.onBackground, + boxShadowColor: light.shadow, + placeholderColor: light.onSurfaceVariant, + }, + dark: { + backgroundColor: dark.background, + textColor: dark.onBackground, + boxShadowColor: dark.shadow, + placeholderColor: dark.onSurfaceVariant, + }, + }, + item: { + palette: light.neutralPalette, + light: { + itemBackground: light.surface, + itemColor: light.onSurface, + cardBackground: light.surfaceContainer, + itemBorderColor: light.outline, + borderColor: light.outline, + }, + dark: { + itemBackground: dark.surface, + itemColor: dark.onSurface, + cardBackground: dark.surfaceContainer, + itemBorderColor: dark.outline, + borderColor: dark.outline, + }, + }, + }; +} + +/** + * Simple color + */ +function color(element: HTMLElement, name: string, argb: number) { + element.style.setProperty(name, hexFromArgb(argb)); +} + +/** + * Color, in RGB + */ +function colorRgb(element: HTMLElement, name: string, argb: number) { + element.style.setProperty(name, `${(argb >> 16) & 0xff}, ${(argb >> 8) & 0xff}, ${argb & 0xff}`); +} + +/** + * Apply the Ionic color to an element + */ +export function applyIonicAccentColor( + element: HTMLElement, + name: string, + colors: IonThemeColorDarkLight, + dark: boolean, +) { + const operations = [ + ['', dark ? colors.dark : colors.light], + ['-dark', colors.dark], + ['-light', colors.light], + ] as const; + + for (const [suffix, colors] of operations) { + color(element, `--ion-color-${name}${suffix}`, colors.color); + color(element, `--ion-color-${name}-contrast${suffix}`, colors.colorContrast); + color(element, `--ion-color-${name}-shade${suffix}`, colors.colorShade); + color(element, `--ion-color-${name}-tint${suffix}`, colors.colorTint); + + colorRgb(element, `--ion-color-${name}-rgb${suffix}`, colors.color); + colorRgb(element, `--ion-color-${name}-contrast-rgb${suffix}`, colors.colorContrast); + colorRgb(element, `--ion-color-${name}-shade-rgb${suffix}`, colors.colorShade); + colorRgb(element, `--ion-color-${name}-tint-rgb${suffix}`, colors.colorTint); + } +} + +/** + * Apply the theme + */ +export function applyIonicTheme(element: HTMLElement, theme: IonTheme, dark: boolean) { + applyIonicAccentColor(element, 'primary', theme.primary, dark); + applyIonicAccentColor(element, 'secondary', theme.secondary, dark); + applyIonicAccentColor(element, 'tertiary', theme.tertiary, dark); + applyIonicAccentColor(element, 'success', theme.success, dark); + applyIonicAccentColor(element, 'warning', theme.warning, dark); + applyIonicAccentColor(element, 'danger', theme.danger, dark); + applyIonicAccentColor(element, 'dark', theme.dark, dark); + applyIonicAccentColor(element, 'medium', theme.medium, dark); + applyIonicAccentColor(element, 'light', theme.light, dark); + + const backgroundOps = [ + ['', dark ? theme.background.dark : theme.background.light], + ['-dark', theme.background.dark], + ['-light', theme.background.light], + ] as const; + + for (const [suffix, background] of backgroundOps) { + color(element, `--ion-background-color${suffix}`, background.backgroundColor); + color(element, `--ion-text-color${suffix}`, background.textColor); + color(element, `--ion-box-shadow-color${suffix}`, background.boxShadowColor); + color(element, `--ion-placeholder-color${suffix}`, background.placeholderColor); + + colorRgb(element, `--ion-background-color-rgb${suffix}`, background.backgroundColor); + colorRgb(element, `--ion-text-color-rgb${suffix}`, background.textColor); + colorRgb(element, `--ion-box-shadow-color-rgb${suffix}`, background.boxShadowColor); + colorRgb(element, `--ion-placeholder-color-rgb${suffix}`, background.placeholderColor); + } + + const stepOps = [ + ['', dark ? true : false], + ['-dark', true], + ['-light', false], + ] as const; + + for (const [suffix, reverse] of stepOps) { + for (let i = 5; i < 100; i += 5) { + const ionicTone = 10 * (reverse ? 100 - i : i); + color(element, `--ion-color-step-${ionicTone}${suffix}`, theme.background.palette.tone(i)); + } + } + + const itemOps = [ + ['', dark ? theme.item.dark : theme.item.light], + ['-dark', theme.item.dark], + ['-light', theme.item.light], + ] as const; + + for (const [suffix, item] of itemOps) { + color(element, `--ion-item-background${suffix}`, item.itemBackground); + color(element, `--ion-card-background${suffix}`, item.cardBackground); + color(element, `--ion-item-border-color${suffix}`, item.itemBorderColor); + color(element, `--ion-border-color${suffix}`, item.borderColor); + color(element, `--ion-item-color${suffix}`, item.itemColor); + + colorRgb(element, `--ion-item-background-rgb${suffix}`, item.itemBackground); + colorRgb(element, `--ion-card-background-rgb${suffix}`, item.cardBackground); + colorRgb(element, `--ion-item-border-color-rgb${suffix}`, item.itemBorderColor); + colorRgb(element, `--ion-border-color-rgb${suffix}`, item.borderColor); + colorRgb(element, `--ion-item-color-rgb${suffix}`, item.itemColor); + } +} diff --git a/frontend/app/src/app/util/theme-types.ts b/frontend/app/src/app/util/theme-types.ts new file mode 100644 index 00000000..b62464db --- /dev/null +++ b/frontend/app/src/app/util/theme-types.ts @@ -0,0 +1,109 @@ +import {TonalPalette} from '@material/material-color-utilities'; + +export const THEME_VARIANTS = [ + 'content', + 'neutral', + 'rainbow', + 'vibrant', + 'fidelity', + 'expressive', + 'monochrome', + 'tonal-spot', + 'fruit-salad', +] as const; +export type ThemeVariant = (typeof THEME_VARIANTS)[number]; + +export interface ThemeCustomColorOptions { + color: number; + blend: boolean; + sourceColor: number; + variant: ThemeVariant; + contrastLevel: number; +} + +export interface ThemeColorNormal { + color: number; + onColor: number; + container: number; + onContainer: number; +} + +/** + * Palette that can be used in both light and dark mode. + * Caution: check contrast level before using + * @see https://m3.material.io/styles/color/roles#26b6a882-064d-4668-b096-c51142477850 + */ +export interface ThemeColorFixed { + color: number; + onColor: number; + /** + * Lower emphasis against fixed color + */ + onVariant: number; + dim: number; +} + +export interface ThemeCustomColor { + color: number; + source: number; + palette: TonalPalette; + light: ThemeColorNormal; + dark: ThemeColorNormal; + fixed: ThemeColorFixed; +} + +export interface IonThemeOptions { + sourceColor: number; + contrastLevel: number; + variant: ThemeVariant; +} + +export interface IonThemeColor { + color: number; + colorContrast: number; + colorShade: number; + colorTint: number; +} + +export interface IonThemeColorDarkLight { + palette: TonalPalette; + dark: IonThemeColor; + light: IonThemeColor; +} + +export interface IonThemeBackground { + backgroundColor: number; + textColor: number; + boxShadowColor: number; + placeholderColor: number; +} + +export interface IonThemeItem { + itemBackground: number; + cardBackground: number; + itemBorderColor: number; + borderColor: number; + itemColor: number; +} + +export interface IonTheme { + primary: IonThemeColorDarkLight; + secondary: IonThemeColorDarkLight; + tertiary: IonThemeColorDarkLight; + success: IonThemeColorDarkLight; + warning: IonThemeColorDarkLight; + danger: IonThemeColorDarkLight; + dark: IonThemeColorDarkLight; + medium: IonThemeColorDarkLight; + light: IonThemeColorDarkLight; + background: { + palette: TonalPalette; + light: IonThemeBackground; + dark: IonThemeBackground; + }; + item: { + palette: TonalPalette; + light: IonThemeItem; + dark: IonThemeItem; + }; +} diff --git a/frontend/app/src/app/util/theme-utils.ts b/frontend/app/src/app/util/theme-utils.ts new file mode 100644 index 00000000..2bd6470c --- /dev/null +++ b/frontend/app/src/app/util/theme-utils.ts @@ -0,0 +1,93 @@ +import { + Blend, + Hct, + SchemeContent, + SchemeExpressive, + SchemeFidelity, + SchemeFruitSalad, + SchemeMonochrome, + SchemeNeutral, + SchemeRainbow, + SchemeTonalSpot, + SchemeVibrant, +} from '@material/material-color-utilities'; +import {ThemeCustomColor, ThemeCustomColorOptions, ThemeVariant} from './theme-types'; + +export const DEFAULT_CONTRAST = 0; +export const GLOBAL_CONTRAST = { + 'more': 4, + 'less': 0, + 'no-preference': 0, +}; + +/** + * Creates a DynamicScheme based on the variant. + */ +export function dynamicScheme( + variant: ThemeVariant, + sourceColorHct: Hct, + isDark: boolean, + contrastLevel: number, +) { + switch (variant) { + case 'content': { + return new SchemeContent(sourceColorHct, isDark, contrastLevel); + } + case 'neutral': { + return new SchemeNeutral(sourceColorHct, isDark, contrastLevel); + } + case 'rainbow': { + return new SchemeRainbow(sourceColorHct, isDark, contrastLevel); + } + case 'vibrant': { + return new SchemeVibrant(sourceColorHct, isDark, contrastLevel); + } + case 'fidelity': { + return new SchemeFidelity(sourceColorHct, isDark, contrastLevel); + } + case 'expressive': { + return new SchemeExpressive(sourceColorHct, isDark, contrastLevel); + } + case 'monochrome': { + return new SchemeMonochrome(sourceColorHct, isDark, contrastLevel); + } + case 'tonal-spot': { + return new SchemeTonalSpot(sourceColorHct, isDark, contrastLevel); + } + case 'fruit-salad': { + return new SchemeFruitSalad(sourceColorHct, isDark, contrastLevel); + } + } +} + +/** + * Create a custom color that works with the theme + */ +export function makeCustomColor(options: ThemeCustomColorOptions): ThemeCustomColor { + const color = options.blend ? Blend.harmonize(options.color, options.sourceColor) : options.color; + const light = dynamicScheme(options.variant, Hct.fromInt(color), false, options.contrastLevel); + const dark = dynamicScheme(options.variant, Hct.fromInt(color), true, options.contrastLevel); + return { + color, + source: options.color, + palette: light.primaryPalette, + light: { + color: light.primary, + onColor: light.onPrimary, + container: light.primaryContainer, + onContainer: light.onPrimaryContainer, + }, + dark: { + color: dark.primary, + onColor: dark.onPrimary, + container: dark.primaryContainer, + onContainer: dark.onPrimaryContainer, + }, + fixed: { + color: light.primaryFixed, + onColor: light.onPrimaryFixed, + dim: light.primaryFixedDim, + onVariant: light.onPrimaryFixedVariant, + }, + }; +} diff --git a/frontend/app/src/app/util/theme.provider.ts b/frontend/app/src/app/util/theme.provider.ts new file mode 100644 index 00000000..56a8990e --- /dev/null +++ b/frontend/app/src/app/util/theme.provider.ts @@ -0,0 +1,61 @@ +import {Injectable} from '@angular/core'; +import {fromMediaQuery, fromMediaQueryValues} from './rxjs/from-media-query'; +import {BehaviorSubject, Observable, combineLatest, distinctUntilChanged, map} from 'rxjs'; +import {argbFromHex} from '@material/material-color-utilities'; +import {applyIonicTheme, makeIonicTheme} from './theme-ionic-utils'; +import {DEFAULT_CONTRAST, GLOBAL_CONTRAST} from './theme-utils'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {ThemeVariant} from './theme-types'; + +@Injectable({ + providedIn: 'root', +}) +export class ThemeProvider { + prefersDark = fromMediaQuery('(prefers-color-scheme: dark)'); + + constrastPreference = fromMediaQueryValues('prefers-contrast', ['more', 'less', 'no-preference']); + + // TODO: fetch the color from somewhere + settingThemeColor = new BehaviorSubject('#3880FF'); + + settingThemeVariant = new BehaviorSubject('content'); + + settingThemeContrast = new BehaviorSubject(undefined); + + settingThemeMode = new BehaviorSubject<'light' | 'dark' | undefined>(undefined); + + themeSourceColor = this.settingThemeColor.pipe(map(argbFromHex)); + + themeVariant = this.settingThemeVariant.asObservable(); + + themeContrastLevel: Observable = combineLatest([ + this.constrastPreference, + this.settingThemeContrast, + ]).pipe( + map(([prefersContrast, customContrast]) => + customContrast === undefined + ? prefersContrast === undefined + ? DEFAULT_CONTRAST + : GLOBAL_CONTRAST[prefersContrast] + : customContrast, + ), + distinctUntilChanged(), + ); + + themeIsDark = combineLatest([this.prefersDark, this.settingThemeMode]).pipe( + map(([prefersDark, customMode]) => (customMode === undefined ? prefersDark : customMode === 'dark')), + distinctUntilChanged(), + ); + + ionicTheme = combineLatest([this.themeContrastLevel, this.themeVariant, this.themeSourceColor]).pipe( + map(([contrastLevel, variant, sourceColor]) => makeIonicTheme({variant, sourceColor, contrastLevel})), + ); + + constructor() { + combineLatest([this.ionicTheme, this.themeIsDark]) + .pipe(takeUntilDestroyed()) + .subscribe(([theme, isDark]) => { + applyIonicTheme(document.documentElement, theme, isDark); + }); + } +} diff --git a/frontend/app/src/theme/colors.scss b/frontend/app/src/theme/colors.scss index 7852b141..80766bdc 100644 --- a/frontend/app/src/theme/colors.scss +++ b/frontend/app/src/theme/colors.scss @@ -13,7 +13,7 @@ * this program. If not, see . */ @import './util/color-system'; - +/* @include ion-color(primary, #3880ff); @include ion-color(secondary, #32db64); @include ion-color(tertiary, #f4a942); @@ -26,6 +26,7 @@ @include ion-background-color(#f5f5f5, #000); @include ion-item-color(#fff, #0e0e0e); +*/ :root { --calender-lecture-card: var(--ion-color-primary-tint); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9d01d74..7fa2b06a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -791,6 +791,9 @@ importers: '@maplibre/ngx-maplibre-gl': specifier: 17.4.1 version: 17.4.1(@angular/common@17.3.0)(@angular/core@17.3.0)(maplibre-gl@4.0.2)(rxjs@7.8.1) + '@material/material-color-utilities': + specifier: 0.3.0 + version: 0.3.0 '@ngx-translate/core': specifier: 15.0.0 version: 15.0.0(@angular/common@17.3.0)(@angular/core@17.3.0)(rxjs@7.8.1) @@ -5522,7 +5525,7 @@ packages: object-assign: 4.1.1 open: 8.4.0 proxy-middleware: 0.15.0 - send: 0.18.0 + send: 0.19.0 serve-index: 1.9.1 transitivePeerDependencies: - supports-color @@ -7067,6 +7070,10 @@ packages: tslib: 2.6.2 dev: false + /@material/material-color-utilities@0.3.0: + resolution: {integrity: sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g==} + dev: false + /@ngtools/webpack@17.3.0(@angular/compiler-cli@17.3.0)(typescript@5.4.2)(webpack@5.90.3): resolution: {integrity: sha512-wNTCDPPEtjP4mxYerLVLCMwOCTEOD2HqZMVXD8pJbarrGPMuoyglUZuqNSIS5KVqR+fFez6JEUnMvC3QSqf58w==} engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} @@ -18928,6 +18935,27 @@ packages: transitivePeerDependencies: - supports-color + /send@0.19.0: + resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} + engines: {node: '>= 0.8.0'} + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + dev: true + /serialize-javascript@6.0.0: resolution: {integrity: sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==} dependencies: