diff --git a/docs/configuration/view.md b/docs/configuration/view.md index a33c5c71..645b7b10 100644 --- a/docs/configuration/view.md +++ b/docs/configuration/view.md @@ -11,13 +11,14 @@ view: | ------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `actions` | | [Actions](actions/README.md) to use for all views, individual actions may be overriden by view-specific actions. | | `camera_select` | `current` | The [view](view.md?id=supported-views) to show when a new camera is selected (e.g. in the camera menu). If `current` the view is unchanged when a new camera is selected. | -| `dark_mode` | `off` | Whether or not to turn dark mode `on`, `off` or `auto` to automatically turn on if the card `interaction_seconds` has expired (i.e. card has been left unattended for that period of time) or if dark mode is enabled in the HA profile theme setting. Dark mode dims the brightness by `25%`. | +| `dim` | `false` | Whether or not to 'dim' the brightness of the card (by 25%) if the card `interaction_seconds` has expired (i.e. card has been left unattended for that period of time). | | `default` | `live` | The view to show in the card by default. The default camera is the first one listed. See [Supported Views](view.md?id=supported-views) below. | | `default_reset` | | The circumstances and behavior that cause the card to reset to the default view. See below. | -| `interaction_seconds` | `300` | After a mouse/touch interaction with the card, it will be considered "interacted with" until this number of seconds elapses without further interaction. May be used as part of an [interaction condition](conditions.md?id=interaction) or with `reset_after_interaction` to reset the view after the interaction is complete. `0` means no interactions are reported / acted upon. | +| `interaction_seconds` | `300` | After a mouse/touch interaction with the card, it will be considered "interacted with" until this number of seconds elapses without further interaction. May be used as part of an [interaction condition](conditions.md?id=interaction) or with `reset_after_interaction` to reset the view after the interaction is complete. | | `keyboard_shortcuts` | See [usage](../usage/keyboard-shortcuts.md) for defaults. | Configure keyboard shortcuts. See below. | | `render_entities` | | **YAML only**: A list of entity ids that should cause the card to re-render 'in-place'. The view/camera is not changed. This should **very** rarely be needed, but could be useful if the card is both setting and changing HA state of the same object as could be the case for some complex `card_mod` scenarios ([example](https://github.com/dermotduffy/frigate-hass-card/issues/343)). | | `reset_after_interaction` | `true` | If `true` the card will reset to the default configured view (i.e. 'screensaver' functionality) after `interaction_seconds` has elapsed after user interaction. | +| `theme` | | How the card is themed. See below. | | `triggers` | | How to react when a camera is [triggered](cameras/README.md?id=triggers). | | `default_cycle_camera` | `false` | When set to `true` the selected camera is cycled on each default view change. | @@ -63,6 +64,39 @@ Configure the key-bindings for the builtin keyboard shortcuts. See [usage](../us | alt | `false` | If `true` requires the `alt` key to be held. | | meta | `false` | If `true` requires the `meta` key to be held. | +## `theme` 🎨 + +All configuration is under: + +```yaml +view: + theme: [...] +``` + +Configure the theming/colors applied to the card. + +| Option | Default | Description | +| ----------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `themes` | `[traditional]` | A list of themes that are applied sequentially. Valid themes are shown below. Usually only a single value is needed. An empty list is treated the same as the default. | +| `overrides` | | A list of CSS keys that can be used to tweak the theming. | + +### `themes` + +| Theme | Description | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `dark` | Use a dark theme that is identical to the HA dark theme. | +| `ha` | Uses HA-prescribed theming. Respects HA choice of dark or light colors. | +| `light` | Use a light theme that is similar to the HA light theme (there are some differences if you do not use the standard choices of primary color). | +| `traditional` | A theme based on the default Frigate card theme before full theming support was added. Respects HA color theme choices. | + +### `overrides` + +Allows overriding of any CSS value, can be used to tweak theming parameters. + +| Option | Description | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | +| Any CSS key. Overriding the [Frigate Card](https://github.com/dermotduffy/frigate-hass-card/tree/main/src/scss/themes/base.scss) CSS variables allows changing individual theming paramters, e.g. `--frigate-card-menu-override-background` | Any CSS value, e.g. `red` or `rgba(10, 11, 12, 0.64)`. | + ## `triggers` The `triggers` block controls how the card reacts when a camera is triggered (note that _what_ triggers the camera is controlled by the [`triggers`](cameras/README.md?id=triggers) block within the config for a given camera). This can be used for a variety of purposes, such as allowing the card to automatically change to `live` for a camera that triggers. @@ -136,7 +170,7 @@ view: interaction_mode: inactive render_entities: - switch.render_card - dark_mode: 'off' + dim: false triggers: show_trigger_status: false filter_selected_camera: true @@ -161,6 +195,12 @@ view: key: '-' ptz_home: key: 'h' + theme: + themes: + - ha + overrides: + '--frigate-card-menu-button-active-color': red + '--frigate-card-menu-position-left-style-overlay-alignment-left-background': pink actions: entity: light.office_main_lights tap_action: diff --git a/docs/images/icons/iris.svg b/docs/images/icons/iris.svg new file mode 100644 index 00000000..0616d097 --- /dev/null +++ b/docs/images/icons/iris.svg @@ -0,0 +1,49 @@ + + + + + + + + + diff --git a/docs/index.html b/docs/index.html index 973ccb40..bc9cfc82 100644 --- a/docs/index.html +++ b/docs/index.html @@ -31,7 +31,7 @@ .svg-icon { background-color: rgb(255, 255, 255); vertical-align: middle; - background: repeating-conic-gradient(#8080804f 0% 25%, transparent 0% 50%) 50% / + background: repeating-conic-gradient(#80808091 0% 25%, transparent 0% 50%) 50% / 4px 4px; } diff --git a/docs/usage/custom-icons.md b/docs/usage/custom-icons.md index 558380dc..ce309191 100644 --- a/docs/usage/custom-icons.md +++ b/docs/usage/custom-icons.md @@ -8,5 +8,6 @@ builtin custom items not found in the traditional [Material Design Icons | Icon Name | Resulting Icon | | ----------- | --------------------------------------------------------------------------- | | `frigate` | ![Frigate Icon](../images/icons/frigate.svg ':size=24 :class=svg-icon') | +| `iris` | ![Iris Icon](../images/icons/iris.svg ':size=24 :class=svg-icon') | | `motioneye` | ![motionEye Icon](../images/icons/motioneye.svg ':size=24 :class=svg-icon') | | `reolink` | ![Reolink Icon](../images/icons/reolink.svg ':size=24 :class=svg-icon') | diff --git a/package.json b/package.json index 467d2fcb..ed742528 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "rollup-plugin-git-info": "^1.0.0", "rollup-plugin-serve": "^1.1.1", "rollup-plugin-styler": "^1.8.0", + "rollup-plugin-svgo": "^2.0.0", "rollup-plugin-visualizer": "^5.12.0", "sass": "patch:sass@npm%3A1.77.4#~/.yarn/patches/sass-npm-1.77.4-13b6910aea.patch", "semantic-release": "^24.1.1", diff --git a/rollup.config.js b/rollup.config.js index c6202371..ede62169 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -8,6 +8,7 @@ import typescript from '@rollup/plugin-typescript'; import gitInfo from 'rollup-plugin-git-info'; import serve from 'rollup-plugin-serve'; import styles from 'rollup-plugin-styler'; +import svgo from 'rollup-plugin-svgo'; import { visualizer } from 'rollup-plugin-visualizer'; const watch = process.env.ROLLUP_WATCH === 'true' || process.env.ROLLUP_WATCH === '1'; @@ -40,7 +41,8 @@ const plugins = [ includePaths: ['./node_modules/'], }, }), - image(), + svgo(), + image({ exclude: '**/*.svg' }), nodeResolve({ browser: true, }), diff --git a/scripts/docs-update-icons.sh b/scripts/docs-update-icons.sh index 32dc1399..0bb7de8a 100755 --- a/scripts/docs-update-icons.sh +++ b/scripts/docs-update-icons.sh @@ -3,6 +3,7 @@ # This script copies icons out of the source tree for documentation. FRIGATE="./src/camera-manager/frigate/assets/frigate.svg" +IRIS="./src/images/iris.svg" MOTIONEYE="./src/camera-manager/motioneye/assets/motioneye.svg" REOLINK="./src/camera-manager/reolink/assets/reolink.svg" DIR_ICONS="./docs/images/icons" @@ -14,5 +15,6 @@ copy() { } copy "$FRIGATE" +copy "$IRIS" copy "$MOTIONEYE" copy "$REOLINK" diff --git a/src/card-controller/config/config-manager.ts b/src/card-controller/config/config-manager.ts index f8408720..2aaa033e 100644 --- a/src/card-controller/config/config-manager.ts +++ b/src/card-controller/config/config-manager.ts @@ -97,7 +97,6 @@ export class ConfigManager { this._api.getViewManager().reset(); this._api.getMessageManager().reset(); - this._api.getStyleManager().setPerformance(); this._api.getStatusBarItemManager().removeAllDynamicStatusBarItems(); setKeyboardShortcutsFromConfig(this._api, this); @@ -134,7 +133,7 @@ export class ConfigManager { const previousConfig = this._overriddenConfig; this._overriddenConfig = overriddenConfig; - this._api.getStyleManager().setMinMaxHeight(); + this._api.getStyleManager().updateFromConfig(); if ( previousConfig && diff --git a/src/card-controller/hass/hass-manager.ts b/src/card-controller/hass/hass-manager.ts index 86ce88eb..fe967abf 100644 --- a/src/card-controller/hass/hass-manager.ts +++ b/src/card-controller/hass/hass-manager.ts @@ -53,8 +53,8 @@ export class HASSManager { }); } - // Dark mode may depend on HASS. - this._api.getStyleManager().setLightOrDarkMode(); + // Theme may depend on HASS. + this._api.getStyleManager().applyTheme(); this._stateWatcher.setHASS(oldHass, hass); } diff --git a/src/card-controller/interaction-manager.ts b/src/card-controller/interaction-manager.ts index 287fe4ca..546ee633 100644 --- a/src/card-controller/interaction-manager.ts +++ b/src/card-controller/interaction-manager.ts @@ -1,10 +1,12 @@ import throttle from 'lodash-es/throttle'; +import { setOrRemoveAttribute } from '../utils/basic'; import { Timer } from '../utils/timer'; import { CardInteractionAPI } from './types'; export class InteractionManager { protected _timer = new Timer(); protected _api: CardInteractionAPI; + protected _interacted = false; constructor(api: CardInteractionAPI) { this._api = api; @@ -17,29 +19,32 @@ export class InteractionManager { }, 1 * 1000); public initialize(): void { - this._api.getConditionsManager().setState({ interaction: false }); + this._setInteraction(false); } public hasInteraction(): boolean { - return this._timer.isRunning(); + return this._interacted; + } + + protected _setInteraction(val: boolean): void { + this._interacted = val; + setOrRemoveAttribute( + this._api.getCardElementManager().getElement(), + val, + 'interaction', + ); + this._api.getConditionsManager().setState({ interaction: val }); } - /** - * Start the user interaction ('screensaver') timer to reset the view to - * default `view.interaction_seconds` after user interaction. - */ protected _reportInteraction(): void { this._timer.stop(); + this._setInteraction(true); const timeoutSeconds = this._api.getConfigManager().getConfig() ?.view.interaction_seconds; - if (timeoutSeconds) { - this._api.getConditionsManager().setState({ interaction: true }); - this._timer.start(timeoutSeconds, () => { - this._api.getConditionsManager().setState({ interaction: false }); - this._api.getStyleManager().setLightOrDarkMode(); + this._setInteraction(false); }); } } diff --git a/src/card-controller/style-manager.ts b/src/card-controller/style-manager.ts index e959e0f3..894673a1 100644 --- a/src/card-controller/style-manager.ts +++ b/src/card-controller/style-manager.ts @@ -1,5 +1,10 @@ import { StyleInfo } from 'lit/directives/style-map'; -import { FrigateCardConfig } from '../config/types'; +import { + FrigateCardConfig, + frigateCardConfigDefaults, + ThemeConfig, + ThemeName, +} from '../config/types'; import { aspectRatioToStyle, setOrRemoveAttribute } from '../utils/basic'; import { View } from '../view/view'; import { CardStyleAPI } from './types'; @@ -11,21 +16,6 @@ export class StyleManager { this._api = api; } - public setLightOrDarkMode = (): void => { - const config = this._api.getConfigManager().getConfig(); - const isDarkMode = - config?.view.dark_mode === 'on' || - (config?.view.dark_mode === 'auto' && - (!this._api.getInteractionManager().hasInteraction() || - !!this._api.getHASSManager().getHASS()?.themes.darkMode)); - - setOrRemoveAttribute( - this._api.getCardElementManager().getElement(), - isDarkMode, - 'dark', - ); - }; - public setExpandedMode(): void { const card = this._api.getCardElementManager().getElement(); const view = this._api.getViewManager().getView(); @@ -58,7 +48,47 @@ export class StyleManager { ); } - public setMinMaxHeight(): void { + public updateFromConfig(): void { + this.applyTheme(); + this._setMinMaxHeight(); + this._setPerformance(); + this._setDimmable(); + } + + public applyTheme() { + const themeConfig = this._api.getConfigManager().getConfig()?.view.theme; + if (!themeConfig) { + return; + } + + const element = this._api.getCardElementManager().getElement(); + const themes = this._getThemeNames(themeConfig); + + setOrRemoveAttribute(element, !!themes, 'themes', themes?.join(' ')); + + if (themeConfig.overrides) { + for (const [key, value] of Object.entries(themeConfig.overrides)) { + element.style.setProperty(key, value); + } + } + } + + protected _getThemeNames(themeConfig: ThemeConfig): ThemeName[] | null { + return themeConfig.themes.length + ? themeConfig.themes + : frigateCardConfigDefaults.view.theme.themes; + } + + protected _setDimmable(): void { + const config = this._api.getConfigManager().getConfig(); + setOrRemoveAttribute( + this._api.getCardElementManager().getElement(), + !!config?.view.dim, + 'dimmable', + ); + } + + protected _setMinMaxHeight(): void { const config = this._api.getConfigManager().getConfig(); if (config) { const card = this._api.getCardElementManager().getElement(); @@ -66,7 +96,7 @@ export class StyleManager { } } - public setPerformance(): void { + protected _setPerformance(): void { const STYLE_DISABLE_MAP = { box_shadow: 'none', border_radius: '0px', diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index f55dae66..e9e25e86 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -180,6 +180,7 @@ export interface CardInitializerAPI { } export interface CardInteractionAPI { + getCardElementManager(): CardElementManager; getConditionsManager(): ConditionsManager; getConfigManager(): ConfigManager; getStyleManager(): StyleManager; diff --git a/src/card.ts b/src/card.ts index 8ecdc343..f993804f 100644 --- a/src/card.ts +++ b/src/card.ts @@ -218,12 +218,14 @@ class FrigateCard extends LitElement { const getContents = (kind: 'overlay' | 'outerlay'): TemplateResult => { const shouldRenderMenu = menuStyle !== 'none' && - ((menuStyle === 'outside' && kind === 'outerlay') || + ((menuStyle === 'outside' && kind === 'outerlay' && menuPosition === position) || (menuStyle !== 'outside' && kind === 'overlay')); const shouldRenderStatusBar = statusBarStyle !== 'none' && - ((statusBarStyle === 'outside' && kind === 'outerlay') || + ((statusBarStyle === 'outside' && + kind === 'outerlay' && + statusBarPosition === position) || (statusBarStyle !== 'outside' && kind === 'overlay')); // Complex logic to try to always put the menu in the right-looking place. @@ -233,8 +235,7 @@ class FrigateCard extends LitElement { (menuPosition === 'bottom' && menuStyle === 'hidden' && statusBarStyle !== 'popup') || - (menuPosition === 'top' && - (menuStyle !== 'hidden' || statusBarStyle === 'popup')); + (menuPosition === 'top' && statusBarStyle === 'popup'); return html` ${shouldRenderMenu && renderMenuFirst ? this._renderMenu(menuPosition) : ''} @@ -329,12 +330,20 @@ class FrigateCard extends LitElement { return; } + const outerlayUsed = + this._config?.menu.style === 'outside' || + this._config?.status_bar.style === 'outside'; + const mainClasses = { main: true, 'curve-top': - this._config?.menu.style !== 'outside' || this._config?.menu.position !== 'top', + !outerlayUsed || + (this._config?.menu.position !== 'top' && + this._config?.status_bar.position !== 'top'), 'curve-bottom': - this._config?.menu.style !== 'outside' || this._config?.menu.position === 'top', + !outerlayUsed || + (this._config?.menu.position !== 'bottom' && + this._config?.status_bar.position !== 'bottom'), }; const actions = this._controller.getActionsManager().getMergedActions(); diff --git a/src/components-lib/icon-controller.ts b/src/components-lib/icon-controller.ts index 4b4b4ab7..97ab232e 100644 --- a/src/components-lib/icon-controller.ts +++ b/src/components-lib/icon-controller.ts @@ -1,9 +1,10 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; +import { HassEntity } from 'home-assistant-js-websocket'; import frigateSVG from '../camera-manager/frigate/assets/frigate.svg'; import motioneyeSVG from '../camera-manager/motioneye/assets/motioneye.svg'; import reolinkSVG from '../camera-manager/reolink/assets/reolink.svg'; +import irisSVG from '../images/iris.svg'; import { Icon } from '../types'; -import { HassEntity } from 'home-assistant-js-websocket'; export class IconController { public getCustomIcon(icon?: Icon): string | null { @@ -14,6 +15,8 @@ export class IconController { return motioneyeSVG; case 'reolink': return reolinkSVG; + case 'iris': + return irisSVG; default: return null; } diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index b6ff9eec..d7125df0 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -647,11 +647,11 @@ export class MenuButtonController { if (critical) { return { animation: 'pulse 3s infinite', - color: 'var(--error-color, white)', + color: 'var(--frigate-card-menu-button-critical-color)', }; } return { - color: 'var(--primary-color, white)', + color: 'var(--frigate-card-menu-button-active-color)', }; } diff --git a/src/components-lib/status-bar-controller.ts b/src/components-lib/status-bar-controller.ts index 172e2770..fc16323c 100644 --- a/src/components-lib/status-bar-controller.ts +++ b/src/components-lib/status-bar-controller.ts @@ -67,6 +67,10 @@ export class StatusBarController { this._host.requestUpdate(); } + public getConfig(): StatusBarConfig | null { + return this._config; + } + public shouldRender(): boolean { return this._items.some((item) => item.enabled !== false && item.sufficient); } diff --git a/src/components/icon.ts b/src/components/icon.ts index a5d6fd23..94749f99 100644 --- a/src/components/icon.ts +++ b/src/components/icon.ts @@ -1,7 +1,13 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; -import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import { styleMap } from 'lit/directives/style-map.js'; import { IconController } from '../components-lib/icon-controller'; import iconStyle from '../scss/icon.scss'; import { Icon } from '../types'; @@ -14,17 +20,35 @@ export class FrigateCardIcon extends LitElement { @property({ attribute: false }) public icon?: Icon; + // Note: This attribute will allow non-active entity state styles (e.g. 'off', + // 'unavailable') to be overriden from outside the icon itself. This is useful + // in the menu / submenus where we want icons to follow menu theming, unless + // they are 'active'. This attribute is not used in code, but matched in + // icon.scss . + @property({ attribute: 'allow-override-non-active-styles', type: Boolean }) + public allowOverrideNonActiveStyles = false; + private _controller = new IconController(); + private _svg: HTMLElement | null = null; + + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('icon')) { + const customIcon = this._controller.getCustomIcon(this.icon); + if (customIcon) { + const svgElement = document.createElement('svg'); + svgElement.innerHTML = customIcon; + this._svg = svgElement; + } else { + this._svg = null; + } + } + } protected render(): TemplateResult { - const customIconURL = this._controller.getCustomIcon(this.icon); - const style = styleMap(this.icon?.style || {}); - - if (customIconURL) { - return html``; - } - if (this.icon?.icon) { - return html``; + if (this._svg) { + // Use SVG objects (rather than ) to ensure styling applies + // correctly. + return html`${this._svg}`; } if (this.hass && this.icon?.entity) { const stateObj = this._controller.createStateObjectForStateBadge( @@ -35,16 +59,19 @@ export class FrigateCardIcon extends LitElement { // As a special case, need to pass in a color in order for the state // color to be overridden. return html``; } } + if (this.icon?.icon) { + return html``; + } if (this.icon?.fallback) { - return html``; + return html``; } return html``; } diff --git a/src/components/loading.ts b/src/components/loading.ts index af196827..d55f9e41 100644 --- a/src/components/loading.ts +++ b/src/components/loading.ts @@ -1,12 +1,12 @@ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { customElement } from 'lit/decorators.js'; -import irisLogo from '../images/camera-iris-transparent.svg'; import loadingStyle from '../scss/loading.scss'; +import './icon'; @customElement('frigate-card-loading') export class FrigateCardLoading extends LitElement { protected render(): TemplateResult { - return html` `; + return html``; } static get styles(): CSSResultGroup { diff --git a/src/components/menu.ts b/src/components/menu.ts index 64b0259f..42c2ac21 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -66,8 +66,6 @@ export class FrigateCardMenu extends LitElement { : button.title; return html` this._controller.actionHandler(ev, button)} > `; } + /** Theme-related styling is dynamically injected into the menu depending on + * the configured position, style and alignment to allow precise theming. + * The alternative is a massive (post-sass processing) CSS file would need to + * be shipped to account for every possible combination. + * + * Each rule uses 'var' values that have nested fallbacks of decreasing + * specificity, so the most specific theme variable will match, followed by + * the next most specific, etc. + */ + protected _renderPerInstanceStyle(): TemplateResult | void { + const config = this._controller.getMenuConfig(); + if (!config) { + return; + } + + const position = config.position; + const style = config.style; + const alignment = config.alignment; + + const generateValue = (suffix: string): string => { + return ` + var(--frigate-card-menu-override-${suffix}, + var(--frigate-card-menu-position-${position}-alignment-${alignment}-style-${style}-${suffix}, + var(--frigate-card-menu-position-${position}-alignment-${alignment}-${suffix}, + var(--frigate-card-menu-position-${position}-${suffix}, + var(--frigate-card-menu-style-${style}-${suffix}, + var(--frigate-card-menu-alignment-${alignment}-${suffix}, + var(--frigate-card-menu-${suffix})))))))`; + }; + + // By definition `rule` will match the current configuration, the choice is + // actually which of the var(...) variables will be used after the match. + const expandedRule = style === 'hidden' ? '[expanded]' : ''; + const rule = + `[data-position='${position}']` + + `[data-style='${style}']` + + `[data-alignment='${alignment}']` + + expandedRule; + + return html``; + } + protected render(): TemplateResult | void { const config = this._controller.getMenuConfig(); const style = config?.style; @@ -97,7 +147,8 @@ export class FrigateCardMenu extends LitElement { const matchingButtons = this._controller.getButtons('matching'); const opposingButtons = this._controller.getButtons('opposing'); - return html`
diff --git a/src/components/next-prev-control.ts b/src/components/next-prev-control.ts index 7b1c4a6d..4c03f81c 100644 --- a/src/components/next-prev-control.ts +++ b/src/components/next-prev-control.ts @@ -63,7 +63,6 @@ export class FrigateCardNextPreviousControl extends LitElement { right: this.side === 'right', thumbnails: !renderIcon, icons: renderIcon, - button: renderIcon, }; if (renderIcon) { diff --git a/src/components/status-bar.ts b/src/components/status-bar.ts index 33750278..13c90814 100644 --- a/src/components/status-bar.ts +++ b/src/components/status-bar.ts @@ -36,12 +36,48 @@ export class FrigateCardStatusBar extends LitElement { } } + /** Theme-related styling is dynamically injected into the status bar depending on + * the configured position and style to allow precise theming. + * + * Each rule uses 'var' values that have nested fallbacks of decreasing + * specificity, so the most specific theme variable will match, followed by + * the next most specific, etc. + */ + protected _renderPerInstanceStyle(): TemplateResult | void { + const config = this._controller.getConfig(); + if (!config) { + return; + } + + const position = config.position; + const style = config.style; + + const generateValue = (suffix: string): string => { + return ` + var(--frigate-card-status-bar-override-${suffix}, + var(--frigate-card-status-bar-position-${position}-style-${style}-${suffix}, + var(--frigate-card-status-bar-position-${position}-${suffix}, + var(--frigate-card-status-bar-style-${style}-${suffix}, + var(--frigate-card-status-bar-${suffix})))))`; + }; + + const rule = `[data-position='${position}']` + `[data-style='${style}']`; + + return html``; + } + protected render(): TemplateResult | void { if (!this._controller.shouldRender()) { return; } return html` + ${this._renderPerInstanceStyle()}
${this._controller.getRenderItems().map((item): TemplateResult | void => { if (item.enabled === false) { diff --git a/src/components/submenu.ts b/src/components/submenu.ts index 0b2dfd8c..95eb4dcd 100644 --- a/src/components/submenu.ts +++ b/src/components/submenu.ts @@ -43,9 +43,9 @@ export class FrigateCardSubmenu extends LitElement { } const title = item.title ?? getEntityTitle(this.hass, item.entity); + const style = styleMap(item.style || {}); return html` - ${title ?? ''} - ${item.subtitle ? html`${item.subtitle}` : ''} + ${title ?? ''} + ${item.subtitle + ? html`${item.subtitle}` + : ''} `; @@ -96,7 +97,6 @@ export class FrigateCardSubmenu extends LitElement { > val === 'on', + }), ]; diff --git a/src/config/types.ts b/src/config/types.ts index e7b379e0..6b92cae4 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1515,7 +1515,10 @@ const viewConfigDefault = { interaction_mode: 'inactive' as const, }, default_cycle_camera: false, - dark_mode: 'off' as const, + dim: false, + theme: { + themes: ['traditional' as const], + }, triggers: { show_trigger_status: false, filter_selected_camera: true, @@ -1553,6 +1556,15 @@ export const triggersSchema = z.object({ }); export type TriggersOptions = z.infer; +const themeName = z.enum(['ha', 'dark', 'light', 'traditional']); +export type ThemeName = z.infer; + +const themeConfigSchema = z.object({ + themes: themeName.array().default(viewConfigDefault.theme.themes), + overrides: z.record(z.string()).optional(), +}); +export type ThemeConfig = z.infer; + const viewConfigSchema = z .object({ default: z @@ -1578,7 +1590,10 @@ const viewConfigSchema = z .default(viewConfigDefault.default_reset), render_entities: z.string().array().optional(), - dark_mode: z.enum(['on', 'off', 'auto']).optional(), + + theme: themeConfigSchema.default(viewConfigDefault.theme), + + dim: z.boolean().default(viewConfigDefault.dim), triggers: triggersSchema.default(viewConfigDefault.triggers), keyboard_shortcuts: keyboardShortcutsSchema.default( viewConfigDefault.keyboard_shortcuts, @@ -1721,7 +1736,7 @@ const statusBarItemDefault = { }; const statusBarConfigDefault = { - height: 30, + height: 40, items: { engine: statusBarItemDefault, resolution: statusBarItemDefault, diff --git a/src/const.ts b/src/const.ts index b60b536a..62c7116d 100644 --- a/src/const.ts +++ b/src/const.ts @@ -117,8 +117,8 @@ export const CONF_ELEMENTS = 'elements' as const; const CONF_VIEW = 'view' as const; export const CONF_VIEW_CAMERA_SELECT = `${CONF_VIEW}.camera_select` as const; -export const CONF_VIEW_DARK_MODE = `${CONF_VIEW}.dark_mode` as const; export const CONF_VIEW_DEFAULT = `${CONF_VIEW}.default` as const; +export const CONF_VIEW_DIM = `${CONF_VIEW}.dim` as const; export const CONF_VIEW_INTERACTION_SECONDS = `${CONF_VIEW}.interaction_seconds` as const; export const CONF_VIEW_KEYBOARD_SHORTCUTS = `${CONF_VIEW}.keyboard_shortcuts` as const; export const CONF_VIEW_KEYBOARD_SHORTCUTS_ENABLED = @@ -148,6 +148,7 @@ export const CONF_VIEW_DEFAULT_RESET_ENTITIES = `${CONF_VIEW_DEFAULT_RESET}.entities` as const; export const CONF_VIEW_DEFAULT_RESET_AFTER_INTERACTION = `${CONF_VIEW_DEFAULT_RESET}.after_interaction` as const; +export const CONF_VIEW_THEME_THEMES = `${CONF_VIEW}.theme.themes` as const; export const CONF_VIEW_TRIGGERS = `${CONF_VIEW}.triggers` as const; export const CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS = `${CONF_VIEW_TRIGGERS}.show_trigger_status` as const; diff --git a/src/editor.ts b/src/editor.ts index fc882a6d..5e718926 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -200,7 +200,6 @@ import { CONF_TIMELINE_STYLE, CONF_TIMELINE_WINDOW_SECONDS, CONF_VIEW_CAMERA_SELECT, - CONF_VIEW_DARK_MODE, CONF_VIEW_DEFAULT, CONF_VIEW_DEFAULT_CYCLE_CAMERA, CONF_VIEW_DEFAULT_RESET, @@ -208,6 +207,7 @@ import { CONF_VIEW_DEFAULT_RESET_ENTITIES, CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS, CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE, + CONF_VIEW_DIM, CONF_VIEW_INTERACTION_SECONDS, CONF_VIEW_KEYBOARD_SHORTCUTS, CONF_VIEW_KEYBOARD_SHORTCUTS_ENABLED, @@ -218,6 +218,7 @@ import { CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_UP, CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_IN, CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_OUT, + CONF_VIEW_THEME_THEMES, CONF_VIEW_TRIGGERS, CONF_VIEW_TRIGGERS_ACTIONS, CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE, @@ -552,13 +553,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { value: 'stack', label: localize('config.common.timeline.styles.stack') }, ]; - protected _darkModes: EditorSelectOption[] = [ - { value: '', label: '' }, - { value: 'on', label: localize('config.view.dark_modes.on') }, - { value: 'off', label: localize('config.view.dark_modes.off') }, - { value: 'auto', label: localize('config.view.dark_modes.auto') }, - ]; - protected _mediaActionNegativeConditions: EditorSelectOption[] = [ { value: '', label: '' }, { @@ -912,6 +906,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { value: 'bottom', label: localize('config.status_bar.positions.bottom') }, ]; + protected _themes: EditorSelectOption[] = [ + { value: '', label: '' }, + { value: 'ha', label: localize('config.view.theme.themes.ha') }, + { value: 'dark', label: localize('config.view.theme.themes.dark') }, + { value: 'light', label: localize('config.view.theme.themes.light') }, + { value: 'traditional', label: localize('config.view.theme.themes.traditional') }, + ]; + public setConfig(config: RawFrigateCardConfig): void { // Note: This does not use Zod to parse the full configuration, so it may be // partially or completely invalid. It's more useful to have a partially @@ -1928,7 +1930,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ? html`
{ @@ -2525,7 +2524,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_VIEW_CAMERA_SELECT, this._cameraSelectViewModes, )} - ${this._renderOptionSelector(CONF_VIEW_DARK_MODE, this._darkModes)} + ${this._renderSwitch(CONF_VIEW_DIM, this._defaults.view.dim)} ${this._renderNumberInput(CONF_VIEW_INTERACTION_SECONDS)} ${this._renderSwitch( CONF_VIEW_DEFAULT_CYCLE_CAMERA, @@ -2533,6 +2532,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor )} ${this._renderViewDefaultResetMenu()} ${this._renderViewTriggersMenu()} ${this._renderViewKeyboardShortcutMenu()} + ${this._renderOptionSelector(CONF_VIEW_THEME_THEMES, this._themes, { + label: localize('config.view.theme.themes.editor_label'), + multiple: true, + })}
` : ''} diff --git a/src/images/README.md b/src/images/README.md index 1c3f584c..0a561e50 100644 --- a/src/images/README.md +++ b/src/images/README.md @@ -22,8 +22,13 @@ $ convert -extent 2048x1152 -flop -gravity center 47543120431_b285c45ac8_k.jpg f $ mogrify -strip -interlace Plane -quality 85% -scale 492x277 frigate-bird-in-sky.jpg ``` -## camera-iris.svg +## iris.svg / iris-outline.svg **Link**: https://pictogrammers.com/library/mdi/icon/camera-iris/ **License**: https://www.apache.org/licenses/LICENSE-2.0 + +**Image Formatting Process**: + +- Outline addded manually +- Opacity adjustmented manually diff --git a/src/images/camera-iris.svg b/src/images/camera-iris.svg deleted file mode 100644 index dd0cbf5e..00000000 --- a/src/images/camera-iris.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/images/camera-iris-transparent.svg b/src/images/iris-background.svg similarity index 84% rename from src/images/camera-iris-transparent.svg rename to src/images/iris-background.svg index 52ffd1e8..53748cb5 100644 --- a/src/images/camera-iris-transparent.svg +++ b/src/images/iris-background.svg @@ -1,7 +1,7 @@ + style="fill-opacity:0.50221002;fill:#ffffff;opacity:0.75" /> diff --git a/src/images/iris.svg b/src/images/iris.svg new file mode 100644 index 00000000..0616d097 --- /dev/null +++ b/src/images/iris.svg @@ -0,0 +1,49 @@ + + + + + + + + + diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json index 229a9026..1ffba635 100644 --- a/src/localize/languages/ca.json +++ b/src/localize/languages/ca.json @@ -481,12 +481,6 @@ }, "view": { "camera_select": "Visualitza les càmeres seleccionades recentment", - "dark_mode": "Mode fosc", - "dark_modes": { - "auto": "Automàtic", - "off": "Desactivat", - "on": "Activat" - }, "default": "Vista per defecte", "default_cycle_camera": "Passeu per les càmeres quan s'actualitzi la vista predeterminada", "default_reset": { @@ -501,6 +495,7 @@ "inactive": "" } }, + "dim": "", "interaction_seconds": "Segons després de l'acció de l'usuari per continuar interactuant (0=mai)", "keyboard_shortcuts": { "editor_label": "", @@ -513,6 +508,15 @@ "ptz_zoom_in": "", "ptz_zoom_out": "" }, + "theme": { + "themes": { + "dark": "", + "ha": "", + "editor_label": "", + "light": "", + "traditional": "" + } + }, "triggers": { "actions": { "editor_label": "Activar accions", diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 41f67155..2472387c 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -481,12 +481,6 @@ }, "view": { "camera_select": "View for newly selected cameras", - "dark_mode": "Dark mode", - "dark_modes": { - "auto": "Auto", - "off": "Off", - "on": "On" - }, "default": "Default view", "default_cycle_camera": "Cycle through cameras when default view updates", "default_reset": { @@ -501,6 +495,7 @@ "inactive": "Only reset when card has no human interaction" } }, + "dim": "Dim when no interaction", "interaction_seconds": "Seconds after user action to remain interacted with (0=never)", "keyboard_shortcuts": { "editor_label": "Keyboard shortcuts", @@ -513,6 +508,15 @@ "ptz_zoom_in": "PTZ Zoom In", "ptz_zoom_out": "PTZ Zoom Out" }, + "theme": { + "themes": { + "dark": "Dark", + "ha": "Home Assistant", + "editor_label": "Themes", + "light": "Light", + "traditional": "Traditional" + } + }, "triggers": { "actions": { "editor_label": "Trigger actions", diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json index bdf3d02e..2ace6383 100644 --- a/src/localize/languages/fr.json +++ b/src/localize/languages/fr.json @@ -481,12 +481,6 @@ }, "view": { "camera_select": "Afficher les caméras nouvellement sélectionnées", - "dark_mode": "Mode sombre", - "dark_modes": { - "auto": "Automatique", - "off": "Désactivé", - "on": "Activé" - }, "default": "Vue par défaut", "default_cycle_camera": "Parcourez les caméras lorsque la vue par défaut est mise à jour", "default_reset": { @@ -501,6 +495,7 @@ "inactive": "Réinitialiser uniquement lorsque la carte n'a pas d'interaction humaine" } }, + "dim": "", "interaction_seconds": "Secondes après l'action de l'utilisateur pour maintenir l'interaction (0=jamais)", "keyboard_shortcuts": { "editor_label": "Raccourcis clavier", @@ -513,6 +508,15 @@ "ptz_zoom_in": "PTZ zoomer", "ptz_zoom_out": "PTZ dézoomer" }, + "theme": { + "themes": { + "dark": "", + "ha": "", + "editor_label": "", + "light": "", + "traditional": "" + } + }, "triggers": { "actions": { "editor_label": "Actions de déclenchement", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 528f09f4..64d1b606 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -481,12 +481,6 @@ }, "view": { "camera_select": "Visualizza per le telecamere appena selezionate", - "dark_mode": "Tema scuro", - "dark_modes": { - "auto": "auto", - "off": "Off", - "on": "On" - }, "default": "Visualizzazione predefinita", "default_cycle_camera": "Scorri le telecamere quando si aggiorna la visualizzazione predefinita", "default_reset": { @@ -501,6 +495,7 @@ "inactive": "" } }, + "dim": "", "interaction_seconds": "", "keyboard_shortcuts": { "editor_label": "", @@ -513,6 +508,15 @@ "ptz_zoom_in": "", "ptz_zoom_out": "" }, + "theme": { + "themes": { + "dark": "", + "ha": "", + "editor_label": "", + "light": "", + "traditional": "" + } + }, "triggers": { "actions": { "editor_label": "", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index c15db734..ac96b25f 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -481,12 +481,6 @@ }, "view": { "camera_select": "Visualização de câmeras recém-selecionadas", - "dark_mode": "Modo escuro", - "dark_modes": { - "auto": "Automático", - "off": "Desligado", - "on": "Ligado" - }, "default": "Visualização padrão", "default_cycle_camera": "Percorrer as câmeras quando a visualização padrão for atualizada", "default_reset": { @@ -501,6 +495,7 @@ "inactive": "" } }, + "dim": "", "interaction_seconds": "", "keyboard_shortcuts": { "editor_label": "", @@ -513,6 +508,15 @@ "ptz_zoom_in": "", "ptz_zoom_out": "" }, + "theme": { + "themes": { + "dark": "", + "ha": "", + "editor_label": "", + "light": "", + "traditional": "" + } + }, "triggers": { "actions": { "editor_label": "", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index d9a96161..6e52dd73 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -481,12 +481,6 @@ }, "view": { "camera_select": "Visualização de câmeras recém-selecionadas", - "dark_mode": "Modo escuro", - "dark_modes": { - "auto": "Automático", - "off": "Desligado", - "on": "Ligado" - }, "default": "Visualização padrão", "default_cycle_camera": "Percorrer as câmeras quando a visualização padrão for atualizada", "default_reset": { @@ -501,6 +495,7 @@ "inactive": "" } }, + "dim": "", "interaction_seconds": "", "keyboard_shortcuts": { "editor_label": "", @@ -513,6 +508,15 @@ "ptz_zoom_in": "", "ptz_zoom_out": "" }, + "theme": { + "themes": { + "dark": "", + "ha": "", + "editor_label": "", + "light": "", + "traditional": "" + } + }, "triggers": { "actions": { "editor_label": "", diff --git a/src/scss/button.scss b/src/scss/button.scss index ea92c2cc..d2ac2a92 100644 --- a/src/scss/button.scss +++ b/src/scss/button.scss @@ -1,6 +1,7 @@ -ha-icon-button.button { - color: var(--secondary-color, white); - background-color: rgba(0, 0, 0, 0.6); +ha-icon-button { + color: var(--frigate-card-button-color); + background-color: var(--frigate-card-button-background); + border-radius: 50%; padding: 0px; margin: 3px; @@ -8,5 +9,4 @@ ha-icon-button.button { /* Buttons can always be clicked */ pointer-events: auto; - opacity: 0.9; } diff --git a/src/scss/card.scss b/src/scss/card.scss index 1761c8b3..a416ef16 100644 --- a/src/scss/card.scss +++ b/src/scss/card.scss @@ -1,3 +1,5 @@ +@use './themes/base.scss'; + :host { display: block; @@ -36,7 +38,7 @@ frigate-card-loading { z-index: 1; } -:host([dark]) { +:host([dimmable]:not([interaction])) { filter: brightness(75%); } :host([panel]:not([casted])) { diff --git a/src/scss/drawer.scss b/src/scss/drawer.scss index 466f2183..96395559 100644 --- a/src/scss/drawer.scss +++ b/src/scss/drawer.scss @@ -37,9 +37,8 @@ div.control-surround { } frigate-card-icon.control { - color: var(--secondary-color, white); - background-color: rgba(0, 0, 0, 0.7); - opacity: 0.5; + color: var(--frigate-card-button-color); + background-color: var(--frigate-card-button-background); pointer-events: all; --mdc-icon-size: #{$drawer-icon-size}; @@ -54,7 +53,6 @@ frigate-card-icon.control:hover { // When the drawer is open or hovered make the button to close it more // prominent. opacity: 1; - background-color: black; } :host([location='left']) frigate-card-icon.control { diff --git a/src/scss/editor.scss b/src/scss/editor.scss index a9982070..def0ed45 100644 --- a/src/scss/editor.scss +++ b/src/scss/editor.scss @@ -98,7 +98,7 @@ div.upgrade span { margin-right: 0px; margin-bottom: 5px; } -.submenu .controls ha-icon-button.button { +.submenu .controls ha-icon-button { --mdc-icon-button-size: 32px; --mdc-icon-size: calc(var(--mdc-icon-button-size) / 2); } diff --git a/src/scss/icon.scss b/src/scss/icon.scss index 3826c6b3..598c174f 100644 --- a/src/scss/icon.scss +++ b/src/scss/icon.scss @@ -12,3 +12,13 @@ width: 100%; box-sizing: border-box; } + +svg path { + // !important is necessary to overcome the fill set on the path itself. + fill: currentColor !important; +} + +:host([allow-override-non-active-styles]) state-badge { + --state-inactive-color: inherit; + --state-unavailable-color: inherit; +} diff --git a/src/scss/loading.scss b/src/scss/loading.scss index ba21e506..090ddbe1 100644 --- a/src/scss/loading.scss +++ b/src/scss/loading.scss @@ -9,8 +9,9 @@ pointer-events: none; } -img { - width: 10%; +frigate-card-icon { + height: 50%; + width: auto; animation: rotate 8s linear infinite; } diff --git a/src/scss/media-background.scss b/src/scss/media-background.scss index 879a3e6c..8da2aa8b 100644 --- a/src/scss/media-background.scss +++ b/src/scss/media-background.scss @@ -1,4 +1,5 @@ -$bg-img: url('../images/camera-iris-transparent.svg'); +// Use the outlined Iris logo since the background color is unknown. +$bg-img: url('../images/iris-background.svg'); :host { background-color: var(--primary-background-color); diff --git a/src/scss/menu.scss b/src/scss/menu.scss index 2f4145cf..bf2d2f93 100644 --- a/src/scss/menu.scss +++ b/src/scss/menu.scss @@ -15,6 +15,10 @@ overflow: visible; } +:host([data-style='outside']) { + width: 100%; +} + /******************************************************* * Non-hover styles should not interact with the pointer *******************************************************/ @@ -45,15 +49,6 @@ div.opposing { justify-content: flex-end; } -/******************** - * Outside menu style - ********************/ - -:host([data-style='outside']) { - width: 100%; - background: var(--secondary-background-color); -} - /******************************************************** * Hack: Ensure host & div expand for column flex layouts ********************************************************/ @@ -126,24 +121,6 @@ div.opposing { height: auto; } -:host([data-style='overlay'][data-position='top'][data-alignment='left']), -:host([data-style='overlay'][data-position='bottom'][data-alignment='left']), -:host([data-style*='hover'][data-position='top'][data-alignment='left']), -:host([data-style*='hover'][data-position='bottom'][data-alignment='left']), -:host([data-style='hidden'][data-position='top'][data-alignment='left'][expanded]), -:host([data-style='hidden'][data-position='bottom'][data-alignment='left'][expanded]) { - background: linear-gradient(90deg, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0)); -} - -:host([data-style='overlay'][data-position='top'][data-alignment='right']), -:host([data-style='overlay'][data-position='bottom'][data-alignment='right']), -:host([data-style*='hover'][data-position='top'][data-alignment='right']), -:host([data-style*='hover'][data-position='bottom'][data-alignment='right']), -:host([data-style='hidden'][data-position='top'][data-alignment='right'][expanded]), -:host([data-style='hidden'][data-position='bottom'][data-alignment='right'][expanded]) { - background: linear-gradient(270deg, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0)); -} - :host([data-style='overlay'][data-position='left']), :host([data-style='overlay'][data-position='right']), :host([data-style*='hover'][data-position='left']), @@ -154,20 +131,24 @@ div.opposing { height: 100%; } -:host([data-style='overlay'][data-position='left'][data-alignment='top']), -:host([data-style='overlay'][data-position='right'][data-alignment='top']), -:host([data-style*='hover'][data-position='left'][data-alignment='top']), -:host([data-style*='hover'][data-position='right'][data-alignment='top']), -:host([data-style='hidden'][data-position='left'][data-alignment='top'][expanded]), -:host([data-style='hidden'][data-position='right'][data-alignment='top'][expanded]) { - background: linear-gradient(180deg, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0)); +/************ + * Menu theme + ************/ + +// When the menu uses the hidden style, and is not expanded, the remaining +// permanent menu buttons use the menu background color instead of the button +// background color. +:host([data-style='hidden']:not([expanded])) ha-icon-button { + background: var(--frigate-card-menu-background); } -:host([data-style='overlay'][data-position='left'][data-alignment='bottom']), -:host([data-style='overlay'][data-position='right'][data-alignment='bottom']), -:host([data-style*='hover'][data-position='left'][data-alignment='bottom']), -:host([data-style*='hover'][data-position='right'][data-alignment='bottom']), -:host([data-style='hidden'][data-position='left'][data-alignment='bottom'][expanded]), -:host([data-style='hidden'][data-position='right'][data-alignment='bottom'][expanded]) { - background: linear-gradient(0deg, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0)); +// Icons in the menu are expected to follow Frigate card theming unless they are +// active (in which case we want to take advantage of the whatever styling is +// appropriate, e.g. light icon partially lit). +:host ha-icon-button { + --state-unavailable-color: var(--frigate-card-button-color); + --state-inactive-color: var(--frigate-card-button-color); } + +// Further theme related styling is dynamically applied by `menu.ts`, see +// `_renderPerInstanceStyle`. diff --git a/src/scss/status.scss b/src/scss/status.scss index fef91a30..8ee52e53 100644 --- a/src/scss/status.scss +++ b/src/scss/status.scss @@ -1,7 +1,7 @@ @use './button.scss'; :host { - --mdc-icon-size: calc(var(--frigate-card-status-bar-height) - 6px); + --mdc-icon-size: calc(var(--frigate-card-status-bar-height) / 2); display: block; width: 100%; @@ -20,23 +20,10 @@ pointer-events: none; } -/*************** - * Outside style - ***************/ +/********************** + * Popup style behavior + **********************/ -:host([data-style='outside']) { - color: var(--primary-text-color); - background: var(--secondary-background-color); -} - -/******************* - * Non-outside style - *******************/ - -:host(:not([data-style='outside'])) { - color: white; - background: linear-gradient(90deg, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0)); -} :host([data-style='popup'][hide]) { opacity: 0; } @@ -63,8 +50,6 @@ margin: 3px 5px; align-content: center; - - opacity: 0.7; } .item:first-child { margin-left: 10px; @@ -81,7 +66,8 @@ cursor: pointer; } -img.item { +img.item, +frigate-card-icon.item { display: block; // To ensure images render somewhat reasonably looking their height is kept to @@ -89,3 +75,10 @@ img.item { height: var(--mdc-icon-size, 24px); width: auto; } + +/************************ + * Status bar theme hooks + ************************/ + +// Further theme related styling is dynamically applied by `status-bar.ts`, see +// `_renderPerInstanceStyle`. diff --git a/src/scss/submenu.scss b/src/scss/submenu.scss index 482e4084..36e222f1 100644 --- a/src/scss/submenu.scss +++ b/src/scss/submenu.scss @@ -7,3 +7,14 @@ mwc-list-item { z-index: 20; } + +ha-icon-button { + // The main submenu icon is expected to follow Frigate card theming unless it + // is are active (in which case we want to take advantage of the whatever + // styling is appropriate, e.g. light icon partially lit). + --state-unavailable-color: var(--frigate-card-button-color); + --state-inactive-color: var(--frigate-card-button-color); + + color: var(--frigate-card-menu-button-inactive-color); + background-color: var(--frigate-card-menu-button-background); +} diff --git a/src/scss/themes/base.scss b/src/scss/themes/base.scss new file mode 100644 index 00000000..200ec8bd --- /dev/null +++ b/src/scss/themes/base.scss @@ -0,0 +1,127 @@ +@use 'dark.scss'; +@use 'light.scss'; +@use 'traditional.scss'; + +:host { + /********* + * General + *********/ + + --frigate-card-exterior: var(--secondary-background-color, black); + --frigate-card-background: var(--primary-background-color, white); + + --frigate-card-foreground-primary: var(--primary-color, black); + + --frigate-card-active-color: var(--accent-color, orange); + --frigate-card-warning-color: var(--error-color, red); + + --frigate-card-text-color: var(--primary-text-color); + --frigate-card-divider-color: var(--divider-color); + + --frigate-card-control-background: var(--app-header-background-color); + --frigate-card-control-background-transparent: color-mix( + in oklab, + var(--frigate-card-control-background), + transparent 60% + ); + --frigate-card-control-foreground: var(--app-header-text-color); + + /********* + * Buttons + *********/ + + // The color of buttons (in general, menu or otherwise). + --frigate-card-button-color: var(--frigate-card-control-foreground); + + // The color of button background "circles". + --frigate-card-button-background: var(--frigate-card-control-background-transparent); + + /****** + * Menu + ******/ + + // The color of an active icon in the menu. + --frigate-card-menu-button-active-color: var(--frigate-card-active-color); + + // The color of an inactive icon in the menu. + --frigate-card-menu-button-inactive-color: var(--frigate-card-button-color); + + // The color of a critical icon in the menu. + --frigate-card-menu-button-critical-color: var( + --error-color, + var(--frigate-card-warning-color) + ); + + // The background color of the button "circles" in the menu. + --frigate-card-menu-button-background: color-mix( + in oklab, + var(--frigate-card-menu-button-inactive-color), + transparent 60% + ); + + // The menu background color. + --frigate-card-menu-background: var(--frigate-card-control-background-transparent); + --frigate-card-menu-style-outside-background: var(--frigate-card-control-background); + + // May be overridden by the user to force a particular color. + --frigate-card-menu-override-background: unset; + --frigate-card-menu-override-button-inactive-color: unset; + --frigate-card-menu-override-button-background: unset; + + // Examples of precise theming for the menu: + // --frigate-card-menu-position-left-alignment-left-style-overlay-background: pink; + // --frigate-card-menu-position-left-background: purple; + // --frigate-card-menu-style-overlay-background: pink; + + /************ + * Status bar + ************/ + + // May be overridden by the user to force a particular color. + --frigate-card-status-bar-override-background: unset; + --frigate-card-status-bar-override-color: unset; + + // The color of items on the status bar. + --frigate-card-status-bar-color: var(--frigate-card-control-foreground); + + // The background color of the outerlay above/below the card. + --frigate-card-status-bar-background: var( + --frigate-card-control-background-transparent + ); + --frigate-card-status-bar-style-outside-background: var( + --frigate-card-control-background + ); + + /************ + * Thumbnails + ************/ + + --frigate-card-thumbnail-icon-color: var(--frigate-card-foreground-primary); + --frigate-card-thumbnail-border-color: var(--frigate-card-foreground-primary); + --frigate-card-thumbnail-background: var(--frigate-card-background); + --frigate-card-thumbnail-icon-favorite-color: color-mix( + in oklab, + gold, + transparent 25% + ); + --frigate-card-thumbnail-text-color: var(--frigate-card-text-color); + + /********** + * Timeline + **********/ + + --frigate-card-timeline-text-color: var(--frigate-card-text-color); + --frigate-card-timeline-item-color: var(--frigate-card-foreground-primary); + --frigate-card-timeline-tool-color: var(--frigate-card-foreground-primary); + --frigate-card-timeline-background: var(--frigate-card-background); + + // style for the 'background' style of item (i.e. recordings). + --frigate-card-timeline-background-item-color: color-mix( + in oklab, + var(--frigate-card-timeline-item-color), + transparent 90% + ); + + --frigate-card-timeline-divider-color: var(--frigate-card-divider-color); + --frigate-card-timeline-target-bar-color: var(--frigate-card-active-color); +} diff --git a/src/scss/themes/dark.scss b/src/scss/themes/dark.scss new file mode 100644 index 00000000..a4b9d21b --- /dev/null +++ b/src/scss/themes/dark.scss @@ -0,0 +1,11 @@ +:host([themes~='dark']) { + // This file should include only, and exactly match, values from + // https://github.com/home-assistant/frontend/blob/dev/src/resources/styles-data.ts . + + --primary-background-color: #111111; + --secondary-background-color: #282828; + --primary-text-color: #e1e1e1; + --divider-color: rgba(225, 225, 225, 0.12); + --app-header-background-color: #101e24; + --app-header-text-color: #e1e1e1; +} diff --git a/src/scss/themes/light.scss b/src/scss/themes/light.scss new file mode 100644 index 00000000..72f38532 --- /dev/null +++ b/src/scss/themes/light.scss @@ -0,0 +1,17 @@ +:host([themes~='light']) { + // This file should include only, and exactly match, values from + // https://github.com/home-assistant/frontend/blob/dev/src/resources/ha-style.ts + + --primary-background-color: #fafafa; + --secondary-background-color: #e5e5e5; + --primary-text-color: #212121; + --divider-color: rgba(0, 0, 0, 0.12); + --app-header-text-color: #ffffff; + + // HA dynamically sets these values based on choice of primary/secondary + // colors and hex blending techniques. The card does not currently + // re-implement this logic, rather just uses the default static value. + // See: https://github.com/home-assistant/frontend/blob/dev/src/common/dom/apply_themes_on_element.ts + + --app-header-background-color: #03a9f4; +} diff --git a/src/scss/themes/traditional.scss b/src/scss/themes/traditional.scss new file mode 100644 index 00000000..b3b2fe2b --- /dev/null +++ b/src/scss/themes/traditional.scss @@ -0,0 +1,99 @@ +@mixin background-gradient($var, $angle) { + #{$var}: linear-gradient( + $angle, + var(--frigate-card-menu-background), + var(--frigate-card-menu-background), + var(--frigate-card-menu-background), + color-mix(in oklab, var(--frigate-card-menu-background), transparent 100%) + ); +} + +:host([themes~='traditional']) { + /********* + * General + *********/ + + --frigate-card-control-background: black; + + /****** + * Menu + ******/ + + --frigate-card-menu-button-active-color: var(--primary-color, white); + --frigate-card-menu-button-background: rgba(0, 0, 0, 0.6); + + @include background-gradient( + '--frigate-card-menu-position-left-alignment-top-background', + 180deg + ); + @include background-gradient( + '--frigate-card-menu-position-left-alignment-bottom-background', + 0deg + ); + @include background-gradient( + '--frigate-card-menu-position-right-alignment-top-background', + 180deg + ); + @include background-gradient( + '--frigate-card-menu-position-right-alignment-bottom-background', + 0deg + ); + + @include background-gradient( + '--frigate-card-menu-position-top-alignment-left-background', + 90deg + ); + @include background-gradient( + '--frigate-card-menu-position-top-alignment-right-background', + 270deg + ); + @include background-gradient( + '--frigate-card-menu-position-bottom-alignment-left-background', + 90deg + ); + @include background-gradient( + '--frigate-card-menu-position-bottom-alignment-right-background', + 270deg + ); + + --frigate-card-menu-position-top-alignment-left-style-outside-background: var( + --secondary-background-color + ); + --frigate-card-menu-position-top-alignment-right-style-outside-background: var( + --secondary-background-color + ); + --frigate-card-menu-position-bottom-alignment-left-style-outside-background: var( + --secondary-background-color + ); + --frigate-card-menu-position-bottom-alignment-right-style-outside-background: var( + --secondary-background-color + ); + + /************ + * Status bar + ************/ + + --frigate-card-status-bar-color: white; + + @include background-gradient( + '--frigate-card-status-bar-position-top-background', + 90deg + ); + @include background-gradient( + '--frigate-card-status-bar-position-bottom-background', + 90deg + ); + + --frigate-card-status-bar-position-top-style-outside-background: var( + --secondary-background-color + ); + --frigate-card-status-bar-position-bottom-style-outside-background: var( + --secondary-background-color + ); + --frigate-card-status-bar-position-top-style-outside-color: var( + --secondary-text-color + ); + --frigate-card-status-bar-position-bottom-style-outside-color: var( + --secondary-text-color + ); +} diff --git a/src/scss/thumbnail-carousel.scss b/src/scss/thumbnail-carousel.scss index 410ceb93..d4c77eaa 100644 --- a/src/scss/thumbnail-carousel.scss +++ b/src/scss/thumbnail-carousel.scss @@ -5,6 +5,8 @@ width: 100%; height: 100%; + background-color: var(--frigate-card-thumbnail-background); + --frigate-card-carousel-thumbnail-opacity: 1; } diff --git a/src/scss/thumbnail-details.scss b/src/scss/thumbnail-details.scss index 77da5f4f..fcf9a537 100644 --- a/src/scss/thumbnail-details.scss +++ b/src/scss/thumbnail-details.scss @@ -5,7 +5,7 @@ width: 100%; margin-left: 5px; padding: 5px; - color: var(--primary-text-color); + color: var(--frigate-card-thumbnail-text-color); overflow: hidden; column-gap: 5%; } diff --git a/src/scss/thumbnail.scss b/src/scss/thumbnail.scss index c0cc6638..5054e93b 100644 --- a/src/scss/thumbnail.scss +++ b/src/scss/thumbnail.scss @@ -15,7 +15,7 @@ } :host([details]) { - border: 1px solid var(--primary-color); + border: 1px solid var(--frigate-card-thumbnail-border-color); border-radius: var( --frigate-card-css-border-radius, var(--ha-card-border-radius, 4px) @@ -24,7 +24,7 @@ // When details are enabled, use a background color so that the details have // contrast with the background. - background-color: var(--primary-background-color, black); + background-color: var(--frigate-card-thumbnail-background); } :host(:hover) { @@ -34,8 +34,7 @@ frigate-card-icon { position: absolute; border-radius: 50%; - opacity: 0.5; - color: var(--primary-color); + color: var(--frigate-card-thumbnail-icon-color); cursor: pointer; @@ -52,7 +51,7 @@ frigate-card-icon.star { left: 3px; } frigate-card-icon.star.starred { - color: gold; + color: var(--frigate-card-thumbnail-icon-favorite-color); } frigate-card-icon.timeline { diff --git a/src/scss/timeline-core.scss b/src/scss/timeline-core.scss index 0de57ac7..990c7149 100644 --- a/src/scss/timeline-core.scss +++ b/src/scss/timeline-core.scss @@ -10,6 +10,8 @@ // So that absolute sidedrawer is relative to this host. position: relative; + + background-color: var(--frigate-card-timeline-background); } :host(:not([mini])) { @@ -30,7 +32,7 @@ div.timeline { } .vis-text { - color: var(--primary-text-color) !important; + color: var(--frigate-card-timeline-text-color) !important; } .vis-timeline { @@ -39,7 +41,7 @@ div.timeline { .vis-labelset .vis-label { // Group labels. - color: var(--primary-text-color); + color: var(--frigate-card-timeline-text-color); } :host([ribbon]:not([groups])) .vis-item:not(.vis-background) { @@ -52,12 +54,11 @@ div.timeline { // Ribbon timelines have a much shorter 'bar'. height: 6px; } - .vis-item { - border-color: var(--primary-color); + border-color: var(--frigate-card-timeline-item-color); background: none; - color: var(--primary-text-color); - background-color: var(--primary-color); + color: var(--frigate-card-timeline-text-color); + background-color: var(--frigate-card-timeline-item-color); transition: background-color ease-in-out 1s, @@ -65,13 +66,15 @@ div.timeline { box-shadow ease-in-out 1s; } .vis-item.vis-selected { - border-color: var(--accent-color); - background-color: var(--accent-color); - box-shadow: var(--frigate-card-css-box-shadow, 0px 0px 5px 1px var(--primary-color)); + border-color: var(--frigate-card-active-color); + background-color: var(--frigate-card-active-color); + box-shadow: var( + --frigate-card-css-box-shadow, + 0px 0px 5px 1px var(--frigate-card-active-color) + ); } .vis-item.vis-background { - background-color: var(--primary-color); - opacity: 0.1; + background-color: var(--frigate-card-timeline-background-item-color); } // If there are no timeline groups shown (e.g. mini mode with a single camera), @@ -103,16 +106,19 @@ div.timeline { .vis-item.vis-cluster { border-style: dotted; - color: var(--primary-text-color); - background-color: var(--primary-background-color); - box-shadow: var(--frigate-card-css-box-shadow, 0px 0px 5px 1px var(--primary-color)); + color: var(--frigate-card-timeline-text-color); + background-color: var(--frigate-card-timeline-background); + box-shadow: var( + --frigate-card-css-box-shadow, + 0px 0px 5px 1px var(--frigate-card-timeline-item-color) + ); } .vis-item.vis-range { border-radius: var(--frigate-card-css-border-radius, unset); } .vis-time-axis .vis-grid.vis-minor { - border-color: var(--secondary-color); + border-color: var(--frigate-card-timeline-divider-color); } // Give an indication that the user can interact with the axes. @@ -121,11 +127,15 @@ div.timeline { transition: background-color 0.5s ease-out; } :host([recordings]) .vis-text.vis-minor:hover { - background-color: var(--primary-color); + background-color: color-mix( + in oklab, + var(--frigate-card-timeline-text-color), + transparent 80% + ); } .vis-time-axis .vis-grid.vis-major { - border-color: var(--secondary-color); + border-color: var(--frigate-card-timeline-divider-color); } .vis-label { @@ -146,7 +156,7 @@ div.vis-tooltip { } .target_bar { - background-color: var(--primary-color); + background-color: var(--frigate-card-timeline-target-bar-color); width: 2px; z-index: 20; @@ -155,8 +165,8 @@ div.vis-tooltip { pointer-events: none; } .target_bar .vis-custom-time-marker { - background-color: var(--primary-background-color); - color: var(--primary-text-color); + background-color: var(--frigate-card-timeline-background); + color: var(--frigate-card-timeline-text-color); bottom: 0px; top: unset; } @@ -176,7 +186,7 @@ div.vis-tooltip { position: absolute; right: 0px; bottom: 0px; - color: var(--primary-color); + color: var(--frigate-card-timeline-tool-color); z-index: 10; } .timeline-tools * { diff --git a/src/types.ts b/src/types.ts index 8e1fd30f..5ded0c3b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,7 +5,6 @@ import { LovelaceCardEditor, Themes, } from '@dermotduffy/custom-card-helpers'; -import { StyleInfo } from 'lit/directives/style-map'; import { z } from 'zod'; export type ClipsOrSnapshots = 'clips' | 'snapshots'; @@ -146,9 +145,6 @@ export interface Icon { // If an icon is not otherwise resolved / available, this will be used instead. fallback?: string; - - // Styling to apply to the icon. - style?: StyleInfo; } // ************************************************************************* diff --git a/tests/card-controller/config/config-manager.test.ts b/tests/card-controller/config/config-manager.test.ts index d527f060..5be228c1 100644 --- a/tests/card-controller/config/config-manager.test.ts +++ b/tests/card-controller/config/config-manager.test.ts @@ -98,7 +98,7 @@ describe('ConfigManager', () => { expect(api.getViewManager().reset).toBeCalled(); expect(api.getMessageManager().reset).toBeCalled(); expect(api.getAutomationsManager().addAutomations).toBeCalled(); - expect(api.getStyleManager().setPerformance).toBeCalled(); + expect(api.getStyleManager().updateFromConfig).toBeCalled(); expect(api.getCardElementManager().update).toBeCalled(); }); @@ -175,7 +175,7 @@ describe('ConfigManager', () => { manager.computeOverrideConfig(); expect(manager.getConfig()).toBeNull(); - expect(api.getStyleManager().setMinMaxHeight).not.toBeCalled(); + expect(api.getStyleManager().updateFromConfig).not.toBeCalled(); }); it('should ignore overrides with same config', () => { @@ -188,12 +188,12 @@ describe('ConfigManager', () => { vi.mocked(getOverriddenConfig).mockReturnValue(config); manager.setConfig(config); - expect(api.getStyleManager().setMinMaxHeight).toBeCalled(); + expect(api.getStyleManager().updateFromConfig).toBeCalled(); - vi.mocked(api.getStyleManager().setMinMaxHeight).mockClear(); + vi.mocked(api.getStyleManager().updateFromConfig).mockClear(); manager.computeOverrideConfig(); - expect(api.getStyleManager().setMinMaxHeight).not.toBeCalled(); + expect(api.getStyleManager().updateFromConfig).not.toBeCalled(); }); it('should override', () => { @@ -204,7 +204,7 @@ describe('ConfigManager', () => { cameras: [{ camera_entity: 'camera.office' }], }; manager.setConfig(config_1); - vi.mocked(api.getStyleManager().setMinMaxHeight).mockClear(); + vi.mocked(api.getStyleManager().updateFromConfig).mockClear(); const config_2 = { type: 'custom:frigate-card', @@ -213,7 +213,7 @@ describe('ConfigManager', () => { vi.mocked(getOverriddenConfig).mockReturnValue(config_2); manager.computeOverrideConfig(); - expect(api.getStyleManager().setMinMaxHeight).toBeCalled(); + expect(api.getStyleManager().updateFromConfig).toBeCalled(); expect(api.getCardElementManager().update).toBeCalled(); expect(manager.getConfig()).not.toEqual(manager.getNonOverriddenConfig()); }); diff --git a/tests/card-controller/hass/hass-manager.test.ts b/tests/card-controller/hass/hass-manager.test.ts index 181a2799..acdc63bb 100644 --- a/tests/card-controller/hass/hass-manager.test.ts +++ b/tests/card-controller/hass/hass-manager.test.ts @@ -38,13 +38,13 @@ describe('HASSManager', () => { expect(manager.hasHASS()).toBeTruthy(); }); - it('should set light or dark mode upon setting hass', () => { + it('should update theme upon setting hass', () => { const api = createCardAPI(); const manager = new HASSManager(api); manager.setHASS(createHASS()); - expect(api.getStyleManager().setLightOrDarkMode).toBeCalled(); + expect(api.getStyleManager().applyTheme).toBeCalled(); }); describe('should set condition manager state', () => { diff --git a/tests/card-controller/interaction-manager.test.ts b/tests/card-controller/interaction-manager.test.ts index d0426f0d..ec3ec81e 100644 --- a/tests/card-controller/interaction-manager.test.ts +++ b/tests/card-controller/interaction-manager.test.ts @@ -1,7 +1,7 @@ import { add } from 'date-fns'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { InteractionManager } from '../../src/card-controller/interaction-manager'; -import { createCardAPI, createConfig } from '../test-utils'; +import { createCardAPI, createConfig, createLitElement } from '../test-utils'; vi.mock('lodash-es/throttle', () => ({ default: vi.fn((fn) => fn), @@ -17,14 +17,20 @@ describe('InteractionManager', () => { it('should initialize', () => { const api = createCardAPI(); + const element = createLitElement(); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + const manager = new InteractionManager(api); manager.initialize(); expect(api.getConditionsManager().setState).toBeCalledWith({ interaction: false }); + expect(element.getAttribute('interaction')).toBeNull(); }); - it('should not take action without an interaction timeout', () => { + it('should still report interaction without an interaction timeout', () => { const api = createCardAPI(); + const element = createLitElement(); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ view: { @@ -36,11 +42,15 @@ describe('InteractionManager', () => { manager.reportInteraction(); - expect(manager.hasInteraction()).toBeFalsy(); + expect(element.getAttribute('interaction')).not.toBeNull(); + expect(manager.hasInteraction()).toBeTruthy(); }); it('should set condition state', () => { const api = createCardAPI(); + const element = createLitElement(); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ view: { @@ -55,12 +65,14 @@ describe('InteractionManager', () => { expect(api.getConditionsManager().setState).not.toBeCalled(); manager.reportInteraction(); + expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith( expect.objectContaining({ interaction: true, }), ); expect(manager.hasInteraction()).toBeTruthy(); + expect(element.getAttribute('interaction')).not.toBeNull(); vi.setSystemTime(add(start, { seconds: 10 })); vi.runOnlyPendingTimers(); @@ -71,5 +83,6 @@ describe('InteractionManager', () => { }), ); expect(manager.hasInteraction()).toBeFalsy(); + expect(element.getAttribute('interaction')).toBeNull(); }); }); diff --git a/tests/card-controller/style-manager.test.ts b/tests/card-controller/style-manager.test.ts index bcbfbcb7..c908a12c 100644 --- a/tests/card-controller/style-manager.test.ts +++ b/tests/card-controller/style-manager.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { StyleManager } from '../../src/card-controller/style-manager'; -import { FrigateCardView } from '../../src/config/types'; -import { createCardAPI, createConfig, createHASS, createView } from '../test-utils'; +import { FrigateCardView, ThemeName } from '../../src/config/types'; +import { createCardAPI, createConfig, createView } from '../test-utils'; // @vitest-environment jsdom describe('StyleManager', () => { @@ -9,97 +9,45 @@ describe('StyleManager', () => { vi.resetAllMocks(); }); - describe('setLightOrDarkMode', () => { - it('dark mode unspecified', () => { - const api = createCardAPI(); - const element = document.createElement('div'); - vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); - const manager = new StyleManager(api); - - manager.setLightOrDarkMode(); - - expect(element.getAttribute('dark')).toBeNull(); - }); - - it('dark mode explicitly off', () => { + describe('should set dimmable', () => { + it('should be dimmable when dim is true', () => { const api = createCardAPI(); const element = document.createElement('div'); vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ view: { - dark_mode: 'off', + dim: true, }, }), ); const manager = new StyleManager(api); - manager.setLightOrDarkMode(); + manager.updateFromConfig(); - expect(element.getAttribute('dark')).toBeNull(); + expect(element.getAttribute('dimmable')).not.toBeNull(); }); - it('dark mode explicitly set', () => { + it('should not be dimmable when dim is false', () => { const api = createCardAPI(); const element = document.createElement('div'); vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ view: { - dark_mode: 'on', + dim: false, }, }), ); const manager = new StyleManager(api); - manager.setLightOrDarkMode(); + manager.updateFromConfig(); - expect(element.getAttribute('dark')).not.toBeNull(); - }); - - it('dark mode auto without interaction', () => { - const api = createCardAPI(); - const element = document.createElement('div'); - vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - view: { - dark_mode: 'auto', - }, - }), - ); - vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false); - const manager = new StyleManager(api); - - manager.setLightOrDarkMode(); - - expect(element.getAttribute('dark')).not.toBeNull(); - }); - - it('dark mode auto with HA dark mode', () => { - const api = createCardAPI(); - const element = document.createElement('div'); - vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - view: { - dark_mode: 'auto', - }, - }), - ); - vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(true); - const hass = createHASS(); - hass.themes.darkMode = true; - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); - const manager = new StyleManager(api); - - manager.setLightOrDarkMode(); - - expect(element.getAttribute('dark')).not.toBeNull(); + expect(element.getAttribute('dimmable')).toBeNull(); }); }); - describe('setExpandedMode', () => { + describe('should set expanded mode', () => { it('with no view or known media', () => { const api = createCardAPI(); const element = document.createElement('div'); @@ -189,14 +137,14 @@ describe('StyleManager', () => { }); }); - describe('setMinMaxHeight', () => { + describe('should set min and max height', () => { it('without a config', () => { const api = createCardAPI(); const element = document.createElement('div'); vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); const manager = new StyleManager(api); - manager.setMinMaxHeight(); + manager.updateFromConfig(); expect(element.style.getPropertyValue('--frigate-card-max-height')).toBeFalsy(); expect(element.style.getPropertyValue('--frigate-card-expand-height')).toBeFalsy(); @@ -215,13 +163,13 @@ describe('StyleManager', () => { ); const manager = new StyleManager(api); - manager.setMinMaxHeight(); + manager.updateFromConfig(); expect(element.style.getPropertyValue('--frigate-card-height')).toBe('800px'); }); }); - describe('setPerformance', () => { + describe('should set performance', () => { it('no styles set', () => { const api = createCardAPI(); const element = document.createElement('div'); @@ -229,7 +177,7 @@ describe('StyleManager', () => { vi.mocked(api.getConfigManager().getCardWideConfig).mockReturnValue({}); const manager = new StyleManager(api); - manager.setPerformance(); + manager.updateFromConfig(); expect( element.style.getPropertyValue('--frigate-card-css-box-shadow'), @@ -255,7 +203,7 @@ describe('StyleManager', () => { ); const manager = new StyleManager(api); - manager.setPerformance(); + manager.updateFromConfig(); expect(element.style.getPropertyValue('--frigate-card-css-box-shadow')).toEqual( 'none', @@ -411,4 +359,111 @@ describe('StyleManager', () => { expect(manager.getAspectRatioStyle()).toEqual({ 'aspect-ratio': '4 / 3' }); }); }); + + describe('should apply themes', () => { + it('should not apply themes without a config', () => { + const api = createCardAPI(); + const element = document.createElement('div'); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + + const manager = new StyleManager(api); + + manager.applyTheme(); + + expect(element.getAttribute('themes')).toBeNull(); + }); + + describe('should apply named theme', () => { + it.each([ + ['light' as const], + ['dark' as const], + ['traditional' as const], + ['ha' as const], + ])('%s', (theme: ThemeName) => { + const api = createCardAPI(); + const element = document.createElement('div'); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + theme: { + themes: [theme], + }, + }, + }), + ); + + const manager = new StyleManager(api); + + manager.applyTheme(); + + expect(element.getAttribute('themes')).toBe(theme); + }); + }); + + it('should apply multiple themes', () => { + const api = createCardAPI(); + const element = document.createElement('div'); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + theme: { + themes: ['light', 'traditional'], + }, + }, + }), + ); + + const manager = new StyleManager(api); + + manager.applyTheme(); + + expect(element.getAttribute('themes')).toBe('light traditional'); + }); + + it('should treat empty themes list as default', () => { + const api = createCardAPI(); + const element = document.createElement('div'); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + theme: { + themes: [], + }, + }, + }), + ); + + const manager = new StyleManager(api); + + manager.applyTheme(); + + expect(element.getAttribute('themes')).toBe('traditional'); + }); + + it('should apply overrides', () => { + const api = createCardAPI(); + const element = document.createElement('div'); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + theme: { + overrides: { + '--test-key': 'test-value', + }, + }, + }, + }), + ); + + const manager = new StyleManager(api); + + manager.applyTheme(); + + expect(element.style.getPropertyValue('--test-key')).toBe('test-value'); + }); + }); }); diff --git a/tests/components-lib/icon-controller.test.ts b/tests/components-lib/icon-controller.test.ts index 62117fc5..44d0bf76 100644 --- a/tests/components-lib/icon-controller.test.ts +++ b/tests/components-lib/icon-controller.test.ts @@ -22,6 +22,10 @@ describe('IconController', () => { ); }); + it('should return iris SVG for iris icon', () => { + expect(new IconController().getCustomIcon({ icon: 'iris' })).toMatch(/iris.svg$/); + }); + it('should return null for mdi icon', () => { expect(new IconController().getCustomIcon({ icon: 'mdi:car' })).toBeNull(); }); diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts index ef3d6b99..0fba379d 100644 --- a/tests/components-lib/menu-button-controller.test.ts +++ b/tests/components-lib/menu-button-controller.test.ts @@ -288,7 +288,7 @@ describe('MenuButtonController', () => { expect(buttons).toContainEqual({ icon: 'mdi:video-input-component', - style: { color: 'var(--primary-color, white)' }, + style: { color: 'var(--frigate-card-menu-button-active-color)' }, title: 'Substream(s)', enabled: true, priority: 50, @@ -442,7 +442,7 @@ describe('MenuButtonController', () => { expect(buttons).toContainEqual({ icon: 'mdi:video-input-component', title: 'Substream(s)', - style: { color: 'var(--primary-color, white)' }, + style: { color: 'var(--frigate-card-menu-button-active-color)' }, enabled: true, priority: 50, type: 'custom:frigate-card-menu-submenu', @@ -508,7 +508,7 @@ describe('MenuButtonController', () => { priority: 50, type: 'custom:frigate-card-menu-icon', title: 'Live view', - style: { color: 'var(--primary-color, white)' }, + style: { color: 'var(--frigate-card-menu-button-active-color)' }, tap_action: { action: 'fire-dom-event', frigate_card_action: 'live' }, }); }); @@ -562,7 +562,7 @@ describe('MenuButtonController', () => { priority: 50, type: 'custom:frigate-card-menu-icon', title: 'Clips gallery', - style: { color: 'var(--primary-color, white)' }, + style: { color: 'var(--frigate-card-menu-button-active-color)' }, tap_action: { action: 'fire-dom-event', frigate_card_action: 'clips' }, hold_action: { action: 'fire-dom-event', frigate_card_action: 'clip' }, }); @@ -615,7 +615,7 @@ describe('MenuButtonController', () => { priority: 50, type: 'custom:frigate-card-menu-icon', title: 'Snapshots gallery', - style: { color: 'var(--primary-color, white)' }, + style: { color: 'var(--frigate-card-menu-button-active-color)' }, tap_action: { action: 'fire-dom-event', frigate_card_action: 'snapshots' }, hold_action: { action: 'fire-dom-event', frigate_card_action: 'snapshot' }, }); @@ -670,7 +670,7 @@ describe('MenuButtonController', () => { priority: 50, type: 'custom:frigate-card-menu-icon', title: 'Recordings gallery', - style: { color: 'var(--primary-color, white)' }, + style: { color: 'var(--frigate-card-menu-button-active-color)' }, tap_action: { action: 'fire-dom-event', frigate_card_action: 'recordings' }, hold_action: { action: 'fire-dom-event', frigate_card_action: 'recording' }, }); @@ -726,7 +726,7 @@ describe('MenuButtonController', () => { priority: 50, type: 'custom:frigate-card-menu-icon', title: 'Static image', - style: { color: 'var(--primary-color, white)' }, + style: { color: 'var(--frigate-card-menu-button-active-color)' }, tap_action: { action: 'fire-dom-event', frigate_card_action: 'image' }, }); }); @@ -781,7 +781,7 @@ describe('MenuButtonController', () => { priority: 50, type: 'custom:frigate-card-menu-icon', title: 'Timeline view', - style: { color: 'var(--primary-color, white)' }, + style: { color: 'var(--frigate-card-menu-button-active-color)' }, tap_action: { action: 'fire-dom-event', frigate_card_action: 'timeline' }, }); }); @@ -938,7 +938,7 @@ describe('MenuButtonController', () => { title: 'Microphone', style: { animation: 'pulse 3s infinite', - color: 'var(--error-color, white)', + color: 'var(--frigate-card-menu-button-critical-color)', }, start_tap_action: { action: 'fire-dom-event', @@ -1110,7 +1110,7 @@ describe('MenuButtonController', () => { title: 'Microphone', style: { animation: 'pulse 3s infinite', - color: 'var(--error-color, white)', + color: 'var(--frigate-card-menu-button-critical-color)', }, tap_action: { action: 'fire-dom-event', @@ -1148,7 +1148,7 @@ describe('MenuButtonController', () => { type: 'custom:frigate-card-menu-icon', title: 'Fullscreen', tap_action: { action: 'fire-dom-event', frigate_card_action: 'fullscreen' }, - style: { color: 'var(--primary-color, white)' }, + style: { color: 'var(--frigate-card-menu-button-active-color)' }, }); }); }); @@ -1178,7 +1178,7 @@ describe('MenuButtonController', () => { type: 'custom:frigate-card-menu-icon', title: 'Expand', tap_action: { action: 'fire-dom-event', frigate_card_action: 'expand' }, - style: { color: 'var(--primary-color, white)' }, + style: { color: 'var(--frigate-card-menu-button-active-color)' }, }); }); }); @@ -1413,7 +1413,10 @@ describe('MenuButtonController', () => { displayMode === 'grid' ? 'Show single media viewer' : 'Show media viewer for each camera in a grid', - style: displayMode === 'grid' ? { color: 'var(--primary-color, white)' } : {}, + style: + displayMode === 'grid' + ? { color: 'var(--frigate-card-menu-button-active-color)' } + : {}, tap_action: { action: 'fire-dom-event', frigate_card_action: 'display_mode_select', @@ -1481,7 +1484,7 @@ describe('MenuButtonController', () => { icon: 'mdi:pan', priority: 50, style: { - color: 'var(--primary-color, white)', + color: 'var(--frigate-card-menu-button-active-color)', }, tap_action: { action: 'fire-dom-event', @@ -1554,7 +1557,7 @@ describe('MenuButtonController', () => { icon: 'mdi:pan', priority: 50, style: { - color: 'var(--primary-color, white)', + color: 'var(--frigate-card-menu-button-active-color)', }, tap_action: { action: 'fire-dom-event', @@ -1701,7 +1704,7 @@ describe('MenuButtonController', () => { expect(calculateButtons(controller, { view: view })).toContainEqual({ ...button, - style: { color: 'var(--primary-color, white)' }, + style: { color: 'var(--frigate-card-menu-button-active-color)' }, }); }); @@ -1714,7 +1717,7 @@ describe('MenuButtonController', () => { expect(calculateButtons(controller)).toContainEqual({ ...button, - style: { color: 'var(--primary-color, white)' }, + style: { color: 'var(--frigate-card-menu-button-active-color)' }, }); }); @@ -1727,7 +1730,7 @@ describe('MenuButtonController', () => { expect(calculateButtons(controller, { inFullscreenMode: true })).toContainEqual({ ...button, - style: { color: 'var(--primary-color, white)' }, + style: { color: 'var(--frigate-card-menu-button-active-color)' }, }); }); @@ -1745,7 +1748,7 @@ describe('MenuButtonController', () => { expect(calculateButtons(controller, { view: view })).toContainEqual({ ...button, - style: { color: 'var(--primary-color, white)' }, + style: { color: 'var(--frigate-card-menu-button-active-color)' }, }); }); diff --git a/tests/components-lib/status-bar-controller.test.ts b/tests/components-lib/status-bar-controller.test.ts index 9b33f85b..c547219d 100644 --- a/tests/components-lib/status-bar-controller.test.ts +++ b/tests/components-lib/status-bar-controller.test.ts @@ -14,14 +14,14 @@ describe('StatusBarController', () => { it('should set config', () => { const host = createLitElement(); const controller = new StatusBarController(host); + const config = createConfig({ + position: 'top', + style: 'hover', + height: 50, + }); + controller.setConfig(config); - controller.setConfig( - createConfig({ - position: 'top', - style: 'hover', - height: 50, - }), - ); + expect(controller.getConfig()).toEqual(config); expect(host.style.getPropertyValue('--frigate-card-status-bar-height')).toBe( '50px', ); diff --git a/tests/config/management.test.ts b/tests/config/management.test.ts index 1c4bd532..91976993 100644 --- a/tests/config/management.test.ts +++ b/tests/config/management.test.ts @@ -3406,4 +3406,31 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); }); + + describe('v6.1.2+', () => { + describe('view.dark_mode -> view.dim', () => { + it.each([ + ['on' as const, true], + ['auto' as const, false], + ['off' as const, false], + ])('%s', (darkMode: 'on' | 'off' | 'auto', expected: boolean) => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + view: { + dark_mode: darkMode, + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + view: { + dim: expected, + }, + }); + postUpgradeChecks(config); + }); + }); + }); }); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 010e876c..3beb634c 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -285,7 +285,7 @@ describe('config defaults', () => { }, }, status_bar: { - height: 30, + height: 40, items: { engine: { enabled: true, @@ -329,7 +329,6 @@ describe('config defaults', () => { type: 'frigate-hass-card', view: { camera_select: 'current', - dark_mode: 'off', default: 'live', keyboard_shortcuts: { enabled: true, @@ -355,6 +354,9 @@ describe('config defaults', () => { key: '-', }, }, + theme: { + themes: ['traditional'], + }, triggers: { show_trigger_status: false, untrigger_seconds: 0, @@ -366,6 +368,7 @@ describe('config defaults', () => { filter_selected_camera: true, }, interaction_seconds: 300, + dim: false, default_cycle_camera: false, default_reset: { after_interaction: false, diff --git a/yarn.lock b/yarn.lock index 7e0587c9..275e709f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3693,6 +3693,19 @@ __metadata: languageName: node linkType: hard +"css-select@npm:^4.1.3": + version: 4.3.0 + resolution: "css-select@npm:4.3.0" + dependencies: + boolbase: "npm:^1.0.0" + css-what: "npm:^6.0.1" + domhandler: "npm:^4.3.1" + domutils: "npm:^2.8.0" + nth-check: "npm:^2.0.1" + checksum: 10c0/a489d8e5628e61063d5a8fe0fa1cc7ae2478cb334a388a354e91cf2908154be97eac9fa7ed4dffe87a3e06cf6fcaa6016553115335c4fd3377e13dac7bd5a8e1 + languageName: node + linkType: hard + "css-select@npm:^5.1.0": version: 5.1.0 resolution: "css-select@npm:5.1.0" @@ -3706,6 +3719,16 @@ __metadata: languageName: node linkType: hard +"css-tree@npm:^1.1.2, css-tree@npm:^1.1.3": + version: 1.1.3 + resolution: "css-tree@npm:1.1.3" + dependencies: + mdn-data: "npm:2.0.14" + source-map: "npm:^0.6.1" + checksum: 10c0/499a507bfa39b8b2128f49736882c0dd636b0cd3370f2c69f4558ec86d269113286b7df469afc955de6a68b0dba00bc533e40022a73698081d600072d5d83c1c + languageName: node + linkType: hard + "css-tree@npm:^2.3.1": version: 2.3.1 resolution: "css-tree@npm:2.3.1" @@ -3726,7 +3749,7 @@ __metadata: languageName: node linkType: hard -"css-what@npm:^6.1.0": +"css-what@npm:^6.0.1, css-what@npm:^6.1.0": version: 6.1.0 resolution: "css-what@npm:6.1.0" checksum: 10c0/a09f5a6b14ba8dcf57ae9a59474722e80f20406c53a61e9aedb0eedc693b135113ffe2983f4efc4b5065ae639442e9ae88df24941ef159c218b231011d733746 @@ -3810,6 +3833,15 @@ __metadata: languageName: node linkType: hard +"csso@npm:^4.2.0": + version: 4.2.0 + resolution: "csso@npm:4.2.0" + dependencies: + css-tree: "npm:^1.1.2" + checksum: 10c0/f8c6b1300efaa0f8855a7905ae3794a29c6496e7f16a71dec31eb6ca7cfb1f058a4b03fd39b66c4deac6cb06bf6b4ba86da7b67d7320389cb9994d52b924b903 + languageName: node + linkType: hard + "csso@npm:^5.0.5": version: 5.0.5 resolution: "csso@npm:5.0.5" @@ -4134,6 +4166,17 @@ __metadata: languageName: node linkType: hard +"dom-serializer@npm:^1.0.1": + version: 1.4.1 + resolution: "dom-serializer@npm:1.4.1" + dependencies: + domelementtype: "npm:^2.0.1" + domhandler: "npm:^4.2.0" + entities: "npm:^2.0.0" + checksum: 10c0/67d775fa1ea3de52035c98168ddcd59418356943b5eccb80e3c8b3da53adb8e37edb2cc2f885802b7b1765bf5022aec21dfc32910d7f9e6de4c3148f095ab5e0 + languageName: node + linkType: hard + "dom-serializer@npm:^2.0.0": version: 2.0.0 resolution: "dom-serializer@npm:2.0.0" @@ -4145,7 +4188,7 @@ __metadata: languageName: node linkType: hard -"domelementtype@npm:^2.3.0": +"domelementtype@npm:^2.0.1, domelementtype@npm:^2.2.0, domelementtype@npm:^2.3.0": version: 2.3.0 resolution: "domelementtype@npm:2.3.0" checksum: 10c0/686f5a9ef0fff078c1412c05db73a0dce096190036f33e400a07e2a4518e9f56b1e324f5c576a0a747ef0e75b5d985c040b0d51945ce780c0dd3c625a18cd8c9 @@ -4161,6 +4204,15 @@ __metadata: languageName: node linkType: hard +"domhandler@npm:^4.2.0, domhandler@npm:^4.3.1": + version: 4.3.1 + resolution: "domhandler@npm:4.3.1" + dependencies: + domelementtype: "npm:^2.2.0" + checksum: 10c0/5c199c7468cb052a8b5ab80b13528f0db3d794c64fc050ba793b574e158e67c93f8336e87fd81e9d5ee43b0e04aea4d8b93ed7be4899cb726a1601b3ba18538b + languageName: node + linkType: hard + "domhandler@npm:^5.0.2, domhandler@npm:^5.0.3": version: 5.0.3 resolution: "domhandler@npm:5.0.3" @@ -4170,6 +4222,17 @@ __metadata: languageName: node linkType: hard +"domutils@npm:^2.8.0": + version: 2.8.0 + resolution: "domutils@npm:2.8.0" + dependencies: + dom-serializer: "npm:^1.0.1" + domelementtype: "npm:^2.2.0" + domhandler: "npm:^4.2.0" + checksum: 10c0/d58e2ae01922f0dd55894e61d18119924d88091837887bf1438f2327f32c65eb76426bd9384f81e7d6dcfb048e0f83c19b222ad7101176ad68cdc9c695b563db + languageName: node + linkType: hard + "domutils@npm:^3.0.1": version: 3.1.0 resolution: "domutils@npm:3.1.0" @@ -4315,6 +4378,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^2.0.0": + version: 2.2.0 + resolution: "entities@npm:2.2.0" + checksum: 10c0/7fba6af1f116300d2ba1c5673fc218af1961b20908638391b4e1e6d5850314ee2ac3ec22d741b3a8060479911c99305164aed19b6254bde75e7e6b1b2c3f3aa3 + languageName: node + linkType: hard + "entities@npm:^4.2.0, entities@npm:^4.4.0": version: 4.5.0 resolution: "entities@npm:4.5.0" @@ -5182,6 +5252,7 @@ __metadata: rollup-plugin-git-info: "npm:^1.0.0" rollup-plugin-serve: "npm:^1.1.1" rollup-plugin-styler: "npm:^1.8.0" + rollup-plugin-svgo: "npm:^2.0.0" rollup-plugin-visualizer: "npm:^5.12.0" sass: "patch:sass@npm%3A1.77.4#~/.yarn/patches/sass-npm-1.77.4-13b6910aea.patch" screenfull: "npm:^6.0.2" @@ -7432,6 +7503,13 @@ __metadata: languageName: node linkType: hard +"mdn-data@npm:2.0.14": + version: 2.0.14 + resolution: "mdn-data@npm:2.0.14" + checksum: 10c0/67241f8708c1e665a061d2b042d2d243366e93e5bf1f917693007f6d55111588b952dcbfd3ea9c2d0969fb754aad81b30fdcfdcc24546495fc3b24336b28d4bd + languageName: node + linkType: hard + "mdn-data@npm:2.0.28": version: 2.0.28 resolution: "mdn-data@npm:2.0.28" @@ -9826,6 +9904,15 @@ __metadata: languageName: node linkType: hard +"rollup-plugin-svgo@npm:^2.0.0": + version: 2.0.0 + resolution: "rollup-plugin-svgo@npm:2.0.0" + dependencies: + svgo: "npm:2.8.0" + checksum: 10c0/3dead444692eca0c99da8ed668e9a835ceaf6a1e0c6eaf060ae0b113f3f227127d2efb2ba657f910bc51c5733b92a2181bdc393dbf6b905da19c2e37997a8fa3 + languageName: node + linkType: hard + "rollup-plugin-visualizer@npm:^5.12.0": version: 5.12.0 resolution: "rollup-plugin-visualizer@npm:5.12.0" @@ -10511,6 +10598,13 @@ __metadata: languageName: node linkType: hard +"stable@npm:^0.1.8": + version: 0.1.8 + resolution: "stable@npm:0.1.8" + checksum: 10c0/df74b5883075076e78f8e365e4068ecd977af6c09da510cfc3148a303d4b87bc9aa8f7c48feb67ed4ef970b6140bd9eabba2129e28024aa88df5ea0114cba39d + languageName: node + linkType: hard + "stackback@npm:0.0.2": version: 0.0.2 resolution: "stackback@npm:0.0.2" @@ -10771,6 +10865,23 @@ __metadata: languageName: node linkType: hard +"svgo@npm:2.8.0": + version: 2.8.0 + resolution: "svgo@npm:2.8.0" + dependencies: + "@trysound/sax": "npm:0.2.0" + commander: "npm:^7.2.0" + css-select: "npm:^4.1.3" + css-tree: "npm:^1.1.3" + csso: "npm:^4.2.0" + picocolors: "npm:^1.0.0" + stable: "npm:^0.1.8" + bin: + svgo: bin/svgo + checksum: 10c0/0741f5d5cad63111a90a0ce7a1a5a9013f6d293e871b75efe39addb57f29a263e45294e485a4d2ff9cc260a5d142c8b5937b2234b4ef05efdd2706fb2d360ecc + languageName: node + linkType: hard + "svgo@npm:^3.2.0": version: 3.3.2 resolution: "svgo@npm:3.3.2"