Compare commits

..

21 Commits

Author SHA1 Message Date
Petar Petrov b279af6856 Apply suggestion from @MindFreeze 2026-07-28 10:50:45 +03:00
Petar Petrov 95be41d56b Update demo/src/html/index.html.template
Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-07-28 10:44:53 +03:00
Petar Petrov 4331943266 Respect prefers-reduced-motion on the launch screen
The pulsing dots in the loading logomark keep animating under a reduced
motion preference. The logomark is loaded via <img>, so page CSS cannot
reach it; the guard has to live inside the SVG itself.

The launch screen's own fades reference --ha-animation-duration-normal,
but core.globals.ts only applies once the app bundle has loaded, so the
250ms literal fallback was always what took effect. Define the token in
the launch screen styles so the fades, the view transition and the
removal timeout all collapse under a reduced motion preference.
2026-07-28 09:29:36 +03:00
Paul Bottein 972ade22bf Add favorite cards to the card picker (#53338)
* Add favorite cards to the card picker

* Rename stored key
2026-07-28 08:43:00 +03:00
Aidan Timson 0e2a5e414b Implement new loading animation (#53328)
* Implement new svg animation

* Fix and speed up fade out transition

* Cleanup

* Remove unintentional scale in

* Match 250ms fade in and out

* Revert

* Fix small layout jump

* Pass localise as context isnt avaliable to a sibling (not a child of ha)

* Update ohf loading logomark

* Fix alt

* Update logomark again

* Reduce gap

* Font changes

* Load font for launch screen

* Smooth launch screen text loading

* Restore launch screen migration line breaks
2026-07-28 08:38:04 +03:00
karwosts 428a21175e Remove special case of grid[0] in energy (#53345) 2026-07-28 08:03:20 +03:00
Stefan Agner 1bcf6aa882 Fix network error briefly shown on landing page when Core comes up (#53339)
When Home Assistant Core comes up and the landing page backend is torn
down, the landing page sometimes briefly showed "Cannot get network
information" before reloading into onboarding. Two issues caused this:

- location.reload() does not stop script execution, so the error state
  was still set after the reload was triggered. Return early from both
  pollers when a reload is in progress.
- During the teardown gap where neither the Supervisor API nor Core
  responds yet, the jobs poller set _coreStatusChecked without arming
  the 60 second grace period, so the network info poller showed the
  error immediately instead of waiting. Arm the grace period in
  _checkCoreAvailability so both pollers share it consistently.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 06:48:15 +02:00
renovate[bot] b7a1f1d0db Update dependency tar to v7.5.22 (#53340)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-28 06:45:32 +02:00
renovate[bot] 2bbd9d37e4 Update Playwright (#53344)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-28 06:45:15 +02:00
renovate[bot] f9bddc1228 Update dependency eslint to v10.8.0 (#53343)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-28 06:44:58 +02:00
Aidan Timson 669eab4d31 Hide superseded E2E failure comments (#53331) 2026-07-27 17:21:14 +03:00
Timothy ea179bd7fe Limit and avoid duplication when loaded is sent to mobile (#53335) 2026-07-27 17:08:12 +03:00
karwosts ef50cc0332 Abbreviate sections in automation add search dialog (#53286)
* Abbreviate sections in automation add search dialog

* fixes

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-27 13:59:43 +00:00
Aidan Timson ab0ce2a3b7 Keep Playwright Renovate updates together (#53336) 2026-07-27 13:54:53 +00:00
Petar Petrov 23bc2cf1c1 Share the sankey effective-parent walk across all four cards (#53329)
* Share the sankey effective-parent walk across all four cards

Each of the four sankey cards built its own `findEffectiveParent` to walk
`included_in_stat` up to the nearest rendered ancestor. The two cumulative
cards were byte-identical; the two instantaneous ones differed only in
comments and brace style. The pairs differed from each other only in which
id space they resolve to, which is exactly what `getId` already expresses.

Move the walk into `buildSankeyDeviceNodes`, which already receives
`devices`, `getId`, `getValue` and `minThreshold`, and drop the
`findEffectiveParent` option. A `renderedId` helper maps a stat_consumption
through `getId` to a node id, so one implementation covers both spaces.

Also folds in three incidental improvements:

- `deviceByStat` replaces each card's `deviceMap` plus the `deviceParents`
  map added in #53324.
- The walk is a bounded `for` instead of an unbounded `while`, which would
  hang on a cyclic `included_in_stat`. Cycles are prevented in the config UI
  but not validated in core.
- `getValue` is now called once per device instead of twice. For the
  cumulative cards it is `calculateStatisticSumGrowth`, which is O(points).

No behaviour change. Adds coverage for the instantaneous id space, which
had no hierarchy tests, and for a nested small consumer inside a rendered
parent's cluster.

* Keep small sankey devices attributed under a rendered ancestor

The nested-small-consumer filter from #53324 walks `included_in_stat` and
drops any small consumer whose chain reaches another small consumer. The
walk did not stop at ancestors that are rendered as their own node, so a
small device sitting under a rendered parent was dropped whenever anything
further up the chain happened to be small.

Such a consumer links to its rendered parent and never touches untracked,
so it cannot be double-counted through a small ancestor beyond it - and
dropping it does not fix the separate overlap between the rendered node and
that ancestor. It just loses a real device from the graph.

Stop the walk at the first rendered ancestor. Needs `included_in_stat` data
where a parent statistic reads smaller than its child, so it takes a gap or
a late start on the parent to trigger.
2026-07-27 14:59:02 +02:00
Marcin Bauer 85e94841a9 Improve target picker group header (#53250)
* Improve target picker group header

- Show group item count only when the group is collapsed, styled as a
  secondary-color "(N)" next to the type label
- Thin the item-groups border to 1px via --ha-border-width-sm
- Remove the "Targets" pre-label before the target selector in the
  action, trigger and condition editors, moving its spacing/divider onto
  the selector itself

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Update src/components/target-picker/ha-target-picker-item-group.ts

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-07-27 14:37:34 +02:00
Paulus Schoutsen 1de7230d9e Fall back to English when nightly translations cannot be fetched (#53272)
* Fall back to English when nightly translations cannot be fetched

Local builds no longer fail when the nightly translations fetch errors
(offline or invalid GitHub credentials); they warn and continue with
English only, like the existing no-token path. CI still fails so
releases cannot silently ship without translations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3oj5uaCRRaoXRd5iEffhi

* Record fetched artifact only after successful extraction

A failure mid-fetch previously left a fresh artifact.json next to wiped
translations, so builds within the next 24 hours skipped the fetch and
silently built English only. Writing the artifact file last means a
failed fetch is retried by the next build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3oj5uaCRRaoXRd5iEffhi

* Update build-scripts/gulp/fetch-nightly-translations.js

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-07-27 11:32:26 +00:00
Josef Zweck 7620f21033 Add search functionality to media browser (#53258)
* Add search functionality to media browser

* prettier

* add debounced search, clear properly

* abort in flight search, remove dead code

* prettier

* filter

* prettier

* Hide media type filter on root media sources page

* Only show media type filter for searchable sources

* Apply media type filter only to backend search, not local browse

* Move media type filter inside the search bar

* Add client-side filters for media browser results

* Center search spinner in media browser

* Revert "Add client-side filters for media browser results"

This reverts commit 3e1fc71fbd.

* Revert "Move media type filter inside the search bar"

This reverts commit 19dcb92ad1.

* Revert "Apply media type filter only to backend search, not local browse"

This reverts commit 9b4f86878d.

* Revert "Only show media type filter for searchable sources"

This reverts commit 6dca0ce24e.

* Revert "Hide media type filter on root media sources page"

This reverts commit 62bbe34b71.

* Revert "prettier"

This reverts commit d64f90865c.

* Revert "filter"

This reverts commit 56dc5450b2.

* Reapply "filter"

This reverts commit a18edb79e4.

* Reapply "prettier"

This reverts commit 224b8823ea.

* Reapply "Hide media type filter on root media sources page"

This reverts commit 9e447e2d68.

* Reapply "Only show media type filter for searchable sources"

This reverts commit f29bd8e434.

* Reapply "Apply media type filter only to backend search, not local browse"

This reverts commit e5c021a814.

* Reapply "Move media type filter inside the search bar"

This reverts commit c83d7b77a6.

* Reapply "Add client-side filters for media browser results"

This reverts commit 436e32eeea.

* Remove local filters panel from media browser

Removes the 'Filters' collapsible panel that included:
- Local text search filter
- Local media class filter chips
- Keeps media type dropdown filter in search bar

* Fix eslint: use const instead of let for notShown variable

* Fix prettier formatting

* remove debounced search

* remove char limit

* Search button, localize

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-27 09:42:46 +00:00
Maarten Lakerveld 425a914d34 Sankey Energy: dont double count smallconsumer ancestor/children in other (#53324)
dont double count smallconsumer ancestor/children in other

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-27 09:42:32 +00:00
Copilot f413e71d54 Fix Prettier formatting in dialog-energy-battery-settings.ts (#53325)
* Initial plan

* Fix Prettier formatting in dialog-energy-battery-settings.ts

Co-authored-by: timmo001 <28114703+timmo001@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: timmo001 <28114703+timmo001@users.noreply.github.com>
2026-07-27 12:34:03 +03:00
Paulus Schoutsen 28045f6cd6 Hide the devices tile on home overview when the other devices view is empty (#53275)
* Show entities without a device in the home other devices view

The home overview counts area-less entities to decide whether to show
the devices tile, but the other devices view dropped any entity without
a device registry entry. Area-less entities that also have no device
(e.g. template or helper entities) thus produced a devices tile leading
to an empty view.

Group them under an "Others" section instead, matching how the home
area view handles entities without a device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZ6ydA22VeQndgynH4SxCV

* Hide the devices tile instead of showing entities without a device

Reverse the fix direction: entities without a device registry entry
should not be rendered by the other devices view. Instead, the home
overview should not show the devices tile when that view has nothing to
render.

Extract the filtering and device grouping into a shared
getOtherDevicesEntities helper used by both the other devices view and
the overview, so the devices tile is only shown when the view actually
has content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZ6ydA22VeQndgynH4SxCV

* Stop at the first matching entity when checking for other devices

The overview only needs to know whether the other devices view has any
content, so short-circuit on the first matching entity instead of
computing the full device grouping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZ6ydA22VeQndgynH4SxCV

* Inline the devices check in the overview strategy

Drop the extracted helper and keep the other devices view strategy
untouched. The overview inlines the check for area-less primary
entities that belong to a device, keeping the diff to the tile
condition only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZ6ydA22VeQndgynH4SxCV

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 11:11:46 +03:00
43 changed files with 2178 additions and 823 deletions
+18 -8
View File
@@ -1,26 +1,36 @@
[modern]
# Modern builds target recent browsers supporting the latest features to minimize transpilation, polyfills, etc.
# It is served to browsers meeting the following requirements:
# - released in the last 3 years + current alpha/beta versions
# - released in the last year + current alpha/beta versions
# - Firefox extended support release (ESR)
# - with global utilization at or above 0.5%
# - exclude dead browsers (no security maintenance for 2+ years)
# - exclude KaiOS, QQ, and UC browsers due to lack of sufficient feature support data
unreleased versions
last 2 years
last 1 year
Firefox ESR
>= 0.5%
not dead
not KaiOS > 0
not QQAndroid > 0
not UCAndroid > 0
[legacy]
# Legacy builds are served when modern requirements are not met and support browsers:
# - released in the last 7 years with global utilization at or above 0.001%
# - released in the last 7 years + current alpha/beta versionss
# - with global utilization at or above 0.05%
# - exclude dead browsers (no security maintenance for 2+ years)
# - exclude Opera Mini which does not support web sockets
last 7 years and >= 0.001%
unreleased versions
last 7 years
>= 0.05%
not dead
not op_mini all
[legacy-sw]
# Same as legacy, restricted to browsers that support service workers
# (currently resolves to the same set; guards the service worker build if the legacy floor ever drops below them)
last 7 years and >= 0.001% and supports serviceworkers
not dead
# Same as legacy plus supports service workers
unreleased versions
last 7 years
>= 0.05% and supports serviceworkers
not dead
not op_mini all
+4 -4
View File
@@ -38,7 +38,7 @@ jobs:
name: Prepare container dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
image: mcr.microsoft.com/playwright:v1.62.0-noble
options: --user 1001
defaults:
run:
@@ -154,7 +154,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
image: mcr.microsoft.com/playwright:v1.62.0-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -206,7 +206,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
image: mcr.microsoft.com/playwright:v1.62.0-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -260,7 +260,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
image: mcr.microsoft.com/playwright:v1.62.0-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -52,6 +52,23 @@ gulp.task("fetch-nightly-translations", async function () {
currentArtifact = null;
}
try {
await fetchTranslations(currentArtifact);
} catch (err) {
// Local builds should work offline or without valid GitHub credentials,
// so fall back to English only. CI must fail instead of silently
// building without translations.
if (process.env.CI) {
throw err;
}
console.warn(
"Failed to fetch nightly translations, continuing with English only:",
err?.message || err
);
}
});
async function fetchTranslations(currentArtifact) {
// To store file writing promises
const createExtractDir = mkdir(EXTRACT_DIR, { recursive: true });
const writings = [];
@@ -130,11 +147,6 @@ gulp.task("fetch-nightly-translations", async function () {
if (!latestArtifact) {
throw Error("Latest nightly workflow run has no translations artifact");
}
writings.push(
createExtractDir.then(
writeFile(ARTIFACT_FILE, JSON.stringify(latestArtifact, null, 2))
)
);
// Remove the current translations
const deleteCurrent = Promise.all(writings).then(
@@ -160,7 +172,12 @@ gulp.task("fetch-nightly-translations", async function () {
await new Promise((resolve, reject) => {
extractStream.on("close", resolve).on("error", reject);
});
});
// Record the artifact only after successful extraction, so a failed fetch
// is retried by the next build instead of being considered current.
await createExtractDir;
await writeFile(ARTIFACT_FILE, JSON.stringify(latestArtifact, null, 2));
}
gulp.task(
"setup-and-fetch-nightly-translations",
+70 -9
View File
@@ -34,11 +34,25 @@
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#03a9f4" />
<link rel="preload" href="/static/fonts/roboto/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
<%= renderTemplate("_social_meta.html.template") %>
<style>
@font-face {
font-family: "Roboto Launch Screen";
font-display: block;
src: url("/static/fonts/roboto/Roboto-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
--ha-animation-duration-normal: 250ms;
}
@media (prefers-reduced-motion: reduce) {
html {
--ha-animation-duration-normal: 1ms;
}
}
@media (prefers-color-scheme: dark) {
html {
@@ -56,15 +70,38 @@
padding: 0;
}
#ha-launch-screen {
font-family: "Roboto Launch Screen", sans-serif;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
}
#ha-launch-screen svg {
width: 112px;
#ha-launch-screen.removing {
opacity: 0;
}
@keyframes launch-lockup-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
#ha-launch-screen .ha-lockup {
display: flex;
align-items: center;
flex-shrink: 0;
animation: launch-lockup-in var(--ha-animation-duration-normal, 250ms) ease-out both;
}
#ha-launch-screen-info-box > *,
#ha-launch-screen .ohf-logo {
animation: launch-lockup-in var(--ha-animation-duration-normal, 250ms) ease-out both;
}
#ha-launch-screen .ha-logo {
width: 96px;
height: 96px;
}
#ha-launch-screen .ha-launch-screen-spacer-top {
flex: 1;
@@ -80,11 +117,32 @@
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
opacity: .66;
}
@media (prefers-color-scheme: dark) {
.ohf-logo span {
font-size: 12px;
line-height: 12px;
text-transform: uppercase;
}
.ohf-logo picture {
display: flex;
}
.ohf-logo img {
width: 237px;
aspect-ratio: 237 / 24;
height: auto;
}
@media (max-height: 560px) {
#ha-launch-screen .ha-launch-screen-spacer-top {
margin-top: 24px;
padding-top: 24px;
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
padding-top: 16px;
}
.ohf-logo {
filter: invert(1);
margin-block: 24px;
}
}
</style>
@@ -92,13 +150,16 @@
<body>
<div id="ha-launch-screen">
<div class="ha-launch-screen-spacer-top"></div>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240">
<path fill="#18BCF2" d="M240 224.762a15 15 0 0 1-15 15H15a15 15 0 0 1-15-15v-90c0-8.25 4.77-19.769 10.61-25.609l98.78-98.7805c5.83-5.83 15.38-5.83 21.21 0l98.79 98.7895c5.83 5.83 10.61 17.36 10.61 25.61v90-.01Z"/>
<path fill="#F2F4F9" d="m107.27 239.762-40.63-40.63c-2.09.72-4.32 1.13-6.64 1.13-11.3 0-20.5-9.2-20.5-20.5s9.2-20.5 20.5-20.5 20.5 9.2 20.5 20.5c0 2.33-.41 4.56-1.13 6.65l31.63 31.63v-115.88c-6.8-3.3395-11.5-10.3195-11.5-18.3895 0-11.3 9.2-20.5 20.5-20.5s20.5 9.2 20.5 20.5c0 8.07-4.7 15.05-11.5 18.3895v81.27l31.46-31.46c-.62-1.96-.96-4.04-.96-6.2 0-11.3 9.2-20.5 20.5-20.5s20.5 9.2 20.5 20.5-9.2 20.5-20.5 20.5c-2.5 0-4.88-.47-7.09-1.29L129 208.892v30.88z"/>
</svg>
<div class="ha-lockup">
<img class="ha-logo" src="/static/images/home-assistant-logo-loading.svg" alt="Home Assistant">
</div>
<div id="ha-launch-screen-info-box" class="ha-launch-screen-spacer-bottom"></div>
<div class="ohf-logo">
<img src="/static/images/ohf-badge.svg" alt="Home Assistant is a project by the Open Home Foundation" height="46">
<span id="ha-launch-screen-attribution">A project from the</span>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="/static/images/open-home-foundation-on-dark.svg">
<img src="/static/images/open-home-foundation-on-light.svg" alt="Open Home Foundation" width="237" height="24">
</picture>
</div>
</div>
<ha-demo></ha-demo>
+18 -11
View File
@@ -182,12 +182,10 @@ class HaLandingPage extends LandingPageBaseElement {
this._networkInfoError = false;
this._coreStatusChecked = false;
} catch (err: any) {
if (!this._coreStatusChecked) {
// wait before show errors, because we assume that core is starting
this._coreCheckActive = true;
this._scheduleTurnOffCoreCheck();
if (await this._checkCoreAvailability()) {
// core is available, page reload in progress -> don't show an error
return;
}
await this._checkCoreAvailability();
// assume supervisor update if ping fails -> don't show an error
if (!this._coreCheckActive && err.message !== "ping-failed") {
@@ -217,7 +215,10 @@ class HaLandingPage extends LandingPageBaseElement {
this._progress = -1;
}
} catch (err: any) {
await this._checkCoreAvailability();
if (await this._checkCoreAvailability()) {
// core is available, page reload in progress -> stop polling
return;
}
if (!this._coreCheckActive) {
this._progress = -1;
@@ -229,16 +230,22 @@ class HaLandingPage extends LandingPageBaseElement {
this._scheduleFetchSupervisorJobsInfo();
}
private async _checkCoreAvailability() {
private async _checkCoreAvailability(): Promise<boolean> {
try {
const response = await fetch("/manifest.json");
if (response.ok) {
location.reload();
} else {
if (!response.ok) {
throw new Error("Failed to fetch manifest");
}
location.reload();
return true;
} catch (_err) {
this._coreStatusChecked = true;
if (!this._coreStatusChecked) {
// wait before showing errors, because we assume that core is starting
this._coreStatusChecked = true;
this._coreCheckActive = true;
this._scheduleTurnOffCoreCheck();
}
return false;
}
}
+3 -3
View File
@@ -150,7 +150,7 @@
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.61.1",
"@playwright/test": "1.62.0",
"@rsdoctor/rspack-plugin": "1.6.1",
"@rspack/core": "2.1.5",
"@rspack/dev-server": "2.1.0",
@@ -174,7 +174,7 @@
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.7.0",
"eslint": "10.8.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
@@ -207,7 +207,7 @@
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.1.0",
"tar": "7.5.21",
"tar": "7.5.22",
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
@@ -0,0 +1,39 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" role="presentation">
<style>
.dot-left { animation: pulse-left 1300ms 350ms linear infinite; }
.dot-right { animation: pulse-right 1300ms 350ms linear infinite; }
.dot-top { animation: pulse-top 1300ms 350ms linear infinite; }
@keyframes pulse-left {
0% { transform: translate(50px, 69.634804px) scale(1); animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
15.384615% { transform: translate(50px, 69.634804px) scale(1.2); animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
38.461538%, 100% { transform: translate(50px, 69.634804px) scale(1); }
}
@keyframes pulse-right {
0%, 15.384615% { transform: translate(90px, 58.634798px) scale(1); }
15.384615% { animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
30.769231% { transform: translate(90px, 58.634798px) scale(1.2); animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
53.846154%, 100% { transform: translate(90px, 58.634798px) scale(1); }
}
@keyframes pulse-top {
0%, 30.769231% { transform: translate(70px, 37.6348px) scale(1); }
30.769231% { animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
46.153846% { transform: translate(70px, 37.6348px) scale(1.2); animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
69.230769%, 100% { transform: translate(70px, 37.6348px) scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.dot-left, .dot-right, .dot-top { animation: none; }
}
</style>
<g transform="matrix(.938408 0 0 .93841 -5.688557 -13.572944)">
<path fill="#18bcf2" d="M73.5367 13.0937 106.463 46.0524v.0033C108.41 48.0043 110 51.848 110 54.6007v30.0292c0 2.7527-2.25 5.0049-5 5.0049l-70-.0034c-2.75 0-5-2.2522-5-5.0048V54.5974c0-2.7527 1.5933-6.5998 3.5367-8.545l32.93-32.9587c1.9433-1.9452 5.1266-1.9452 7.07 0Z" transform="matrix(1.598452 0 0 1.598452 -41.89164 -.937304)"/>
<g mask="url(#logo-mask)" transform="matrix(1.598452 0 0 1.598452 -41.89164 -.937311)">
<path d="m70 89.6348-20-20M70 89.6348v-52M90 58.1348l-20 20" fill="none" stroke="#f2f4f9" stroke-linecap="round" stroke-width="6"/>
<circle class="dot-left" r="7" fill="#f2f4f9" transform="translate(50 69.634804)"/>
<circle class="dot-right" r="7" fill="#f2f4f9" transform="translate(90 58.634798)"/>
<circle class="dot-top" r="7" fill="#f2f4f9" transform="translate(70 37.6348)"/>
</g>
<mask id="logo-mask" x="-150%" y="-150%" width="400%" height="400%" mask-type="luminance">
<path fill="#f2f4f9" d="M73.5367 13.0937 106.463 46.0524v.0033C108.41 48.0043 110 51.848 110 54.6007v30.0292c0 2.7527-2.25 5.0049-5 5.0049l-70-.0034c-2.75 0-5-2.2522-5-5.0048V54.5974c0-2.7527 1.5933-6.5998 3.5367-8.545l32.93-32.9587c1.9433-1.9452 5.1266-1.9452 7.07 0Z"/>
</mask>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -0,0 +1,22 @@
<svg width="237" height="24" viewBox="0 0 237 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.54181 0.297615C8.14709 -0.0992051 7.5012 -0.0992051 7.1082 0.297615L0.717655 6.72232C0.322945 7.11914 0 7.90248 0 8.46249V14.2894C0 14.8494 0.456224 15.3098 1.01497 15.3098H14.635C15.1921 15.3098 15.65 14.8511 15.65 14.2894V8.46249C15.65 7.90248 15.3271 7.11914 14.9324 6.72232L8.54181 0.297615Z" fill="#F7F6F2"/>
<path d="M14.8672 17.918C15.2993 17.918 15.6502 18.2682 15.6504 18.7002V22.417C15.6504 22.8492 15.2994 23.2002 14.8672 23.2002H13.8896C13.4575 23.2002 13.1064 22.8492 13.1064 22.417V20.4609H2.54297V22.417C2.54297 22.8491 2.19285 23.2001 1.76074 23.2002H0.782227C0.350189 23.2 0 22.8491 0 22.417V18.7002C0.000147852 18.2682 0.350281 17.9181 0.782227 17.918H14.8672Z" fill="#F7F6F2"/>
<path d="M28.0439 5.24023C28.9236 5.24023 29.7386 5.39857 30.4891 5.71734C31.2396 6.03402 31.8754 6.47362 32.3965 7.03406C32.9177 7.5945 33.3242 8.26536 33.616 9.05081C33.9058 9.83625 34.0517 10.6842 34.0517 11.5967C34.0621 12.4989 33.9204 13.3447 33.6285 14.1385C33.3346 14.9323 32.9239 15.6094 32.3965 16.1719C31.8691 16.7344 31.227 17.1761 30.4724 17.4949C29.7178 17.8157 28.9027 17.9699 28.0272 17.9595C26.8702 17.9762 25.83 17.7095 24.9086 17.1616C23.9872 16.6136 23.2764 15.8532 22.7761 14.8802C22.2757 13.9073 22.0339 12.8176 22.0506 11.6134C22.0402 10.7113 22.1819 9.86542 22.4738 9.07164C22.7677 8.27786 23.1763 7.60075 23.7016 7.03406C24.2269 6.46945 24.8648 6.02568 25.6174 5.70692C26.3699 5.38816 27.1787 5.2319 28.0439 5.24232V5.24023ZM28.0584 15.524C29.0966 15.524 29.9179 15.1719 30.5245 14.4698C31.1312 13.7656 31.4334 12.8093 31.4334 11.5967C31.4334 10.3842 31.1312 9.41332 30.5287 8.71121C29.9262 8.01118 29.1028 7.66117 28.0584 7.66117C27.0141 7.66117 26.1906 8.01118 25.5882 8.71121C24.9857 9.41124 24.6835 10.3738 24.6835 11.5967C24.6835 12.8197 24.9857 13.7802 25.5882 14.4781C26.1906 15.1761 27.0141 15.5261 28.0584 15.5261V15.524Z" fill="#F7F6F2"/>
<path d="M44.9854 9.52582C44.9854 10.7592 44.6123 11.7509 43.8681 12.503C43.1239 13.2551 42.1108 13.6322 40.8267 13.6322H38.3898V17.7553H35.9028V5.45899H40.86C42.1483 5.45899 43.1572 5.82151 43.8889 6.54654C44.6206 7.27157 44.9854 8.26536 44.9854 9.52374V9.52582ZM42.3588 9.47791C42.3588 8.95705 42.19 8.52578 41.8502 8.18619C41.5104 7.84659 41.0205 7.67783 40.3785 7.67783H38.3898V11.4676H40.3785C41.0247 11.4676 41.5167 11.2842 41.8544 10.9175C42.1921 10.5509 42.3588 10.0717 42.3588 9.47999V9.47791Z" fill="#F7F6F2"/>
<path d="M54.4829 17.7553H46.7386V5.45899H54.4829V7.82159H49.2234V10.4446H53.895V12.6447H49.2234V15.4156H54.4829V17.7553Z" fill="#F7F6F2"/>
<path d="M67.397 5.46108V17.7574H64.9268L59.5381 9.42999V17.7574H57.0678V5.46108H59.5381L64.9268 13.8052V5.46108H67.397Z" fill="#F7F6F2"/>
<path d="M74.4409 5.46108H76.9279V10.4321L81.8037 10.4488V5.46108H84.3074V17.7574H81.8037V12.6655L76.9279 12.6322V17.7574H74.4409V5.46108Z" fill="#F7F6F2"/>
<path d="M92.133 5.24023C93.0127 5.24023 93.8278 5.39857 94.5782 5.71734C95.3287 6.03402 95.9645 6.47362 96.4856 7.03406C97.0068 7.5945 97.4133 8.26536 97.7051 9.05081C97.9949 9.83625 98.1408 10.6842 98.1408 11.5967C98.1512 12.4989 98.0095 13.3447 97.7176 14.1385C97.4237 14.9323 97.013 15.6094 96.4856 16.1719C95.9582 16.7344 95.3162 17.1761 94.5615 17.4949C93.8069 17.8157 92.9918 17.9699 92.1163 17.9595C90.9593 17.9762 89.9191 17.7095 88.9977 17.1616C88.0763 16.6136 87.3655 15.8532 86.8652 14.8802C86.3649 13.9073 86.1231 12.8176 86.1397 11.6134C86.1293 10.7113 86.2711 9.86542 86.5629 9.07164C86.8568 8.27786 87.2654 7.60075 87.7907 7.03406C88.3161 6.46945 88.954 6.02568 89.7065 5.70692C90.459 5.38816 91.2679 5.2319 92.133 5.24232V5.24023ZM92.1476 15.524C93.1857 15.524 94.007 15.1719 94.6137 14.4698C95.2203 13.7656 95.5225 12.8093 95.5225 11.5967C95.5225 10.3842 95.2203 9.41332 94.6178 8.71121C94.0154 8.01118 93.192 7.66117 92.1476 7.66117C91.1032 7.66117 90.2798 8.01118 89.6773 8.71121C89.0749 9.41124 88.7726 10.3738 88.7726 11.5967C88.7726 12.8197 89.0749 13.7802 89.6773 14.4781C90.2798 15.1761 91.1032 15.5261 92.1476 15.5261V15.524Z" fill="#F7F6F2"/>
<path d="M112.408 5.46108V17.7574H109.954V11.0342L107.321 17.7574H104.966L102.364 11.0842V17.7574H99.9919V5.46108H102.364L106.148 14.8552L109.954 5.46108H112.408Z" fill="#F7F6F2"/>
<path d="M122.762 17.7553H115.018V5.45899H122.762V7.82159H117.503V10.4446H122.174V12.6447H117.503V15.4156H122.762V17.7553Z" fill="#F7F6F2"/>
<path d="M131.526 7.21579V10.9075H136.254V12.5936H131.526V17.7408H129.65V5.44079H137.146V7.21579H131.526Z" fill="#F7F6F2"/>
<path d="M144.303 5.24079C145.156 5.22968 145.956 5.38523 146.699 5.70468C147.441 6.02412 148.074 6.46579 148.597 7.02968C149.117 7.59357 149.525 8.27135 149.82 9.05746C150.114 9.84635 150.256 10.688 150.245 11.5852C150.256 12.488 150.114 13.338 149.82 14.1297C149.525 14.9241 149.117 15.5991 148.597 16.163C148.077 16.7241 147.443 17.1658 146.699 17.4852C145.954 17.8047 145.156 17.9602 144.303 17.9491C143.449 17.9602 142.652 17.8047 141.91 17.4852C141.168 17.1658 140.537 16.7241 140.017 16.1602C139.497 15.5963 139.089 14.9186 138.797 14.1325C138.505 13.3463 138.363 12.5019 138.374 11.6047C138.363 10.7075 138.505 9.86301 138.797 9.07412C139.089 8.28523 139.494 7.60746 140.017 7.04079C140.537 6.4769 141.168 6.03246 141.91 5.71023C142.652 5.38801 143.449 5.23246 144.303 5.24357V5.24079ZM141.44 14.8602C142.165 15.6825 143.124 16.0963 144.319 16.0963C145.515 16.0963 146.473 15.6852 147.196 14.8602C147.919 14.038 148.28 12.9463 148.28 11.5852C148.28 10.2241 147.919 9.12412 147.196 8.3019C146.473 7.47968 145.515 7.06579 144.319 7.06579C143.124 7.06579 142.163 7.47968 141.44 8.30468C140.715 9.12968 140.353 10.2241 140.353 11.5852C140.353 12.9463 140.715 14.038 141.44 14.8602Z" fill="#F7F6F2"/>
<path d="M154.097 5.43801V13.3019C154.103 14.238 154.378 14.9463 154.923 15.4241C155.467 15.9047 156.184 16.1436 157.071 16.1436C157.958 16.1436 158.636 15.8908 159.203 15.388C159.77 14.8852 160.053 14.188 160.053 13.3019V5.43801H161.929V13.3769C161.929 14.0991 161.799 14.7519 161.54 15.338C161.282 15.9241 160.929 16.4075 160.487 16.7825C160.042 17.1602 159.531 17.4491 158.944 17.6547C158.358 17.8602 157.735 17.9602 157.068 17.9602C156.401 17.9602 155.804 17.8602 155.226 17.663C154.647 17.4658 154.131 17.1797 153.68 16.8075C153.227 16.4352 152.871 15.9547 152.61 15.363C152.349 14.7713 152.218 14.1075 152.218 13.3769V5.43801H154.097Z" fill="#F7F6F2"/>
<path d="M174.473 5.43801V17.738H172.597L166.529 8.46857V17.738H164.653V5.43801H166.529L172.597 14.7241V5.43801H174.473Z" fill="#F7F6F2"/>
<path d="M187.583 11.6019C187.588 12.4825 187.436 13.3102 187.124 14.0797C186.813 14.8519 186.382 15.5075 185.835 16.0463C185.287 16.5852 184.628 17.0075 183.859 17.3102C183.089 17.613 182.269 17.7575 181.391 17.7408H177.291V5.44079H181.391C182.547 5.42412 183.603 5.67968 184.553 6.21023C185.504 6.73801 186.249 7.4769 186.788 8.42412C187.327 9.37135 187.591 10.4325 187.583 11.6047V11.6019ZM185.59 11.6019C185.59 10.3019 185.204 9.24635 184.428 8.43523C183.653 7.62412 182.636 7.21579 181.374 7.21579H179.184V15.9963H181.374C182.647 15.9963 183.667 15.5936 184.437 14.7852C185.207 13.9797 185.59 12.9158 185.59 11.6019Z" fill="#F7F6F2"/>
<path d="M195.821 14.7825H190.91L189.843 17.7408H187.916L192.363 5.44079H194.381L198.853 17.7408H196.869L195.818 14.7825H195.821ZM195.276 13.188L193.383 7.8269L191.474 13.188H195.276Z" fill="#F7F6F2"/>
<path d="M207.278 7.21579H203.706V17.7408H201.813V7.21579H198.242V5.44079H207.275V7.21579H207.278Z" fill="#F7F6F2"/>
<path d="M208.973 17.738V5.43801H210.866V17.738H208.973Z" fill="#F7F6F2"/>
<path d="M218.873 5.24079C219.726 5.22968 220.527 5.38523 221.269 5.70468C222.014 6.02412 222.645 6.46579 223.167 7.02968C223.687 7.59357 224.095 8.27135 224.39 9.05746C224.685 9.84635 224.826 10.688 224.815 11.5852C224.826 12.488 224.685 13.338 224.39 14.1297C224.095 14.9241 223.687 15.5991 223.167 16.163C222.647 16.7241 222.014 17.1658 221.269 17.4852C220.524 17.8047 219.726 17.9602 218.873 17.9491C218.02 17.9602 217.222 17.8047 216.48 17.4852C215.738 17.1658 215.107 16.7241 214.587 16.1602C214.067 15.5963 213.659 14.9186 213.367 14.1325C213.075 13.3463 212.933 12.5019 212.945 11.6047C212.933 10.7075 213.075 9.86301 213.367 9.07412C213.659 8.28523 214.065 7.60746 214.587 7.04079C215.107 6.4769 215.738 6.03246 216.48 5.71023C217.222 5.38801 218.02 5.23246 218.873 5.24357V5.24079ZM216.01 14.8602C216.736 15.6825 217.695 16.0963 218.89 16.0963C220.085 16.0963 221.044 15.6852 221.766 14.8602C222.489 14.038 222.85 12.9463 222.85 11.5852C222.85 10.2241 222.489 9.12412 221.766 8.3019C221.044 7.47968 220.085 7.06579 218.89 7.06579C217.695 7.06579 216.733 7.47968 216.01 8.30468C215.285 9.12968 214.924 10.2241 214.924 11.5852C214.924 12.9463 215.285 14.038 216.01 14.8602Z" fill="#F7F6F2"/>
<path d="M236.728 5.43801V17.738H234.852L228.784 8.46857V17.738H226.908V5.43801H228.784L234.852 14.7241V5.43801H236.728Z" fill="#F7F6F2"/>
</svg>

After

Width:  |  Height:  |  Size: 8.7 KiB

@@ -0,0 +1,22 @@
<svg width="237" height="24" viewBox="0 0 237 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.54181 0.297615C8.14709 -0.0992051 7.5012 -0.0992051 7.1082 0.297615L0.717655 6.72232C0.322945 7.11914 0 7.90248 0 8.46249V14.2894C0 14.8494 0.456224 15.3098 1.01497 15.3098H14.635C15.1921 15.3098 15.65 14.8511 15.65 14.2894V8.46249C15.65 7.90248 15.3271 7.11914 14.9324 6.72232L8.54181 0.297615Z" fill="#09202E"/>
<path d="M14.8672 17.9182C15.2993 17.9182 15.6502 18.2684 15.6504 18.7004V22.4172C15.6504 22.8494 15.2994 23.2004 14.8672 23.2004H13.8896C13.4575 23.2004 13.1064 22.8494 13.1064 22.4172V20.4612H2.54297V22.4172C2.54297 22.8494 2.19285 23.2004 1.76074 23.2004H0.782227C0.350189 23.2003 0 22.8493 0 22.4172V18.7004C0.000147852 18.2685 0.350281 17.9184 0.782227 17.9182H14.8672Z" fill="#09202E"/>
<path d="M28.0439 5.23999C28.9236 5.23999 29.7386 5.39833 30.4891 5.71709C31.2396 6.03377 31.8754 6.47337 32.3965 7.03381C32.9177 7.59425 33.3242 8.26511 33.616 9.05056C33.9058 9.83601 34.0517 10.684 34.0517 11.5965C34.0621 12.4986 33.9204 13.3445 33.6285 14.1383C33.3346 14.932 32.9239 15.6092 32.3965 16.1717C31.8691 16.7342 31.227 17.1759 30.4724 17.4947C29.7178 17.8155 28.9027 17.9697 28.0272 17.9593C26.8702 17.9759 25.83 17.7092 24.9086 17.1613C23.9872 16.6134 23.2764 15.8529 22.7761 14.88C22.2757 13.907 22.0339 12.8174 22.0506 11.6132C22.0402 10.711 22.1819 9.86518 22.4738 9.0714C22.7677 8.27761 23.1763 7.6005 23.7016 7.03381C24.2269 6.46921 24.8648 6.02544 25.6174 5.70668C26.3699 5.38791 27.1787 5.23166 28.0439 5.24207V5.23999ZM28.0584 15.5237C29.0966 15.5237 29.9179 15.1716 30.5245 14.4695C31.1312 13.7653 31.4334 12.809 31.4334 11.5965C31.4334 10.3839 31.1312 9.41308 30.5287 8.71096C29.9263 8.01094 29.1028 7.66092 28.0584 7.66092C27.0141 7.66092 26.1906 8.01094 25.5882 8.71096C24.9857 9.41099 24.6835 10.3735 24.6835 11.5965C24.6835 12.8195 24.9857 13.7799 25.5882 14.4779C26.1906 15.1758 27.0141 15.5258 28.0584 15.5258V15.5237Z" fill="#09202E"/>
<path d="M44.9854 9.52558C44.9854 10.759 44.6123 11.7507 43.8681 12.5028C43.1239 13.2549 42.1108 13.632 40.8267 13.632H38.3898V17.7551H35.9028V5.45875H40.86C42.1483 5.45875 43.1572 5.82126 43.8889 6.54629C44.6206 7.27132 44.9854 8.26511 44.9854 9.5235V9.52558ZM42.3588 9.47766C42.3588 8.95681 42.19 8.52554 41.8502 8.18594C41.5104 7.84635 41.0205 7.67759 40.3785 7.67759H38.3898V11.4673H40.3785C41.0247 11.4673 41.5167 11.284 41.8544 10.9173C42.1921 10.5506 42.3588 10.0714 42.3588 9.47975V9.47766Z" fill="#09202E"/>
<path d="M54.4829 17.7551H46.7386V5.45875H54.4829V7.82134H49.2234V10.4444H53.895V12.6445H49.2234V15.4154H54.4829V17.7551Z" fill="#09202E"/>
<path d="M67.397 5.46083V17.7572H64.9268L59.5381 9.42974V17.7572H57.0678V5.46083H59.5381L64.9268 13.8049V5.46083H67.397Z" fill="#09202E"/>
<path d="M74.4409 5.46083H76.9279V10.4319L81.8037 10.4485V5.46083H84.3074V17.7572H81.8037V12.6653L76.9279 12.632V17.7572H74.4409V5.46083Z" fill="#09202E"/>
<path d="M92.133 5.23999C93.0127 5.23999 93.8278 5.39833 94.5782 5.71709C95.3287 6.03377 95.9645 6.47337 96.4856 7.03381C97.0068 7.59425 97.4133 8.26511 97.7051 9.05056C97.9949 9.83601 98.1408 10.684 98.1408 11.5965C98.1512 12.4986 98.0095 13.3445 97.7176 14.1383C97.4237 14.932 97.013 15.6092 96.4856 16.1717C95.9582 16.7342 95.3162 17.1759 94.5615 17.4947C93.8069 17.8155 92.9918 17.9697 92.1163 17.9593C90.9593 17.9759 89.9191 17.7092 88.9977 17.1613C88.0763 16.6134 87.3655 15.8529 86.8652 14.88C86.3649 13.907 86.1231 12.8174 86.1397 11.6132C86.1293 10.711 86.2711 9.86518 86.5629 9.0714C86.8568 8.27761 87.2654 7.6005 87.7907 7.03381C88.3161 6.46921 88.954 6.02544 89.7065 5.70668C90.459 5.38791 91.2679 5.23166 92.133 5.24207V5.23999ZM92.1476 15.5237C93.1857 15.5237 94.007 15.1716 94.6137 14.4695C95.2203 13.7653 95.5225 12.809 95.5225 11.5965C95.5225 10.3839 95.2203 9.41308 94.6178 8.71096C94.0154 8.01094 93.192 7.66092 92.1476 7.66092C91.1032 7.66092 90.2798 8.01094 89.6773 8.71096C89.0749 9.41099 88.7726 10.3735 88.7726 11.5965C88.7726 12.8195 89.0749 13.7799 89.6773 14.4779C90.2798 15.1758 91.1032 15.5258 92.1476 15.5258V15.5237Z" fill="#09202E"/>
<path d="M112.408 5.46083V17.7572H109.954V11.034L107.321 17.7572H104.966L102.364 11.084V17.7572H99.9919V5.46083H102.364L106.148 14.855L109.954 5.46083H112.408Z" fill="#09202E"/>
<path d="M122.762 17.7551H115.018V5.45875H122.762V7.82134H117.503V10.4444H122.174V12.6445H117.503V15.4154H122.762V17.7551Z" fill="#09202E"/>
<path d="M131.526 7.21555V10.9072H136.254V12.5933H131.526V17.7405H129.65V5.44054H137.146V7.21555H131.526Z" fill="#09202E"/>
<path d="M144.303 5.24055C145.156 5.22943 145.956 5.38499 146.699 5.70443C147.441 6.02388 148.074 6.46555 148.597 7.02943C149.117 7.59332 149.525 8.2711 149.82 9.05721C150.114 9.8461 150.256 10.6878 150.245 11.585C150.256 12.4878 150.114 13.3378 149.82 14.1294C149.525 14.9239 149.117 15.5989 148.597 16.1628C148.077 16.7239 147.443 17.1655 146.699 17.485C145.954 17.8044 145.156 17.96 144.303 17.9489C143.449 17.96 142.652 17.8044 141.91 17.485C141.168 17.1655 140.537 16.7239 140.017 16.16C139.497 15.5961 139.089 14.9183 138.797 14.1322C138.505 13.3461 138.363 12.5017 138.374 11.6044C138.363 10.7072 138.505 9.86277 138.797 9.07388C139.089 8.28499 139.494 7.60721 140.017 7.04055C140.537 6.47666 141.168 6.03221 141.91 5.70999C142.652 5.38777 143.449 5.23221 144.303 5.24332V5.24055ZM141.44 14.86C142.165 15.6822 143.124 16.0961 144.319 16.0961C145.515 16.0961 146.473 15.685 147.196 14.86C147.919 14.0378 148.28 12.9461 148.28 11.585C148.28 10.2239 147.919 9.12388 147.196 8.30166C146.473 7.47943 145.515 7.06555 144.319 7.06555C143.124 7.06555 142.163 7.47943 141.44 8.30443C140.715 9.12943 140.353 10.2239 140.353 11.585C140.353 12.9461 140.715 14.0378 141.44 14.86Z" fill="#09202E"/>
<path d="M154.097 5.43777V13.3017C154.103 14.2378 154.378 14.9461 154.923 15.4239C155.467 15.9044 156.184 16.1433 157.071 16.1433C157.958 16.1433 158.636 15.8905 159.203 15.3878C159.77 14.885 160.053 14.1878 160.053 13.3017V5.43777H161.929V13.3767C161.929 14.0989 161.799 14.7517 161.54 15.3378C161.282 15.9239 160.929 16.4072 160.487 16.7822C160.042 17.16 159.531 17.4489 158.944 17.6544C158.358 17.86 157.735 17.96 157.068 17.96C156.401 17.96 155.804 17.86 155.226 17.6628C154.647 17.4655 154.131 17.1794 153.68 16.8072C153.227 16.435 152.871 15.9544 152.61 15.3628C152.349 14.7711 152.218 14.1072 152.218 13.3767V5.43777H154.097Z" fill="#09202E"/>
<path d="M174.473 5.43777V17.7378H172.597L166.529 8.46832V17.7378H164.653V5.43777H166.529L172.597 14.7239V5.43777H174.473Z" fill="#09202E"/>
<path d="M187.583 11.6017C187.588 12.4822 187.436 13.31 187.124 14.0794C186.813 14.8517 186.382 15.5072 185.835 16.0461C185.287 16.585 184.628 17.0072 183.859 17.31C183.089 17.6128 182.269 17.7572 181.391 17.7405H177.291V5.44054H181.391C182.547 5.42388 183.603 5.67943 184.553 6.20999C185.504 6.73777 186.249 7.47666 186.788 8.42388C187.327 9.3711 187.591 10.4322 187.583 11.6044V11.6017ZM185.59 11.6017C185.59 10.3017 185.204 9.2461 184.428 8.43499C183.653 7.62388 182.636 7.21555 181.374 7.21555H179.184V15.9961H181.374C182.647 15.9961 183.667 15.5933 184.437 14.785C185.207 13.9794 185.59 12.9155 185.59 11.6017Z" fill="#09202E"/>
<path d="M195.821 14.7822H190.91L189.843 17.7405H187.916L192.363 5.44054H194.381L198.853 17.7405H196.869L195.818 14.7822H195.821ZM195.276 13.1878L193.383 7.82666L191.474 13.1878H195.276Z" fill="#09202E"/>
<path d="M207.278 7.21555H203.706V17.7405H201.813V7.21555H198.242V5.44054H207.275V7.21555H207.278Z" fill="#09202E"/>
<path d="M208.973 17.7378V5.43777H210.866V17.7378H208.973Z" fill="#09202E"/>
<path d="M218.873 5.24055C219.726 5.22943 220.527 5.38499 221.269 5.70443C222.014 6.02388 222.645 6.46555 223.167 7.02943C223.687 7.59332 224.095 8.2711 224.39 9.05721C224.685 9.8461 224.826 10.6878 224.815 11.585C224.826 12.4878 224.685 13.3378 224.39 14.1294C224.095 14.9239 223.687 15.5989 223.167 16.1628C222.647 16.7239 222.014 17.1655 221.269 17.485C220.524 17.8044 219.726 17.96 218.873 17.9489C218.02 17.96 217.222 17.8044 216.48 17.485C215.738 17.1655 215.107 16.7239 214.587 16.16C214.067 15.5961 213.659 14.9183 213.367 14.1322C213.075 13.3461 212.933 12.5017 212.945 11.6044C212.933 10.7072 213.075 9.86277 213.367 9.07388C213.659 8.28499 214.065 7.60721 214.587 7.04055C215.107 6.47666 215.738 6.03221 216.48 5.70999C217.222 5.38777 218.02 5.23221 218.873 5.24332V5.24055ZM216.01 14.86C216.736 15.6822 217.695 16.0961 218.89 16.0961C220.085 16.0961 221.044 15.685 221.766 14.86C222.489 14.0378 222.85 12.9461 222.85 11.585C222.85 10.2239 222.489 9.12388 221.766 8.30166C221.044 7.47943 220.085 7.06555 218.89 7.06555C217.695 7.06555 216.733 7.47943 216.01 8.30443C215.285 9.12943 214.924 10.2239 214.924 11.585C214.924 12.9461 215.285 14.0378 216.01 14.86Z" fill="#09202E"/>
<path d="M236.728 5.43777V17.7378H234.852L228.784 8.46832V17.7378H226.908V5.43777H228.784L234.852 14.7239V5.43777H236.728Z" fill="#09202E"/>
</svg>

After

Width:  |  Height:  |  Size: 8.7 KiB

+2 -1
View File
@@ -106,7 +106,8 @@
{
"description": "Group Playwright package and CI container updates",
"groupName": "Playwright",
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"]
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"],
"minimumGroupSize": 5
}
]
}
-113
View File
@@ -1,113 +0,0 @@
# Build size comparison: old vs new `.browserslistrc`
Both builds were produced on the `pass-browser-list` branch (which passes the browserslist
config through to rspack/SWC and Lightning CSS), so the **only difference between the two
builds is the `.browserslistrc` content**:
- **Old** = `.browserslistrc` as committed at HEAD (`aa02a1117`)
- **New** = the staged `.browserslistrc` on this branch
Both full production builds (`yarn build``gulp build-app`) took ~18 min.
## Resolved browser targets
| Env | Old | New |
| --------- | ------------ | ------------ |
| modern | 75 browsers | 124 browsers |
| legacy | 424 browsers | 181 browsers |
| legacy-sw | 422 browsers | 181 browsers |
Key minimum versions per env:
| Browser | modern old | modern new | legacy old | legacy new |
| ---------------- | ---------- | ---------- | ---------- | ---------- |
| Chrome | 109 | 128 | 39 | 79 |
| Edge | 139 | 128 | 79 | 92 |
| Firefox | 140 (ESR) | 129 | 69 | 78 |
| Safari | 26.0 | 17.6 | 13 | 13.1 |
| iOS Safari | 18.5 | 17.6 | 11.0 | 13.2 |
| Samsung Internet | 29 | 27 | 10.1 | 24 |
The new modern list (`last 2 years`) _lowers_ the Safari/Firefox floor but _raises_ the
Chrome floor (the old `>= 0.5%` rule kept Chrome 109 alive). The new legacy list
(`last 7 years and >= 0.001%`, intersection instead of union) raises the floor across the
board — that is where the size win comes from.
## Total JS output size
All `.js` files in the output directory; gzip is `gzip -9` recomputed per file, brotli is
the `.js.br` files the build itself emits (what is actually served).
### `frontend_latest` (modern build)
| Metric | Old | New | Δ |
| ------------- | --------- | --------- | ------------------ |
| Raw JS | 31.97 MiB | 31.89 MiB | 86.7 KiB (0.3%) |
| Gzip | 9.50 MiB | 9.46 MiB | 39.5 KiB (0.4%) |
| Brotli | 8.16 MiB | 8.13 MiB | 33.6 KiB (0.4%) |
| Sourcemaps | 28.18 MiB | 28.06 MiB | 126.8 KiB (0.4%) |
| JS file count | 1099 | 1096 | 3 |
### `frontend_es5` (legacy build)
| Metric | Old | New | Δ |
| ------------- | --------- | --------- | ---------------------- |
| Raw JS | 35.31 MiB | 31.74 MiB | **3.57 MiB (10.1%)** |
| Gzip | 9.62 MiB | 9.38 MiB | 249.0 KiB (2.5%) |
| Brotli | 8.13 MiB | 8.03 MiB | 100.0 KiB (1.2%) |
| Sourcemaps | 32.05 MiB | 28.16 MiB | 3.89 MiB (12.1%) |
| JS file count | 1151 | 1138 | 13 |
Total `hass_frontend/` on disk: 260.9 MiB → 252.7 MiB (8.2 MiB).
## Cold-load critical entrypoints (raw initial JS)
These are the budget-tracked entrypoints from `build-scripts/check-bundle-size.cjs`
(downloaded before anything interactive happens).
### Modern
| Entrypoint | Old | New | Δ |
| ------------ | --------- | --------- | ---------------- |
| app | 542.0 KiB | 537.3 KiB | 4.8 KiB (0.9%) |
| core | 52.4 KiB | 50.2 KiB | 2.2 KiB (4.2%) |
| authorize | 520.7 KiB | 514.7 KiB | 6.0 KiB (1.2%) |
| onboarding | 620.0 KiB | 613.0 KiB | 7.0 KiB (1.1%) |
| custom-panel | 63.3 KiB | 61.1 KiB | 2.2 KiB (3.5%) |
### Legacy
| Entrypoint | Old | New | Δ |
| ------------ | --------- | --------- | ----------------------- |
| app | 822.5 KiB | 632.6 KiB | **189.8 KiB (23.1%)** |
| core | 229.0 KiB | 158.2 KiB | **70.8 KiB (30.9%)** |
| authorize | 795.9 KiB | 617.1 KiB | 178.9 KiB (22.5%) |
| onboarding | 939.3 KiB | 722.7 KiB | 216.6 KiB (23.1%) |
| custom-panel | 174.1 KiB | 106.9 KiB | **67.2 KiB (38.6%)** |
## Takeaways
- **The modern build barely changes** (≈−0.4% across the board). Trading the old
usage-based floor for a clean `last 2 years` rule is roughly size-neutral: the lower
Safari floor costs a little, the higher Chrome floor saves a little more.
- **The legacy build shrinks substantially**: 10% raw JS and 23% to 39% on the
cold-load entrypoints, because raising the floor (Chrome 39→79, Samsung 10→24,
iOS 11→13.2) drops a large amount of transpilation output and polyfills.
- Over the wire the legacy win is smaller (2.5% gzip / 1.2% brotli) since transpilation
boilerplate compresses very well — but parse/execute cost on the (typically slow)
devices that receive the legacy build scales with the raw size, so the 10% still
matters where it hurts most.
- The legacy entrypoints land well under the current `build-scripts/bundle-budget.json`
budgets; those could be re-seeded (`node build-scripts/check-bundle-size.cjs --update
--headroom=3`) once this browserslist change is final.
## Method
1. `yarn build` with the staged (new) `.browserslistrc`; recorded per-file raw and
`gzip -9` sizes of `hass_frontend/frontend_latest` + `frontend_es5` and the rspack
stats (`build/stats/frontend-{modern,legacy}.json`).
2. Restored the HEAD (old) `.browserslistrc`, rebuilt, recorded the same.
3. Restored the staged file. Entrypoint sizes are the initial entry-chunk JS as computed
by the same logic as `check-bundle-size.cjs`. Translations/static assets are identical
between builds and excluded from the JS numbers. The root service-worker files
(`sw-modern.js`, `sw-legacy.js`) were not compared separately.
@@ -0,0 +1,120 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import type { LitElement } from "lit";
import type { Ref } from "lit/directives/ref";
const scrollParent = (element: Element): HTMLElement | undefined => {
let node = element.parentElement;
while (node) {
const { overflowY } = getComputedStyle(node);
if (overflowY === "auto" || overflowY === "scroll") {
return node;
}
node = node.parentElement;
}
return undefined;
};
/**
* Does what CSS scroll anchoring does in Chrome and Firefox but not in Safari.
* Point the ref at the element that grows, not at the scroller. Turns native
* anchoring off on that scroller, so growth elsewhere in it is no longer
* compensated either.
*/
export class PreserveScrollPositionController implements ReactiveController {
private _target: Ref<HTMLElement>;
private _element?: HTMLElement;
private _scroller?: HTMLElement;
private _observer?: ResizeObserver;
private _height = 0;
constructor(
host: ReactiveControllerHost & LitElement,
target: Ref<HTMLElement>
) {
this._target = target;
host.addController(this);
}
hostConnected() {
this._sync();
}
hostUpdated() {
this._sync();
}
hostDisconnected() {
this._detach();
}
private _sync() {
const element = this._target.value;
if (element === this._element) {
return;
}
this._detach();
this._element = element;
if (element) {
this._height = element.getBoundingClientRect().height;
this._observer = new ResizeObserver((entries) =>
this._compensate(entries)
);
this._observer.observe(element);
}
}
private _detach() {
this._observer?.disconnect();
this._observer = undefined;
this._element = undefined;
if (this._scroller) {
this._scroller.style.removeProperty("overflow-anchor");
this._scroller = undefined;
}
}
private _resolveScroller(): HTMLElement | undefined {
if (!this._scroller && this._element) {
this._scroller = scrollParent(this._element);
if (this._scroller) {
// Chrome and Firefox would otherwise anchor on top of this controller
// and correct twice.
this._scroller.style.overflowAnchor = "none";
}
}
return this._scroller;
}
private _compensate(entries: ResizeObserverEntry[]) {
const element = this._element;
if (!element) {
return;
}
const height =
entries[0]?.borderBoxSize?.[0]?.blockSize ?? element.offsetHeight;
const delta = height - this._height;
this._height = height;
if (!delta) {
return;
}
const scroller = this._resolveScroller();
if (
scroller &&
element.getBoundingClientRect().top < scroller.getBoundingClientRect().top
) {
scroller.scrollTop += delta;
}
}
}
+16 -14
View File
@@ -542,20 +542,14 @@ export class HaServiceControl extends LitElement {
}
${
serviceData && "target" in serviceData
? html`<ha-settings-row
.narrow=${this.narrow || isFullWidthSelector(targetSelector)}
>
<span slot="heading"
>${this.hass.localize("ui.components.service-control.target")}</span
>
<ha-selector
.hass=${this.hass}
.selector=${targetSelector}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this._value?.target}
></ha-selector
></ha-settings-row>`
? html`<ha-selector
class="target-selector"
.hass=${this.hass}
.selector=${targetSelector}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this._value?.target}
></ha-selector>`
: entityId
? html`<ha-entity-picker
.disabled=${this.disabled}
@@ -1059,6 +1053,14 @@ export class HaServiceControl extends LitElement {
display: block;
margin: var(--service-control-padding, 0 var(--ha-space-4));
}
ha-selector.target-selector {
display: block;
padding: var(--ha-space-2) var(--ha-space-4);
border-top: var(
--service-control-items-border-top,
1px solid var(--divider-color)
);
}
ha-yaml-editor {
padding: var(--ha-space-4) 0;
}
+1 -1
View File
@@ -1342,7 +1342,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
.item-groups {
overflow: hidden;
border: 2px solid var(--divider-color);
border: var(--ha-border-width-sm) solid var(--divider-color);
border-radius: var(--ha-border-radius-lg);
}
`;
@@ -1,7 +1,13 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer";
import { grid } from "@lit-labs/virtualizer/layouts/grid";
import { mdiArrowUpRight, mdiKeyboard, mdiPlay, mdiPlus } from "@mdi/js";
import {
mdiArrowUpRight,
mdiFilterVariant,
mdiKeyboard,
mdiPlay,
mdiPlus,
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import {
@@ -14,6 +20,7 @@ import {
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { slugify } from "../../common/string/slugify";
import { debounce } from "../../common/util/debounce";
import { UNAVAILABLE } from "../../data/entity/entity";
@@ -32,6 +39,7 @@ import {
browseLocalMediaPlayer,
isManualMediaSourceContentId,
MANUAL_MEDIA_SOURCE_PREFIX,
searchMedia,
} from "../../data/media_source";
import { isTTSMediaSource } from "../../data/tts";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
@@ -43,9 +51,14 @@ import "../entity/ha-entity-picker";
import "../ha-alert";
import "../ha-button";
import "../ha-card";
import "../ha-dropdown";
import type { HaDropdownSelectEvent } from "../ha-dropdown";
import "../ha-dropdown-item";
import "../ha-icon-button";
import "../ha-list";
import "../ha-list-item";
import "../input/ha-input-search";
import type { HaInputSearch } from "../input/ha-input-search";
import "./ha-media-browser-thumbnail";
import "../ha-spinner";
import "../ha-svg-icon";
@@ -74,6 +87,8 @@ export interface MediaPlayerItemId {
media_content_type?: string | undefined;
}
type MediaClass = MediaPlayerItem["media_class"];
const MANUAL_ITEM_BASE: Omit<MediaPlayerItem, "title"> = {
can_expand: true,
can_play: false,
@@ -122,6 +137,16 @@ export class HaMediaPlayerBrowse extends LitElement {
@state() private _currentItem?: MediaPlayerItem;
@state() private _searchQuery = "";
@state() private _searchResults?: MediaPlayerItem[];
@state() private _searching = false;
@state() private _mediaClassFilter: MediaClass[] = [];
private _searchRequestId = 0;
@query(".header") private _header?: HTMLDivElement;
@query(".content") private _content?: HTMLDivElement;
@@ -197,6 +222,10 @@ export class HaMediaPlayerBrowse extends LitElement {
const oldParentItem = this._parentItem;
this._currentItem = undefined;
this._parentItem = undefined;
this._abortSearch();
this._searchQuery = "";
this._searchResults = undefined;
this._mediaClassFilter = [];
const currentId = navigateIds[navigateIds.length - 1];
const parentId =
navigateIds.length > 1 ? navigateIds[navigateIds.length - 2] : undefined;
@@ -328,11 +357,14 @@ export class HaMediaPlayerBrowse extends LitElement {
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("_scrolled")) {
if (changedProps.has("_scrolled") || changedProps.has("_currentItem")) {
// Re-measure across frames rather than once: the search input sizes
// asynchronously, so a single measurement can be too small and the
// content would clip under the header.
this._animateHeaderHeight();
} else if (changedProps.has("_currentItem")) {
this._setHeaderHeight();
}
if (changedProps.has("_currentItem")) {
// This fixes a race condition for resizing of the cards using the grid layout
if (this._observed) {
return;
@@ -365,10 +397,15 @@ export class HaMediaPlayerBrowse extends LitElement {
const currentItem = this._currentItem;
const isSearching = this._searchResults !== undefined;
const subtitle = this.hass.localize(
`ui.components.media-browser.class.${currentItem.media_class}`
);
let children = currentItem.children || [];
let children = isSearching
? this._searchResults!
: currentItem.children || [];
const notShown = isSearching ? 0 : currentItem.not_shown || 0;
const canPlayChildren = new Set<string>();
// Filter children based on accept property if provided
@@ -399,6 +436,26 @@ export class HaMediaPlayerBrowse extends LitElement {
});
}
// Search is available on non-root pages that opt in via can_search, aside
// from the manual-entry and TTS pseudo-sources.
const showSearch =
this.navigateIds.length > 1 &&
!isManualMediaSourceContentId(currentItem.media_content_id) &&
!isTTSMediaSource(currentItem.media_content_id) &&
currentItem.can_search;
// The backend reports which media classes are worth filtering by for this
// item; without them we still allow searching, just without the filter.
const mediaClassFilterOptions =
showSearch && currentItem.search_media_classes
? [...currentItem.search_media_classes].sort((a, b) =>
caseInsensitiveStringCompare(
this._localizeMediaClass(a),
this._localizeMediaClass(b),
this.hass.locale.language
)
)
: [];
const mediaClass = MediaClassBrowserSettings[currentItem.media_class];
const childrenMediaClass = currentItem.children_media_class
? MediaClassBrowserSettings[currentItem.children_media_class]
@@ -406,94 +463,109 @@ export class HaMediaPlayerBrowse extends LitElement {
return html`
${
currentItem.can_play
currentItem.can_play || showSearch
? html`
<div
class="header ${classMap({
"no-img": !currentItem.thumbnail,
"no-dialog": !this.dialog,
"search-only": !currentItem.can_play,
})}"
@transitionend=${this._setHeaderHeight}
>
<div class="header-content">
${
currentItem.thumbnail
? html`
<div class="img">
<ha-media-browser-thumbnail
.hass=${this.hass}
.url=${currentItem.thumbnail}
></ha-media-browser-thumbnail>
${
showSearch
? this._renderSearchRow(
currentItem,
mediaClassFilterOptions
)
: nothing
}
${
currentItem.can_play
? html`<div class="header-content">
${
currentItem.thumbnail
? html`
<div class="img">
<ha-media-browser-thumbnail
.hass=${this.hass}
.url=${currentItem.thumbnail}
></ha-media-browser-thumbnail>
${
this.narrow &&
currentItem?.can_play &&
(!this.accept ||
canPlayChildren.has(
currentItem.media_content_id
))
? html`
<ha-button
class="fab"
.item=${currentItem}
@click=${this._actionClicked}
.title=${this.hass.localize(
`ui.components.media-browser.${this.action}`
)}
>
<ha-svg-icon
.path=${
this.action === "play"
? mdiPlay
: mdiPlus
}
></ha-svg-icon>
</ha-button>
`
: ""
}
</div>
`
: nothing
}
<div class="header-info">
<div class="breadcrumb">
<h1 class="title">${currentItem.title}</h1>
${
this.narrow &&
currentItem?.can_play &&
(!this.accept ||
canPlayChildren.has(
currentItem.media_content_id
))
subtitle
? html`
<ha-button
class="fab"
.item=${currentItem}
@click=${this._actionClicked}
.title=${this.hass.localize(
`ui.components.media-browser.${this.action}`
)}
>
<ha-svg-icon
.path=${
this.action === "play"
? mdiPlay
: mdiPlus
}
></ha-svg-icon>
</ha-button>
<h2 class="subtitle">
${subtitle}
</h2>
`
: ""
}
</div>
`
: nothing
}
<div class="header-info">
<div class="breadcrumb">
<h1 class="title">${currentItem.title}</h1>
${
subtitle
? html`
<h2 class="subtitle">${subtitle}</h2>
`
: ""
}
</div>
${
currentItem.can_play &&
(!currentItem.thumbnail || !this.narrow)
? html`
<ha-button
.item=${currentItem}
@click=${this._actionClicked}
>
<ha-svg-icon
.label=${this.hass.localize(
`ui.components.media-browser.${this.action}-media`
)}
.path=${
this.action === "play"
? mdiPlay
: mdiPlus
}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.components.media-browser.${this.action}`
)}
</ha-button>
`
: ""
}
</div>
</div>
${
currentItem.can_play &&
(!currentItem.thumbnail || !this.narrow)
? html`
<ha-button
.item=${currentItem}
@click=${this._actionClicked}
>
<ha-svg-icon
.label=${this.hass.localize(
`ui.components.media-browser.${this.action}-media`
)}
.path=${
this.action === "play"
? mdiPlay
: mdiPlus
}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.components.media-browser.${this.action}`
)}
</ha-button>
`
: ""
}
</div>
</div>`
: nothing
}
</div>
`
: ""
@@ -504,12 +576,10 @@ export class HaMediaPlayerBrowse extends LitElement {
@touchmove=${this._scroll}
>
${
this._error
this._searching
? html`
<div class="container">
<ha-alert alert-type="error">
${this._renderError(this._error)}
</ha-alert>
<ha-spinner></ha-spinner>
</div>
`
: isManualMediaSourceContentId(currentItem.media_content_id)
@@ -532,29 +602,33 @@ export class HaMediaPlayerBrowse extends LitElement {
@tts-picked=${this._ttsPicked}
></ha-browse-media-tts>
`
: !children.length && !currentItem.not_shown
: !children.length && !notShown
? html`
<div class="container no-items">
${
currentItem.media_content_id ===
"media-source://media_source/local/."
? html`
<div class="highlight-add-button">
<span>
<ha-svg-icon
.path=${mdiArrowUpRight}
></ha-svg-icon>
</span>
<span>
${this.hass.localize(
"ui.components.media-browser.file_management.highlight_button"
)}
</span>
</div>
`
: this.hass.localize(
"ui.components.media-browser.no_items"
isSearching
? this.hass.localize(
"ui.components.media-browser.search.no_results"
)
: currentItem.media_content_id ===
"media-source://media_source/local/."
? html`
<div class="highlight-add-button">
<span>
<ha-svg-icon
.path=${mdiArrowUpRight}
></ha-svg-icon>
</span>
<span>
${this.hass.localize(
"ui.components.media-browser.file_management.highlight_button"
)}
</span>
</div>
`
: this.hass.localize(
"ui.components.media-browser.no_items"
)
}
</div>
`
@@ -584,17 +658,17 @@ export class HaMediaPlayerBrowse extends LitElement {
portrait:
childrenMediaClass.thumbnail_ratio ===
"portrait",
not_shown: !!currentItem.not_shown,
not_shown: !!notShown,
})}"
></lit-virtualizer>
${
currentItem.not_shown
notShown
? html`
<div class="grid not-shown">
<div class="title">
${this.hass.localize(
"ui.components.media-browser.not_shown",
{ count: currentItem.not_shown }
{ count: notShown }
)}
</div>
</div>
@@ -614,7 +688,7 @@ export class HaMediaPlayerBrowse extends LitElement {
.renderItem=${this._renderListItem}
></lit-virtualizer>
${
currentItem.not_shown
notShown
? html`
<ha-list-item
noninteractive
@@ -628,7 +702,7 @@ export class HaMediaPlayerBrowse extends LitElement {
<span class="title">
${this.hass.localize(
"ui.components.media-browser.not_shown",
{ count: currentItem.not_shown }
{ count: notShown }
)}
</span>
</ha-list-item>
@@ -644,6 +718,188 @@ export class HaMediaPlayerBrowse extends LitElement {
`;
}
private _renderSearchRow(
currentItem: MediaPlayerItem,
mediaClassFilterOptions: MediaClass[]
): TemplateResult {
return html`
<div class="search-row">
${
currentItem.can_search
? html`
<ha-input-search
class="search-input"
appearance="outlined"
.value=${this._searchQuery}
.placeholder=${this.hass.localize(
"ui.components.media-browser.search.search_placeholder",
{ name: currentItem.title }
)}
@input=${this._handleSearchInput}
@keydown=${this._handleSearchKeydown}
></ha-input-search>
${
mediaClassFilterOptions.length
? this._renderMediaClassFilter(mediaClassFilterOptions)
: nothing
}
<ha-button
class="search-button"
appearance="filled"
.disabled=${!this._searchQuery.trim()}
@click=${this._search}
>
${this.hass.localize("ui.common.search")}
</ha-button>
`
: nothing
}
</div>
`;
}
private _renderMediaClassFilter(
mediaClassFilterOptions: MediaClass[]
): TemplateResult {
const selectedCount = this._mediaClassFilter.length;
return html`
<div class="media-class-filter">
<ha-dropdown
placement="bottom-end"
@wa-select=${this._toggleMediaClassFilter}
>
<ha-icon-button
slot="trigger"
class="filter-button ${classMap({ active: selectedCount > 0 })}"
.path=${mdiFilterVariant}
.label=${this.hass.localize(
"ui.components.media-browser.filter_media_type"
)}
></ha-icon-button>
${mediaClassFilterOptions.map((mediaClass) => {
const selected = this._mediaClassFilter.includes(mediaClass);
return html`
<ha-dropdown-item
.value=${mediaClass}
.action=${selected ? "remove" : "add"}
type="checkbox"
.checked=${selected}
>
${this._localizeMediaClass(mediaClass)}
</ha-dropdown-item>
`;
})}
</ha-dropdown>
${
selectedCount
? html`<div class="filter-badge">${selectedCount}</div>`
: nothing
}
</div>
`;
}
private _localizeMediaClass(mediaClass: MediaClass): string {
return (
this.hass.localize(`ui.components.media-browser.class.${mediaClass}`) ||
mediaClass
);
}
private _toggleMediaClassFilter(ev: HaDropdownSelectEvent<MediaClass>): void {
ev.preventDefault(); // keep the dropdown open for multi-select
const value = ev.detail.item.value;
const action = (ev.detail.item as { action?: "add" | "remove" }).action;
this._mediaClassFilter =
action === "add"
? [...this._mediaClassFilter, value]
: this._mediaClassFilter.filter((mediaClass) => mediaClass !== value);
// Only refine results already on screen; before a search has run, the
// filter is just staged for the next Enter/search-button submission.
if (this._searchResults !== undefined) {
this._search();
}
}
private _handleSearchInput(ev: InputEvent): void {
const value = (ev.target as HaInputSearch).value ?? "";
this._searchQuery = value;
// Searching is explicit (Enter or the search button). Emptying the field —
// e.g. via the clear button — returns to the browse view.
if (!value) {
this._clearSearch();
}
}
private _handleSearchKeydown(ev: KeyboardEvent): void {
if (ev.key === "Enter") {
ev.preventDefault();
this._search();
}
}
private _abortSearch(): void {
// Invalidate any in-flight search so a late response is ignored, and clear
// the loading state.
this._searchRequestId++;
this._searching = false;
}
private _clearSearch(): void {
this._abortSearch();
this._searchQuery = "";
this._searchResults = undefined;
}
private async _search(): Promise<void> {
const searchQuery = this._searchQuery.trim();
if (!searchQuery) {
// Nothing to search; drop any stale results but keep the input.
this._abortSearch();
this._searchResults = undefined;
return;
}
const navigateId = this.navigateIds[this.navigateIds.length - 1];
const requestId = ++this._searchRequestId;
this._searching = true;
// Clear previous results so stale data isn't shown while searching or on error
this._searchResults = undefined;
const mediaFilterClasses = this._mediaClassFilter.length
? this._mediaClassFilter
: undefined;
try {
const { result } = await searchMedia(
this.hass,
navigateId.media_content_id,
searchQuery,
mediaFilterClasses
);
// Ignore the response if a newer search started or we navigated away
if (requestId !== this._searchRequestId) {
return;
}
this._searchResults = result;
} catch (err) {
// Ignore errors from superseded searches
if (requestId !== this._searchRequestId) {
return;
}
showAlertDialog(this, {
title: this.hass.localize(
"ui.components.media-browser.media_browsing_error"
),
text: err instanceof Error ? err.message : String(err),
});
} finally {
// Only the most recent search controls the loading state
if (requestId === this._searchRequestId) {
this._searching = false;
}
}
}
private _renderGridItem = (child: MediaPlayerItem): TemplateResult => {
return html`
<div class="child" .item=${child} @click=${this._childClicked}>
@@ -1009,7 +1265,8 @@ export class HaMediaPlayerBrowse extends LitElement {
.header {
display: flex;
justify-content: space-between;
flex-direction: column;
gap: var(--ha-space-2);
border-bottom: 1px solid var(--divider-color);
background-color: var(--card-background-color);
position: absolute;
@@ -1019,6 +1276,56 @@ export class HaMediaPlayerBrowse extends LitElement {
z-index: 3;
padding: 16px;
}
.header.search-only {
padding: 8px 16px;
}
.search-row {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--ha-space-2);
}
.search-input {
flex: 1;
--ha-input-padding-top: 0;
--ha-input-padding-bottom: 0;
}
.search-button {
flex: none;
}
:host([narrow]) .search-row {
box-sizing: border-box;
padding: 8px 16px;
}
.media-class-filter {
position: relative;
flex: none;
}
.filter-button {
--ha-icon-button-size: 40px;
color: var(--secondary-text-color);
}
.filter-button.active {
color: var(--primary-color);
}
.filter-badge {
position: absolute;
top: -4px;
right: -4px;
inset-inline-end: -4px;
inset-inline-start: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
pointer-events: none;
}
.header_button {
position: relative;
right: -8px;
@@ -8,6 +8,13 @@ import "../ha-expansion-panel";
import "../list/ha-list-base";
import "./ha-target-picker-item-row";
const TYPE_PLURAL = {
entity: "entities",
device: "devices",
area: "areas",
label: "labels",
} as const satisfies Record<TargetTypeFloorless, string>;
@customElement("ha-target-picker-item-group")
export class HaTargetPickerItemGroup extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -60,11 +67,11 @@ export class HaTargetPickerItemGroup extends LitElement {
>
<div slot="header" class="heading">
${this.hass.localize(
`ui.components.target-picker.selected.${this.type}`,
{
count,
}
`ui.components.target-picker.type.${TYPE_PLURAL[this.type]}`
)}
${
this.collapsed ? html`<span class="count">(${count})</span>` : nothing
}
</div>
<ha-list-base>
${Object.entries(this.items).map(([type, items]) =>
@@ -106,6 +113,10 @@ export class HaTargetPickerItemGroup extends LitElement {
justify-content: space-between;
min-height: unset;
}
.count {
color: var(--secondary-text-color);
font-weight: var(--ha-font-weight-normal);
}
`;
}
+1
View File
@@ -10,6 +10,7 @@ export interface CoreFrontendUserData {
showEntityIdPicker?: boolean;
default_panel?: string;
apps_info_dismissed?: boolean;
dashboard_favorite_card_types?: string[];
}
export interface SidebarFrontendUserData {
+3
View File
@@ -186,6 +186,9 @@ export interface MediaPlayerItem {
can_play: boolean;
can_expand: boolean;
can_search: boolean;
search_media_classes?:
| (keyof TranslationDict["ui"]["components"]["media-browser"]["class"])[]
| null;
thumbnail?: string;
iconPath?: string;
children?: MediaPlayerItem[];
+17
View File
@@ -24,6 +24,23 @@ export const browseLocalMediaPlayer = (
media_content_id: mediaContentId,
});
export interface SearchMediaResult {
result: MediaPlayerItem[];
}
export const searchMedia = (
hass: HomeAssistant,
mediaContentId: string | undefined,
searchQuery: string,
mediaFilterClasses?: string[]
): Promise<SearchMediaResult> =>
hass.callWS<SearchMediaResult>({
type: "media_source/search_media",
media_content_id: mediaContentId,
search_query: searchQuery,
media_filter_classes: mediaFilterClasses,
});
export const MANUAL_MEDIA_SOURCE_PREFIX = "__MANUAL_ENTRY__";
export const isManualMediaSourceContentId = (mediaContentId: string) =>
+72 -10
View File
@@ -18,8 +18,24 @@
<meta name="referrer" content="same-origin" />
<meta name="theme-color" content="{{ theme_color }}" />
<meta name="color-scheme" content="dark light" />
<link rel="preload" href="/static/fonts/roboto/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
<%= renderTemplate("_style_base.html.template") %>
<style>
@font-face {
font-family: "Roboto Launch Screen";
font-display: block;
src: url("/static/fonts/roboto/Roboto-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
html {
--ha-animation-duration-normal: 250ms;
}
@media (prefers-reduced-motion: reduce) {
html {
--ha-animation-duration-normal: 1ms;
}
}
@keyframes fade-out {
from {
opacity: 1;
@@ -29,11 +45,11 @@
}
}
::view-transition-group(launch-screen) {
animation-duration: var(--ha-animation-duration-slow, 350ms);
animation-duration: var(--ha-animation-duration-normal, 250ms);
animation-timing-function: ease-out;
}
::view-transition-old(launch-screen) {
animation: fade-out var(--ha-animation-duration-slow, 350ms) ease-out;
animation: fade-out var(--ha-animation-duration-normal, 250ms) ease-out;
}
html {
background-color: var(--primary-background-color, #fafafa);
@@ -41,6 +57,7 @@
height: 100vh;
}
#ha-launch-screen {
font-family: "Roboto Launch Screen", sans-serif;
position: fixed;
top: 0;
left: 0;
@@ -55,13 +72,32 @@
view-transition-name: launch-screen;
background-color: var(--primary-background-color, #fafafa);
z-index: 100;
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
}
#ha-launch-screen.removing {
opacity: 0;
}
#ha-launch-screen svg {
width: 112px;
@keyframes launch-lockup-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
#ha-launch-screen .ha-lockup {
display: flex;
align-items: center;
flex-shrink: 0;
animation: launch-lockup-in var(--ha-animation-duration-normal, 250ms) ease-out both;
}
#ha-launch-screen-info-box > *,
#ha-launch-screen .ohf-logo {
animation: launch-lockup-in var(--ha-animation-duration-normal, 250ms) ease-out both;
}
#ha-launch-screen .ha-logo {
width: 96px;
height: 96px;
}
#ha-launch-screen .ha-launch-screen-spacer-top {
flex: 1;
@@ -77,8 +113,22 @@
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
opacity: .66;
}
.ohf-logo span {
font-size: 12px;
line-height: 12px;
text-transform: uppercase;
}
.ohf-logo picture {
display: flex;
}
.ohf-logo img {
width: 237px;
aspect-ratio: 237 / 24;
height: auto;
}
@media (prefers-color-scheme: dark) {
html {
background-color: var(--primary-background-color, #111111);
@@ -88,8 +138,17 @@
body #ha-launch-screen {
background-color: var(--primary-background-color, #111111);
}
}
@media (max-height: 560px) {
#ha-launch-screen .ha-launch-screen-spacer-top {
margin-top: 24px;
padding-top: 24px;
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
padding-top: 16px;
}
.ohf-logo {
filter: invert(1);
margin-block: 24px;
}
}
</style>
@@ -97,13 +156,16 @@
<body>
<div id="ha-launch-screen">
<div class="ha-launch-screen-spacer-top"></div>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240">
<path fill="#18BCF2" d="M240 224.762a15 15 0 0 1-15 15H15a15 15 0 0 1-15-15v-90c0-8.25 4.77-19.769 10.61-25.609l98.78-98.7805c5.83-5.83 15.38-5.83 21.21 0l98.79 98.7895c5.83 5.83 10.61 17.36 10.61 25.61v90-.01Z"/>
<path fill="#F2F4F9" d="m107.27 239.762-40.63-40.63c-2.09.72-4.32 1.13-6.64 1.13-11.3 0-20.5-9.2-20.5-20.5s9.2-20.5 20.5-20.5 20.5 9.2 20.5 20.5c0 2.33-.41 4.56-1.13 6.65l31.63 31.63v-115.88c-6.8-3.3395-11.5-10.3195-11.5-18.3895 0-11.3 9.2-20.5 20.5-20.5s20.5 9.2 20.5 20.5c0 8.07-4.7 15.05-11.5 18.3895v81.27l31.46-31.46c-.62-1.96-.96-4.04-.96-6.2 0-11.3 9.2-20.5 20.5-20.5s20.5 9.2 20.5 20.5-9.2 20.5-20.5 20.5c-2.5 0-4.88-.47-7.09-1.29L129 208.892v30.88z"/>
</svg>
<div class="ha-lockup">
<img class="ha-logo" src="/static/images/home-assistant-logo-loading.svg" alt="Home Assistant">
</div>
<div id="ha-launch-screen-info-box" class="ha-launch-screen-spacer-bottom"></div>
<div class="ohf-logo">
<img src="/static/images/ohf-badge.svg" alt="Home Assistant is a project by the Open Home Foundation" height="46">
<span id="ha-launch-screen-attribution">A project from the</span>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="/static/images/open-home-foundation-on-dark.svg">
<img src="/static/images/open-home-foundation-on-light.svg" alt="Open Home Foundation" width="237" height="24">
</picture>
</div>
</div>
<home-assistant></home-assistant>
+59 -61
View File
@@ -1,91 +1,91 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../components/ha-spinner";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-button";
@customElement("ha-init-page")
class HaInitPage extends LitElement {
export class HaInitPage extends LitElement {
@property({ type: Boolean }) public error = false;
@property({ type: Boolean }) public migration = false;
@state() private _retryInSeconds = 60;
@property({ attribute: false }) public localize?: LocalizeFunc;
private _showProgressIndicatorTimeout?: number;
@state() private _retryInSeconds = 60;
private _retryInterval?: number;
protected render() {
return this.error
? html`
<p>Unable to connect to Home Assistant.</p>
<p>
${
this.localize?.("ui.init.error.title") ||
"Unable to connect to Home Assistant."
}
</p>
<p class="retry-text">
Retrying in ${this._retryInSeconds} seconds...
${
this.localize?.("ui.init.error.retrying", {
seconds: this._retryInSeconds,
}) || `Retrying in ${this._retryInSeconds} seconds...`
}
</p>
<ha-button size="s" appearance="plain" @click=${this._retry}
>Retry now</ha-button
>${
this.localize?.("ui.init.error.retry_now") || "Retry now"
}</ha-button
>
${
location.host.includes("ui.nabu.casa")
? html`
<p>
It is possible that you are seeing this screen because your
Home Assistant is not currently connected. You can ask it to
come online from your
<a href="https://account.nabucasa.com/"
>Nabu Casa account page</a
>.
</p>
`
: ""
? html`<p>
${
this.localize?.("ui.init.error.nabu_casa", {
account_link: html`<a href="https://account.nabucasa.com/"
>${
this.localize?.("ui.init.error.nabu_casa_account") ||
"Nabu Casa account page"
}</a
>`,
}) ||
html`It is possible that you are seeing this screen because
your Home Assistant is not currently connected. You can
ask it to come online from your
<a href="https://account.nabucasa.com/"
>Nabu Casa account page</a
>.`
}
</p>`
: nothing
}
`
: html`
<div id="progress-indicator-wrapper">
<ha-spinner></ha-spinner>
</div>
<div id="loading-text">
${
this.migration
? html`
Database upgrade is in progress, Home Assistant will not
start until the upgrade is completed.
<br /><br />
The upgrade may need a long time to complete, please be
patient.
`
: "Loading data"
}
</div>
`;
: html`<p>
${
this.migration
? html`<span class="migration-text"
>${
this.localize?.("ui.init.migration") ||
"Database upgrade is in progress, Home Assistant will not start until the upgrade is completed.\n\nThe upgrade may need a long time to complete, please be patient."
}</span
>`
: this.localize?.("ui.init.loading") || "Loading data"
}
</p>`;
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._showProgressIndicatorTimeout) {
clearTimeout(this._showProgressIndicatorTimeout);
}
if (this._retryInterval) {
clearInterval(this._retryInterval);
}
}
protected willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("error") && this.error) {
import("../components/ha-button");
}
}
protected firstUpdated() {
this._showProgressIndicatorTimeout = window.setTimeout(() => {
import("../components/ha-spinner");
}, 5000);
this._retryInterval = window.setInterval(() => {
const remainingSeconds = this._retryInSeconds--;
if (remainingSeconds <= 0) {
if (this._retryInSeconds <= 1) {
this._retry();
} else {
this._retryInSeconds -= 1;
}
}, 1000);
}
@@ -104,24 +104,22 @@ class HaInitPage extends LitElement {
flex-direction: column;
align-items: center;
}
#progress-indicator-wrapper {
display: flex;
align-items: center;
margin: 25px 0;
height: 50px;
}
a {
color: var(--primary-color);
}
.retry-text {
margin-top: 0;
}
p,
#loading-text {
p {
max-width: 350px;
margin: var(--ha-space-3, 12px) var(--ha-space-4, 16px);
color: var(--primary-text-color);
font-size: var(--ha-font-size-m, 14px);
text-align: center;
}
.migration-text {
white-space: pre-line;
}
`;
}
+33 -8
View File
@@ -5,6 +5,7 @@ import { customElement, state } from "lit/decorators";
import { storage } from "../common/decorators/storage";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
import { fetchHttpConfig } from "../data/http";
import type { HttpConfigState } from "../data/http";
import type { WindowWithPreloads } from "../data/preloads";
@@ -18,7 +19,7 @@ import type { HomeAssistant, Route } from "../types";
import { storeState } from "../util/ha-pref-storage";
import {
removeLaunchScreen,
renderLaunchScreenInfoBox,
renderLaunchScreenContent,
} from "../util/launch-screen";
import { checkOnboardingSurveyToast } from "../util/onboarding-survey";
import {
@@ -60,6 +61,8 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
private _httpPendingDialogOpen = false;
private _initError = false;
private _onboardingSurveyChecked = false;
private _panelUrl: string;
@@ -135,11 +138,14 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
this.update = super.update;
// Apps with a native splash screen keep covering the frontend until
// frontend/loaded, so the launch screen stays up (invisibly) until the
// first panel has rendered and partial-panel-resolver removes it.
if (!this.hass.auth.external?.config.hasSplashscreen) {
removeLaunchScreen();
// first panel has rendered and partial-panel-resolver removes it and
// fires frontend/loaded.
if (
!this.hass.auth.external?.config.hasSplashscreen &&
removeLaunchScreen()
) {
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
}
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
}
super.update(changedProps);
}
@@ -188,6 +194,11 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
if (this.render !== this.renderHass) {
this._renderInitInfo(false);
}
this.addEventListener("translations-updated", () => {
if (this.render !== this.renderHass) {
this._renderInitInfo(this._initError);
}
});
}
protected updated(changedProps: PropertyValues): void {
@@ -315,7 +326,7 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
protected async _initializeHass() {
try {
let result;
let result: Awaited<Window["hassConnection"]>;
if (window.hassConnection) {
result = await window.hassConnection;
@@ -388,11 +399,25 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
}
private _renderInitInfo(error: boolean) {
renderLaunchScreenInfoBox(
this._initError = error;
renderLaunchScreenContent(
html`<ha-init-page
.error=${error}
.migration=${this._databaseMigration}
></ha-init-page>`
.localize=${this._launchScreenLocalize}
></ha-init-page>`,
this._launchScreenAttribution
);
}
private get _launchScreenLocalize(): LocalizeFunc | undefined {
return (this.hass ?? this._pendingHass).localize;
}
private get _launchScreenAttribution() {
return (
this._launchScreenLocalize?.("ui.init.project_from") ||
"A project from the"
);
}
}
+7 -2
View File
@@ -220,8 +220,13 @@ class PartialPanelResolver extends HassRouterPage {
) {
await this.rebuild();
await this.pageRendered;
removeLaunchScreen(!!this.hass.auth.external?.config.hasSplashscreen);
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
// Only fire frontend/loaded when this call actually removed the launch
// screen, so later panel updates do not fire it again.
if (
removeLaunchScreen(!!this.hass.auth.external?.config.hasSplashscreen)
) {
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
}
}
}
}
@@ -1,7 +1,7 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer";
import { consume } from "@lit/context";
import "@material/mwc-list/mwc-list";
import { mdiPlus, mdiTextureBox } from "@mdi/js";
import { mdiPlus, mdiTextureBox, mdiUnfoldMoreHorizontal } from "@mdi/js";
import Fuse from "fuse.js";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import {
@@ -98,8 +98,16 @@ export const ITEM_SEARCH_KEYS: FuseWeightedKey[] = [
},
];
const MAX_SEARCH_ITEMS_PER_SECTION = 5;
type SearchSection = "item" | "block" | "entity" | "device" | "area" | "label";
interface SearchMoreComboBoxItem extends PickerComboBoxItem {
type: "more";
section: "entity" | "device" | "item";
label: string;
}
@customElement("ha-automation-add-search")
export class HaAutomationAddSearch extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -288,7 +296,8 @@ export class HaAutomationAddSearch extends LitElement {
| (FloorComboBoxItem & { last?: boolean | undefined })
| EntityComboBoxItem
| DevicePickerItem
| AutomationItemComboBoxItem,
| AutomationItemComboBoxItem
| SearchMoreComboBoxItem,
index: number
) => {
if (!item) {
@@ -299,6 +308,24 @@ export class HaAutomationAddSearch extends LitElement {
return html`<ha-section-title>${item}</ha-section-title>`;
}
if ("type" in item && item.type === "more") {
return html`<ha-combo-box-item
id=${`search-list-item-${index}`}
type="button"
tabindex="-1"
.section-id=${item.section}
.value=${`more-${item.section}`}
@click=${this._toggleSection}
>
<ha-svg-icon
slot="start"
.path=${mdiUnfoldMoreHorizontal}
></ha-svg-icon>
<span slot="headline"></span>
<span slot="supporting-text">${item.label}</span>
</ha-combo-box-item>`;
}
const type = ["trigger", "condition", "action", "block"].includes(
(item as AutomationItemComboBoxItem).type
)
@@ -512,10 +539,17 @@ export class HaAutomationAddSearch extends LitElement {
selectedSection?: SearchSection,
relatedIdSets?: RelatedIdSets
) => {
const resultItems: (
string | FloorComboBoxItem | EntityComboBoxItem | PickerComboBoxItem
)[] = [];
type ResultItem =
| string
| FloorComboBoxItem
| EntityComboBoxItem
| PickerComboBoxItem
| SearchMoreComboBoxItem;
const resultSections: {
title: string;
type: string;
items: ResultItem[];
}[] = [];
if (!selectedSection || selectedSection === "item") {
let items = this._convertItemsToComboBoxItems(automationItems, type);
if (searchTerm) {
@@ -526,15 +560,13 @@ export class HaAutomationAddSearch extends LitElement {
ITEM_SEARCH_KEYS
) as AutomationItemComboBoxItem[];
}
if (!selectedSection && items.length) {
// show group title
resultItems.push(
localize(`ui.panel.config.automation.editor.${type}s.name`)
);
if (items.length) {
resultSections.push({
title: localize(`ui.panel.config.automation.editor.${type}s.name`),
type: "item",
items: items,
});
}
resultItems.push(...items);
}
if (
@@ -564,13 +596,13 @@ export class HaAutomationAddSearch extends LitElement {
) as AutomationItemComboBoxItem[];
}
if (!selectedSection && blocks.length) {
// show group title
resultItems.push(
localize("ui.panel.config.automation.editor.blocks")
);
if (blocks.length) {
resultSections.push({
title: localize("ui.panel.config.automation.editor.blocks"),
type: "block",
items: blocks,
});
}
resultItems.push(...blocks);
}
if (!selectedSection || selectedSection === "entity") {
@@ -601,14 +633,13 @@ export class HaAutomationAddSearch extends LitElement {
entityItems = sortRelatedFirst(entityItems) as EntityComboBoxItem[];
}
if (!selectedSection && entityItems.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.entities")
);
if (entityItems.length) {
resultSections.push({
title: localize("ui.components.target-picker.type.entities"),
type: "entity",
items: entityItems,
});
}
resultItems.push(...entityItems);
}
if (!selectedSection || selectedSection === "device") {
@@ -640,14 +671,13 @@ export class HaAutomationAddSearch extends LitElement {
deviceItems = sortRelatedFirst(deviceItems);
}
if (!selectedSection && deviceItems.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.devices")
);
if (deviceItems.length) {
resultSections.push({
title: localize("ui.components.target-picker.type.devices"),
type: "device",
items: deviceItems,
});
}
resultItems.push(...deviceItems);
}
if (!selectedSection || selectedSection === "area") {
@@ -697,15 +727,9 @@ export class HaAutomationAddSearch extends LitElement {
) as FloorComboBoxItem[];
}
if (!selectedSection && areasAndFloors.length) {
// show group title
resultItems.push(localize("ui.components.target-picker.type.areas"));
}
resultItems.push(
...areasAndFloors.map((item, index) => {
if (areasAndFloors.length) {
const areaItems = areasAndFloors.map((item, index) => {
const nextItem = areasAndFloors[index + 1];
if (
!nextItem ||
(item.type === "area" && nextItem.type === "floor")
@@ -715,10 +739,15 @@ export class HaAutomationAddSearch extends LitElement {
last: true,
};
}
return item;
})
);
});
resultSections.push({
title: localize("ui.components.target-picker.type.areas"),
type: "area",
items: areaItems,
});
}
}
if (!selectedSection || selectedSection === "label") {
@@ -746,14 +775,48 @@ export class HaAutomationAddSearch extends LitElement {
);
}
if (!selectedSection && labels.length) {
// show group title
resultItems.push(localize("ui.components.target-picker.type.labels"));
if (labels.length) {
resultSections.push({
title: localize("ui.components.target-picker.type.labels"),
type: "label",
items: labels,
});
}
resultItems.push(...labels);
}
const resultItems: ResultItem[] = [];
resultSections.forEach((section, index) => {
if (selectedSection) {
resultItems.push(...section.items);
return;
}
resultItems.push(section.title);
if (
index !== resultSections.length - 1 &&
(section.type === "item" ||
section.type === "entity" ||
section.type === "device") &&
section.items.length > MAX_SEARCH_ITEMS_PER_SECTION + 1
) {
resultItems.push(
...section.items.slice(0, MAX_SEARCH_ITEMS_PER_SECTION)
);
const typeKey = section.type === "item" ? type : section.type;
resultItems.push({
primary: "",
id: `search-more-${section.type}`,
type: "more",
section: section.type,
label: localize(
`ui.panel.config.automation.editor.show_more_search.${typeKey}`,
{ count: section.items.length - MAX_SEARCH_ITEMS_PER_SECTION }
),
});
} else {
resultItems.push(...section.items);
}
});
return resultItems;
}
);
@@ -790,18 +853,22 @@ export class HaAutomationAddSearch extends LitElement {
);
}
private _toggleSection(ev: Event) {
private _toggleSection = (ev: Event) => {
ev.stopPropagation();
// this._resetSelectedItem();
this._searchSectionTitle = undefined;
const section = (ev.target as HTMLElement)["section-id"] as string;
const section = (ev.currentTarget as HTMLElement)["section-id"] as string;
if (!section) {
return;
}
if (this._selectedSearchSection === section) {
this._toggleSectionType(section);
};
private _toggleSectionType(type: string) {
if (this._selectedSearchSection === type) {
this._selectedSearchSection = undefined;
} else {
this._selectedSearchSection = section as SearchSection;
this._selectedSearchSection = type as SearchSection;
}
// Reset scroll position when filter changes
@@ -1019,9 +1086,13 @@ export class HaAutomationAddSearch extends LitElement {
const item = this._virtualizerElement?.items[
this._selectedSearchItemIndex
] as PickerComboBoxItem;
] as PickerComboBoxItem | SearchMoreComboBoxItem;
if (item) {
this._selectSearchItem(item);
if ("type" in item && item.type === "more") {
this._toggleSectionType((item as SearchMoreComboBoxItem).section);
} else {
this._selectSearchItem(item as PickerComboBoxItem);
}
}
};
@@ -218,20 +218,14 @@ export class HaPlatformCondition extends LitElement {
</div>
${
conditionDesc && "target" in conditionDesc
? html`<ha-settings-row narrow>
<span slot="heading"
>${this.hass.localize(
"ui.components.service-control.target"
)}</span
>
<ha-selector
.hass=${this.hass}
.selector=${this._targetSelector(conditionDesc.target)}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this.condition?.target}
></ha-selector
></ha-settings-row>`
? html`<ha-selector
class="target-selector"
.hass=${this.hass}
.selector=${this._targetSelector(conditionDesc.target)}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this.condition?.target}
></ha-selector>`
: nothing
}
${
@@ -670,6 +664,14 @@ export class HaPlatformCondition extends LitElement {
display: block;
margin: 0 var(--ha-space-4);
}
ha-selector.target-selector {
display: block;
padding: var(--ha-space-2) var(--ha-space-4);
border-top: var(
--service-control-items-border-top,
1px solid var(--divider-color)
);
}
ha-yaml-editor {
padding: var(--ha-space-4) 0;
}
@@ -213,19 +213,14 @@ export class HaPlatformTrigger extends LitElement {
</div>
${
triggerDesc && "target" in triggerDesc
? html`<ha-settings-row narrow>
<span slot="heading"
>${this.hass.localize(
"ui.components.service-control.target"
)}</span
><ha-selector
.hass=${this.hass}
.selector=${this._targetSelector(triggerDesc.target)}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this.trigger?.target}
></ha-selector
></ha-settings-row>`
? html`<ha-selector
class="target-selector"
.hass=${this.hass}
.selector=${this._targetSelector(triggerDesc.target)}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this.trigger?.target}
></ha-selector>`
: nothing
}
${
@@ -548,6 +543,14 @@ export class HaPlatformTrigger extends LitElement {
display: block;
margin: 0 var(--ha-space-4);
}
ha-selector.target-selector {
display: block;
padding: var(--ha-space-2) var(--ha-space-4);
border-top: var(
--service-control-items-border-top,
1px solid var(--divider-color)
);
}
ha-yaml-editor {
padding: var(--ha-space-4) 0;
}
@@ -249,9 +249,9 @@ export class DialogEnergyBatterySettings
></ha-statistic-picker>
<ha-input
.value=${this._source.capacity != null
? String(this._source.capacity)
: ""}
.value=${
this._source.capacity != null ? String(this._source.capacity) : ""
}
.label=${this.hass.localize(
"ui.panel.config.energy.battery.dialog.capacity"
)}
@@ -17,6 +17,7 @@ export type SankeyDeviceNode = Node & { parent?: string };
interface SmallConsumer {
id: string;
includedInStat: string | undefined;
name: string | undefined;
value: number;
effectiveParent: string | undefined;
@@ -53,9 +54,6 @@ export interface BuildSankeyDeviceNodesOptions {
getValue: (id: string) => number;
getLabel: (id: string, name: string | undefined) => string;
getEntityId: (id: string) => string | undefined;
findEffectiveParent: (
includedInStat: string | undefined
) => string | undefined;
}
/**
@@ -85,7 +83,6 @@ export const buildSankeyDeviceNodes = (
getValue,
getLabel,
getEntityId,
findEffectiveParent,
} = options;
const unavailableColor = computedStyle
@@ -96,15 +93,65 @@ export const buildSankeyDeviceNodes = (
const deviceNodes: SankeyDeviceNode[] = [];
const parentLinks: Record<string, string> = {};
const smallConsumersByParent = new Map<string, SmallConsumer[]>();
const smallConsumerStats = new Set<string>();
let untrackedConsumption = initialUntracked;
devices.forEach((device, idx) => {
// Resolve every device's node id and value once. `included_in_stat` always
// names a stat_consumption, so the hierarchy is keyed by that, while the
// rendered set holds node ids (stat_consumption or stat_rate, per `getId`).
const deviceByStat = new Map<string, DeviceConsumptionEnergyPreference>();
const deviceValues = new Map<string, number>();
const renderedIds = new Set<string>();
devices.forEach((device) => {
deviceByStat.set(device.stat_consumption, device);
const id = getId(device);
// Falsy check (not `=== undefined`) mirrors the cards' original `!stat_rate` guard.
if (!id) {
return;
}
const value = getValue(id);
deviceValues.set(id, value);
if (value >= minThreshold) {
renderedIds.add(id);
}
});
/** Node id of a device rendered as its own node, else undefined. */
const renderedId = (statConsumption: string): string | undefined => {
const device = deviceByStat.get(statConsumption);
if (!device) {
return undefined;
}
const id = getId(device);
return id && renderedIds.has(id) ? id : undefined;
};
// Walk up the included_in_stat chain to the first ancestor that is rendered.
// Bounded because a hand-edited config can make included_in_stat cyclic.
const findEffectiveParent = (
includedInStat: string | undefined
): string | undefined => {
let current = includedInStat;
for (let hops = 0; current && hops < devices.length; hops++) {
const rendered = renderedId(current);
if (rendered) {
return rendered;
}
const device = deviceByStat.get(current);
if (!device) {
return undefined;
}
current = device.included_in_stat;
}
return undefined;
};
devices.forEach((device, idx) => {
const id = getId(device);
if (!id) {
return;
}
const value = deviceValues.get(id)!;
const effectiveParent = findEffectiveParent(device.included_in_stat);
if (value < minThreshold) {
@@ -115,11 +162,13 @@ export const buildSankeyDeviceNodes = (
}
smallConsumersByParent.get(parentKey)!.push({
id,
includedInStat: device.included_in_stat,
name: device.name,
value,
effectiveParent,
idx,
});
smallConsumerStats.add(device.stat_consumption);
return;
}
@@ -142,7 +191,26 @@ export const buildSankeyDeviceNodes = (
});
// Process small consumers - show a lone one directly, group clusters as "Other"
smallConsumersByParent.forEach((consumers, parentKey) => {
smallConsumersByParent.forEach((allConsumers, parentKey) => {
// A small consumer whose included_in_stat chain reaches another small
// consumer is already counted inside that ancestor's value - drop it so
// totals don't double-count nested devices. A rendered ancestor ends the
// walk: the consumer links to it and never touches untracked, so it can't
// be double-counted through anything further up.
const consumers = allConsumers.filter((consumer) => {
let ancestor = consumer.includedInStat;
for (let hops = 0; ancestor && hops < devices.length; hops++) {
if (renderedId(ancestor)) {
return true;
}
if (smallConsumerStats.has(ancestor)) {
return false;
}
ancestor = deviceByStat.get(ancestor)?.included_in_stat;
}
return true;
});
const totalValue = consumers.reduce((sum, c) => sum + c.value, 0);
if (totalValue <= 0) {
return;
@@ -155,15 +155,15 @@ class HuiEnergyDistrubutionCard
const prefs = this._data.prefs;
const types = energySourcesByType(prefs);
const hasGrid =
!!types.grid?.[0] &&
(!!types.grid[0].stat_energy_from || !!types.grid[0].stat_energy_to);
const hasGrid = types.grid?.some(
(g) => g.stat_energy_from || g.stat_energy_to
);
const hasSolarProduction = types.solar !== undefined;
const hasBattery = types.battery !== undefined;
const hasGas = types.gas !== undefined;
const hasWater = types.water !== undefined;
const hasReturnToGrid =
types.grid?.some((source) => !!source.stat_energy_to) ?? false;
types.grid?.some((source) => source.stat_energy_to) ?? false;
const { summedData, compareSummedData: _ } = getSummedData(this._data);
const { consumption, compareConsumption: __ } = computeConsumptionData(
@@ -237,7 +237,7 @@ class HuiEnergySankeyCard
}
// Add grid return if available
if (types.grid && types.grid[0].stat_energy_to) {
if (types.grid && types.grid.some((g) => g.stat_energy_to)) {
const totalToGrid = summedData.total.to_grid ?? 0;
nodes.push({
@@ -272,35 +272,6 @@ class HuiEnergySankeyCard
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
: 0;
// Set of device stats that will be rendered as their own node
const renderedStats = new Set<string>();
prefs.device_consumption.forEach((device) => {
if (deviceValue(device.stat_consumption) >= minEnergyThreshold) {
renderedStats.add(device.stat_consumption);
}
});
// Walk up the included_in_stat chain to the first ancestor that is rendered
const deviceMap = new Map<string, string | undefined>();
prefs.device_consumption.forEach((device) => {
deviceMap.set(device.stat_consumption, device.included_in_stat);
});
const findEffectiveParent = (
includedInStat: string | undefined
): string | undefined => {
let currentParent = includedInStat;
while (currentParent) {
if (renderedStats.has(currentParent)) {
return currentParent;
}
if (!deviceMap.has(currentParent)) {
return undefined;
}
currentParent = deviceMap.get(currentParent);
}
return undefined;
};
const deviceLabel = (statConsumption: string, name?: string) =>
name ||
getStatisticLabel(
@@ -327,7 +298,6 @@ class HuiEnergySankeyCard
getValue: deviceValue,
getLabel: deviceLabel,
getEntityId: (id) => (isExternalStatistic(id) ? undefined : id),
findEffectiveParent,
});
links.push(...deviceLinks);
@@ -278,54 +278,6 @@ class HuiPowerSankeyCard
}
}
// Build a map of device relationships for hierarchy resolution
// Key: stat_consumption (energy), Value: { stat_rate, included_in_stat }
const deviceMap = new Map<
string,
{ stat_rate?: string; included_in_stat?: string }
>();
prefs.device_consumption.forEach((device) => {
deviceMap.set(device.stat_consumption, {
stat_rate: device.stat_rate,
included_in_stat: device.included_in_stat,
});
});
// Set of stat_rate entities that will be rendered as nodes
const renderedStatRates = new Set<string>();
prefs.device_consumption.forEach((device) => {
if (device.stat_rate) {
const value = this._getCurrentPower(device.stat_rate);
if (value >= minPowerThreshold) {
renderedStatRates.add(device.stat_rate);
}
}
});
// Find the effective parent for power hierarchy
// Walks up the chain to find an ancestor with stat_rate that will be rendered
const findEffectiveParent = (
includedInStat: string | undefined
): string | undefined => {
let currentParent = includedInStat;
while (currentParent) {
const parentDevice = deviceMap.get(currentParent);
if (!parentDevice) {
return undefined;
}
// If this parent has a stat_rate and will be rendered, use it
if (
parentDevice.stat_rate &&
renderedStatRates.has(parentDevice.stat_rate)
) {
return parentDevice.stat_rate;
}
// Otherwise, continue up the chain
currentParent = parentDevice.included_in_stat;
}
return undefined;
};
const {
deviceNodes,
parentLinks,
@@ -344,7 +296,6 @@ class HuiPowerSankeyCard
getValue: (id) => this._getCurrentPower(id),
getLabel: (id, name) => name || this._getEntityLabel(id),
getEntityId: (id) => id,
findEffectiveParent,
});
links.push(...deviceLinks);
@@ -241,48 +241,6 @@ class HuiWaterFlowSankeyCard
}
}
// Build a map of device relationships for hierarchy resolution
const deviceMap = new Map<
string,
{ stat_rate?: string; included_in_stat?: string }
>();
prefs.device_consumption_water.forEach((device) => {
deviceMap.set(device.stat_consumption, {
stat_rate: device.stat_rate,
included_in_stat: device.included_in_stat,
});
});
// Set of stat_rate entities that will be rendered as nodes
const renderedStatRates = new Set<string>();
prefs.device_consumption_water.forEach((device) => {
if (device.stat_rate) {
const value = this._getCurrentFlowRate(device.stat_rate);
if (value >= minFlowThreshold) {
renderedStatRates.add(device.stat_rate);
}
}
});
// Find the effective parent for hierarchy
const findEffectiveParent = (
includedInStat: string | undefined
): string | undefined => {
let currentParent = includedInStat;
while (currentParent) {
const parentDevice = deviceMap.get(currentParent);
if (!parentDevice) return undefined;
if (
parentDevice.stat_rate &&
renderedStatRates.has(parentDevice.stat_rate)
) {
return parentDevice.stat_rate;
}
currentParent = parentDevice.included_in_stat;
}
return undefined;
};
const {
deviceNodes,
parentLinks,
@@ -301,7 +259,6 @@ class HuiWaterFlowSankeyCard
getValue: (id) => this._getCurrentFlowRate(id),
getLabel: (id, name) => name || this._getEntityLabel(id),
getEntityId: (id) => id,
findEffectiveParent,
});
links.push(...deviceLinks);
@@ -215,35 +215,6 @@ class HuiWaterSankeyCard
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
: 0;
// Set of device stats that will be rendered as their own node
const renderedStats = new Set<string>();
prefs.device_consumption_water.forEach((device) => {
if (deviceValue(device.stat_consumption) >= minWaterThreshold) {
renderedStats.add(device.stat_consumption);
}
});
// Walk up the included_in_stat chain to the first ancestor that is rendered
const deviceMap = new Map<string, string | undefined>();
prefs.device_consumption_water.forEach((device) => {
deviceMap.set(device.stat_consumption, device.included_in_stat);
});
const findEffectiveParent = (
includedInStat: string | undefined
): string | undefined => {
let currentParent = includedInStat;
while (currentParent) {
if (renderedStats.has(currentParent)) {
return currentParent;
}
if (!deviceMap.has(currentParent)) {
return undefined;
}
currentParent = deviceMap.get(currentParent);
}
return undefined;
};
const deviceLabel = (statConsumption: string, name?: string) =>
name ||
getStatisticLabel(
@@ -270,7 +241,6 @@ class HuiWaterSankeyCard
getValue: deviceValue,
getLabel: deviceLabel,
getEntityId: (id) => (isExternalStatistic(id) ? undefined : id),
findEffectiveParent,
});
links.push(...deviceLinks);
@@ -1,19 +1,27 @@
import { mdiSort, mdiStar, mdiStarOutline } from "@mdi/js";
import type { IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import { until } from "lit/directives/until";
import memoizeOne from "memoize-one";
import { storage } from "../../../../common/decorators/storage";
import { PreserveScrollPositionController } from "../../../../common/controllers/preserve-scroll-position-controller";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { stringCompare } from "../../../../common/string/compare";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-ripple";
import "../../../../components/ha-spinner";
import "../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../components/input/ha-input-search";
import { UNAVAILABLE, UNKNOWN } from "../../../../data/entity/entity";
import { saveFrontendUserData } from "../../../../data/frontend";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { CustomCardEntry } from "../../../../data/lovelace_custom_cards";
@@ -24,6 +32,7 @@ import {
} from "../../../../data/lovelace_custom_cards";
import { haStyleScrollbar } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
import {
calcUnusedEntities,
computeUsedEntities,
@@ -33,12 +42,18 @@ import type { LovelaceCard } from "../../types";
import { getCardStubConfig } from "../get-card-stub-config";
import { coreCards, energyCards } from "../lovelace-cards";
import type { Card, CardPickTarget } from "../types";
import { showReorderFavoriteCardsDialog } from "./show-reorder-favorite-cards-dialog";
interface CardElement {
card: Card;
element: TemplateResult;
}
const cardKey = (card: Card): string =>
card.isCustom ? `${CUSTOM_TYPE_PREFIX}${card.type}` : card.type;
const SPINNER = html`<div class="spinner"><ha-spinner></ha-spinner></div>`;
@customElement("hui-card-picker")
export class HuiCardPicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@@ -56,6 +71,8 @@ export class HuiCardPicker extends LitElement {
@state() private _cards: CardElement[] = [];
@state() private _favorites: string[] = [];
public lovelace?: LovelaceConfig;
public cardPicked?: (cardConf: LovelaceCardConfig) => void;
@@ -64,10 +81,23 @@ export class HuiCardPicker extends LitElement {
@query("ha-input-search") private _searchInput?: HaInputSearch;
@query("#content") private _content?: HTMLElement;
private _unusedEntities?: string[];
private _usedEntities?: string[];
private _suggestedCards: CardElement[] = [];
private _favoriteElements = new Map<string, CardElement>();
private _topSection = createRef<HTMLElement>();
public preserveScrollPosition = new PreserveScrollPositionController(
this,
this._topSection
);
public async focus(): Promise<void> {
await this.updateComplete;
// Wait for the input's inner wa-input to render so focus delegation works.
@@ -99,18 +129,10 @@ export class HuiCardPicker extends LitElement {
}
);
private _suggestedCards = memoizeOne(
(cardElements: CardElement[]): CardElement[] =>
cardElements.filter(
(cardElement: CardElement) => cardElement.card.isSuggested
)
);
private _customCards = memoizeOne(
(cardElements: CardElement[]): CardElement[] =>
cardElements.filter(
(cardElement: CardElement) =>
cardElement.card.isCustom && !cardElement.card.isSuggested
(cardElement: CardElement) => cardElement.card.isCustom
)
);
@@ -118,9 +140,7 @@ export class HuiCardPicker extends LitElement {
(cardElements: CardElement[]): CardElement[] =>
cardElements.filter(
(cardElement: CardElement) =>
!cardElement.card.isSuggested &&
!cardElement.card.isCustom &&
!cardElement.card.isEnergy
!cardElement.card.isCustom && !cardElement.card.isEnergy
)
);
@@ -131,6 +151,34 @@ export class HuiCardPicker extends LitElement {
)
);
private _favoriteCards(): CardElement[] {
return this._favorites
.map((key) => this._favoriteElement(key))
.filter((cardElement): cardElement is CardElement => !!cardElement);
}
// A preview is a live DOM node, so every section a card shows up in needs
// its own element.
private _toCardElement(card: Card): CardElement {
return {
card,
element: html`${until(this._renderCardElement(card), SPINNER)}`,
};
}
private _favoriteElement(key: string): CardElement | undefined {
let cardElement = this._favoriteElements.get(key);
if (!cardElement) {
const card = this._cards.find((item) => cardKey(item.card) === key)?.card;
if (!card) {
return undefined;
}
cardElement = this._toCardElement(card);
this._favoriteElements.set(key, cardElement);
}
return cardElement;
}
protected render() {
if (
!this.hass ||
@@ -141,7 +189,8 @@ export class HuiCardPicker extends LitElement {
return nothing;
}
const suggestedCards = this._suggestedCards(this._cards);
const favoriteCards = this._favoriteCards();
const suggestedCards = favoriteCards.length > 0 ? [] : this._suggestedCards;
const othersCards = this._otherCards(this._cards);
const energyCardsItems = this._energyCards(this._cards);
const customCardsItems = this._customCards(this._cards);
@@ -160,53 +209,106 @@ export class HuiCardPicker extends LitElement {
this._filter
? html`<div class="cards-container">
${this._filterCards(this._cards, this._filter).map(
(cardElement: CardElement) => cardElement.element
(cardElement: CardElement) => this._renderCard(cardElement)
)}
</div>`
: html`
${
suggestedCards.length > 0
? html` <ha-expansion-panel expanded>
<div slot="header" class="cards-container-header">
${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.suggested_cards`
)}
</div>
<div class="cards-container">
${this._renderClipboardCard()}
${suggestedCards.map(
(cardElement: CardElement) => cardElement.element
)}
</div>
</ha-expansion-panel>`
: nothing
}
<div ${ref(this._topSection)}>
${
favoriteCards.length > 0
? html`<ha-expansion-panel expanded>
<div slot="header" class="cards-container-header">
<span class="title"
>${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.favorite_cards`
)}</span
>
${
favoriteCards.length > 1
? html`<ha-icon-button
class="reorder-favorites"
.path=${mdiSort}
.label=${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.reorder_favorites`
)}
@click=${this._reorderFavorites}
></ha-icon-button>`
: nothing
}
</div>
<div class="cards-container">
${this._renderClipboardCard(this._clipboard, this.hass!.locale)}
${repeat(
favoriteCards,
(cardElement: CardElement) =>
cardKey(cardElement.card),
(cardElement: CardElement) =>
this._renderCard(cardElement)
)}
</div>
</ha-expansion-panel>`
: nothing
}
${
suggestedCards.length > 0
? html`<ha-expansion-panel expanded>
<div slot="header" class="cards-container-header">
<span class="title"
>${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.suggested_cards`
)}</span
>
</div>
<div class="cards-container">
${
favoriteCards.length === 0
? this._renderClipboardCard(
this._clipboard,
this.hass!.locale
)
: nothing
}
${suggestedCards.map((cardElement: CardElement) =>
this._renderCard(cardElement)
)}
</div>
</ha-expansion-panel>`
: nothing
}
</div>
<ha-expansion-panel expanded>
<div slot="header" class="cards-container-header">
${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.core_cards`
)}
<span class="title"
>${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.core_cards`
)}</span
>
</div>
<div class="cards-container">
${
suggestedCards.length === 0
? this._renderClipboardCard()
favoriteCards.length === 0 && suggestedCards.length === 0
? this._renderClipboardCard(
this._clipboard,
this.hass!.locale
)
: nothing
}
${othersCards.map(
(cardElement: CardElement) => cardElement.element
${othersCards.map((cardElement: CardElement) =>
this._renderCard(cardElement)
)}
</div>
</ha-expansion-panel>
<ha-expansion-panel>
<div slot="header" class="cards-container-header">
${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.energy_cards`
)}
<span class="title"
>${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.energy_cards`
)}</span
>
</div>
<div class="cards-container">
${energyCardsItems.map(
(cardElement: CardElement) => cardElement.element
${energyCardsItems.map((cardElement: CardElement) =>
this._renderCard(cardElement)
)}
</div>
</ha-expansion-panel>
@@ -215,13 +317,15 @@ export class HuiCardPicker extends LitElement {
? html`
<ha-expansion-panel expanded>
<div slot="header" class="cards-container-header">
${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.custom_cards`
)}
<span class="title"
>${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.custom_cards`
)}</span
>
</div>
<div class="cards-container">
${customCardsItems.map(
(cardElement: CardElement) => cardElement.element
${customCardsItems.map((cardElement: CardElement) =>
this._renderCard(cardElement)
)}
</div>
</ha-expansion-panel>
@@ -254,16 +358,12 @@ export class HuiCardPicker extends LitElement {
}
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
if (changedProps.size > 1 || !changedProps.has("hass")) {
return true;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass) {
return true;
}
if (oldHass.locale !== this.hass!.locale) {
return true;
}
return false;
return !oldHass || oldHass.locale !== this.hass!.locale;
}
protected firstUpdated(): void {
@@ -283,16 +383,15 @@ export class HuiCardPicker extends LitElement {
this._usedEntities = [...usedEntities].filter(isAvailable);
this._unusedEntities = [...unusedEntities].filter(isAvailable);
this._favorites = this.hass.userData?.dashboard_favorite_card_types ?? [];
this._loadCards();
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("_filter")) {
const div = this.shadowRoot!.getElementById("content");
if (div) {
div.scrollTo({ behavior: "auto", top: 0 });
}
this._content?.scrollTo({ behavior: "auto", top: 0 });
}
}
@@ -308,19 +407,9 @@ export class HuiCardPicker extends LitElement {
...card,
}));
cards = cards.sort((a, b) => {
if (a.isSuggested && !b.isSuggested) {
return -1;
}
if (!a.isSuggested && b.isSuggested) {
return 1;
}
return stringCompare(
a.name || a.type,
b.name || b.type,
this.hass?.language
);
});
cards = cards.sort((a, b) =>
stringCompare(a.name || a.type, b.name || b.type, this.hass?.language)
);
cards = cards.concat(
energyCards.map((card: Card) => ({
@@ -354,48 +443,126 @@ export class HuiCardPicker extends LitElement {
)
);
}
this._cards = cards.map((card: Card) => ({
card: card,
element: html`${until(
this._renderCardElement(card),
html`
<div class="card spinner">
<ha-spinner></ha-spinner>
</div>
`
)}`,
}));
this._cards = cards.map((card: Card) => this._toCardElement(card));
this._suggestedCards = cards
.filter((card: Card) => card.isSuggested)
.map((card: Card) => this._toCardElement(card));
}
private _renderClipboardCard() {
if (!this._clipboard) {
return nothing;
}
private _renderClipboardCard = memoizeOne(
(clipboard: LovelaceCardConfig | undefined, _locale: unknown) => {
if (!clipboard) {
return nothing;
}
return html` ${until(
this._renderCardElement(
{
type: this._clipboard.type,
showElement: true,
isCustom: false,
name: this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.paste"
),
description: `${this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.paste_description",
return html`<div class="card" tabindex="0">
${until(
this._renderCardElement(
{
type: this._clipboard.type,
}
)}`,
},
this._clipboard
type: clipboard.type,
showElement: true,
isCustom: false,
name: this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.paste"
),
description: `${this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.paste_description",
{
type: clipboard.type,
}
)}`,
},
clipboard
),
SPINNER
)}
<ha-ripple></ha-ripple>
</div>`;
}
);
private _renderCard(cardElement: CardElement): TemplateResult {
const key = cardKey(cardElement.card);
const favorite = this._favorites.includes(key);
return html`
<div class="card" tabindex="0">
${cardElement.element}
<ha-icon-button
class="favorite ${classMap({ selected: favorite })}"
.path=${favorite ? mdiStar : mdiStarOutline}
.label=${this.hass!.localize(
favorite
? "ui.panel.lovelace.editor.card.generic.remove_favorite"
: "ui.panel.lovelace.editor.card.generic.add_favorite"
)}
data-card=${key}
@click=${this._toggleFavorite}
@pointerdown=${stopPropagation}
></ha-icon-button>
<ha-ripple></ha-ripple>
</div>
`;
}
private _setFavorite(key: string, value: boolean): void {
const favorites = this._favorites.filter((favorite) => favorite !== key);
this._favorites = value ? [...favorites, key] : favorites;
}
private async _toggleFavorite(ev: Event): Promise<void> {
ev.stopPropagation();
const key = (ev.currentTarget as HTMLElement).dataset.card!;
const adding = !this._favorites.includes(key);
this._setFavorite(key, adding);
try {
await this._persistFavorites();
} catch (_err: any) {
this._setFavorite(key, !adding);
this._showSaveError();
}
}
private _reorderFavorites(ev: Event): void {
ev.preventDefault();
ev.stopPropagation();
showReorderFavoriteCardsDialog(this, {
favorites: this._favoriteCards().map((cardElement) => ({
key: cardKey(cardElement.card),
name: cardElement.card.name || cardElement.card.type,
})),
saveFavorites: (favorites) => this._saveFavorites(favorites),
});
}
private async _saveFavorites(favorites: string[]): Promise<void> {
const previous = this._favorites;
this._favorites = favorites;
try {
await this._persistFavorites();
} catch (_err: any) {
this._favorites = previous;
this._showSaveError();
}
}
private _persistFavorites(): Promise<void> {
return saveFrontendUserData(this.hass!.connection, "core", {
...this.hass!.userData,
dashboard_favorite_card_types: this._favorites,
});
}
private _showSaveError(): void {
showToast(this, {
message: this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.favorite_save_failed"
),
html`
<div class="card spinner">
<ha-spinner></ha-spinner>
</div>
`
)}`;
});
}
private _handleSearchChange(ev: Event) {
@@ -477,32 +644,29 @@ export class HuiCardPicker extends LitElement {
}
return html`
<div class="card" tabindex="0">
<div
class="overlay"
@click=${this._cardPicked}
.config=${cardConfig}
></div>
<div class="card-header">
${customCard ? customCard.name || customCard.type : name}
</div>
<div
class="preview ${classMap({
description: !element || element.tagName === "HUI-ERROR-CARD",
})}"
>
${
element && element.tagName !== "HUI-ERROR-CARD"
? element
: customCard
? customCard.description ||
this.hass!.localize(
`ui.panel.lovelace.editor.cardpicker.no_description`
)
: description
}
</div>
<ha-ripple></ha-ripple>
<div
class="overlay"
@click=${this._cardPicked}
.config=${cardConfig}
></div>
<div class="card-header">
${customCard ? customCard.name || customCard.type : name}
</div>
<div
class="preview ${classMap({
description: !element || element.tagName === "HUI-ERROR-CARD",
})}"
>
${
element && element.tagName !== "HUI-ERROR-CARD"
? element
: customCard
? customCard.description ||
this.hass!.localize(
`ui.panel.lovelace.editor.cardpicker.no_description`
)
: description
}
</div>
`;
}
@@ -531,6 +695,10 @@ export class HuiCardPicker extends LitElement {
}
.cards-container-header {
display: flex;
align-items: center;
gap: var(--ha-space-2);
min-width: 0;
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
padding: var(--ha-space-3) var(--ha-space-2);
@@ -578,11 +746,55 @@ export class HuiCardPicker extends LitElement {
font-weight: var(--ha-font-weight-bold);
letter-spacing: -0.012em;
line-height: var(--ha-line-height-condensed);
padding: var(--ha-space-3) var(--ha-space-4);
padding: var(--ha-space-3) var(--ha-space-11);
display: block;
text-align: center;
}
.cards-container-header .title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reorder-favorites {
flex: none;
margin-inline-start: auto;
/* Keeps the hit area without growing the header row */
margin-block: calc(-1 * var(--ha-space-1));
--ha-icon-button-size: var(--ha-space-8);
--mdc-icon-size: var(--ha-space-5);
color: var(--secondary-text-color);
}
.favorite {
position: absolute;
top: var(--ha-space-1);
inset-inline-end: var(--ha-space-1);
z-index: 2;
color: var(--secondary-text-color);
opacity: 0;
transition: opacity var(--ha-animation-duration-fast) ease-in-out;
--ha-icon-button-size: var(--ha-space-10);
}
.card:hover .favorite,
.card:focus-within .favorite,
.favorite.selected {
opacity: 1;
}
.favorite.selected {
color: var(--primary-color);
}
@media (hover: none) {
.favorite {
opacity: 1;
}
}
.preview {
pointer-events: none;
margin: var(--ha-space-5);
@@ -602,6 +814,8 @@ export class HuiCardPicker extends LitElement {
}
.spinner {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
@@ -622,23 +836,6 @@ export class HuiCardPicker extends LitElement {
max-width: none;
grid-column: 1 / -1;
}
.icon {
position: absolute;
top: var(--ha-space-2);
right: var(--ha-space-2);
inset-inline-start: var(--ha-space-2);
inset-inline-end: var(--ha-space-2);
border-radius: var(--ha-border-radius-circle);
--mdc-icon-size: var(--ha-space-4);
line-height: var(--ha-space-4);
box-sizing: border-box;
color: var(--text-primary-color);
padding: var(--ha-space-1);
}
.icon.custom {
background: var(--warning-color);
}
`,
];
}
@@ -0,0 +1,136 @@
import { mdiDragHorizontalVariant } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-sortable";
import "../../../../components/ha-svg-icon";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type {
FavoriteCardItem,
ReorderFavoriteCardsDialogParams,
} from "./show-reorder-favorite-cards-dialog";
@customElement("hui-dialog-reorder-favorite-cards")
export class HuiDialogReorderFavoriteCards extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: ReorderFavoriteCardsDialogParams;
@state() private _open = false;
@state() private _favorites: FavoriteCardItem[] = [];
public async showDialog(
params: ReorderFavoriteCardsDialogParams
): Promise<void> {
this._params = params;
this._favorites = params.favorites;
this._open = true;
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._open = false;
this._params = undefined;
this._favorites = [];
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-dialog
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.lovelace.editor.card.generic.reorder_favorites"
)}
@closed=${this._dialogClosed}
>
<ha-sortable handle-selector=".handle" @item-moved=${this._moved}>
<div class="favorites">
${repeat(
this._favorites,
(favorite) => favorite.key,
(favorite) => html`
<div class="favorite">
<div class="handle">
<ha-svg-icon
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</div>
<span class="name">${favorite.name}</span>
</div>
`
)}
</div>
</ha-sortable>
<ha-dialog-footer slot="footer">
<ha-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.close")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _moved(ev: HASSDomEvent<HASSDomEvents["item-moved"]>): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const favorites = [...this._favorites];
const [moved] = favorites.splice(oldIndex, 1);
favorites.splice(newIndex, 0, moved);
this._favorites = favorites;
this._params!.saveFavorites(favorites.map((favorite) => favorite.key));
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
.favorites {
display: flex;
flex-direction: column;
}
.favorite {
display: flex;
align-items: center;
gap: var(--ha-space-2);
min-height: var(--ha-space-12);
}
.handle {
display: flex;
align-items: center;
cursor: grab;
padding: var(--ha-space-2);
color: var(--secondary-text-color);
}
.name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-dialog-reorder-favorite-cards": HuiDialogReorderFavoriteCards;
}
}
@@ -0,0 +1,25 @@
import { fireEvent } from "../../../../common/dom/fire_event";
export interface FavoriteCardItem {
key: string;
name: string;
}
export interface ReorderFavoriteCardsDialogParams {
favorites: FavoriteCardItem[];
saveFavorites: (favorites: string[]) => void;
}
export const importReorderFavoriteCardsDialog = () =>
import("./hui-dialog-reorder-favorite-cards");
export const showReorderFavoriteCardsDialog = (
element: HTMLElement,
reorderFavoriteCardsDialogParams: ReorderFavoriteCardsDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "hui-dialog-reorder-favorite-cards",
dialogImport: importReorderFavoriteCardsDialog,
dialogParams: reorderFavoriteCardsDialogParams,
});
};
@@ -2,6 +2,7 @@ import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { getAreasFloorHierarchy } from "../../../../common/areas/areas-floor-hierarchy";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
import {
findEntities,
generateEntityFilter,
@@ -107,7 +108,24 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
generateEntityFilter(hass, filter)
);
const entitiesWithoutAreas = findEntities(allEntities, otherDevicesFilters);
const primaryFilter = generateEntityFilter(hass, {
entity_category: "none",
});
// Only show the devices tile if the other devices view has content: it
// only renders area-less primary entities that belong to a device.
const hasOtherDevices = allEntities.some(
(entityId) =>
otherDevicesFilters.some((filter) => filter(entityId)) &&
primaryFilter(entityId) &&
!!getEntityContext(
hass.states[entityId],
hass.entities,
hass.devices,
hass.areas,
hass.floors
).device
);
const floorsSections: LovelaceSectionConfig[] = [];
for (const floorStructure of home.floors) {
@@ -140,13 +158,13 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
}
}
if (home.areas.length > 0 || entitiesWithoutAreas.length > 0) {
if (home.areas.length > 0 || hasOtherDevices) {
const cards: LovelaceCardConfig[] = [];
for (const areaId of home.areas) {
cards.push(computeAreaCard(areaId, hass));
}
if (entitiesWithoutAreas.length > 0) {
if (hasOtherDevices) {
cards.push({
type: "tile",
entity: "zone.home", // zone entity to represent unassigned area as it always exists
+29 -7
View File
@@ -74,6 +74,18 @@
}
},
"ui": {
"init": {
"loading": "Loading data",
"migration": "Database upgrade is in progress, Home Assistant will not start until the upgrade is completed.\n\nThe upgrade may need a long time to complete, please be patient.",
"project_from": "A project from the",
"error": {
"title": "Unable to connect to Home Assistant.",
"retrying": "Retrying in {seconds, plural, one {# second} other {# seconds}}...",
"retry_now": "Retry now",
"nabu_casa": "It is possible that you are seeing this screen because your Home Assistant is not currently connected. You can ask it to come online from your {account_link}.",
"nabu_casa_account": "Nabu Casa account page"
}
},
"backup": {
"upload_backup": "Upload backup"
},
@@ -797,13 +809,6 @@
"target_details": "Target details",
"no_targets": "No targets",
"no_target_found": "No target found for {term}",
"selected": {
"entity": "Entities: {count}",
"device": "Devices: {count}",
"area": "Areas: {count}",
"label": "Labels: {count}",
"floor": "Floors: {count}"
},
"type": {
"area": "Area",
"areas": "Areas",
@@ -1193,6 +1198,11 @@
"play": "Play",
"play-media": "Play media",
"pick-media": "Pick media",
"search": {
"search_placeholder": "Search {name}",
"no_results": "No results found"
},
"filter_media_type": "Filter by media type",
"no_items": "No items",
"not_shown": "{count} {count, plural,\n one {item}\n other {items}\n} incompatible with the currently selected player {count, plural,\n one {is}\n other {are}\n} hidden",
"choose_player": "Choose player",
@@ -5179,6 +5189,13 @@
"area": "No areas or floors found for {term}",
"label": "No labels found for {term}"
},
"show_more_search": {
"trigger": "{count} more triggers...",
"condition": "{count} more conditions...",
"action": "{count} more actions...",
"entity": "{count} more entities...",
"device": "{count} more devices..."
},
"load_target_items_failed": "Failed to load target items for",
"other_areas": "Other areas",
"services": "Services",
@@ -10058,6 +10075,11 @@
"core_cards": "Core cards",
"energy_cards": "Energy cards",
"custom_cards": "Community cards",
"favorite_cards": "Favorite cards",
"add_favorite": "Add to favorites",
"remove_favorite": "Remove from favorites",
"reorder_favorites": "Reorder favorites",
"favorite_save_failed": "Failed to save favorite cards",
"round_temperature": "Round temperature",
"features": "Features",
"actions": "Actions",
+23 -9
View File
@@ -26,28 +26,42 @@ export const removeLaunchScreen = (instant = false): boolean => {
return true;
}
withViewTransition((viewTransitionAvailable: boolean) => {
if (!viewTransitionAvailable) {
withViewTransition((viewTransitionAvailable) => {
if (viewTransitionAvailable) {
launchScreenElement.parentElement?.removeChild(launchScreenElement);
return;
}
launchScreenElement.classList.add("removing");
const durationFromCss = getComputedStyle(document.documentElement)
.getPropertyValue("--ha-animation-duration-slow")
.getPropertyValue("--ha-animation-duration-normal")
.trim();
setTimeout(() => {
launchScreenElement.parentElement?.removeChild(launchScreenElement);
}, parseAnimationDuration(durationFromCss));
setTimeout(
() => {
launchScreenElement.parentElement?.removeChild(launchScreenElement);
},
parseAnimationDuration(durationFromCss || "250ms")
);
});
return true;
};
export const renderLaunchScreenInfoBox = (content: TemplateResult) => {
export const renderLaunchScreenContent = (
content: TemplateResult,
attribution: string
) => {
const infoBoxElement = document.getElementById("ha-launch-screen-info-box");
if (infoBoxElement) {
render(content, infoBoxElement);
}
updateLaunchScreenAttribution(attribution);
};
export const updateLaunchScreenAttribution = (attribution: string) => {
const attributionElement = document.getElementById(
"ha-launch-screen-attribution"
);
if (attributionElement) {
attributionElement.textContent = attribution;
}
};
+53 -1
View File
@@ -10,6 +10,8 @@
import { readFileSync } from "fs";
const REPORT_PATH = "test/e2e/reports/combined/results.json";
const COMMENT_MARKER = "<!-- playwright-e2e-report -->";
const COMMENT_HEADING = "## Playwright E2E tests failed";
// GitHub comment bodies cap at 65536 chars; leave headroom.
const MAX_BODY = 60000;
@@ -76,6 +78,19 @@ export default async function postReportComment({ github, context, core }) {
const { owner, repo } = context.repo;
const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: context.issue.number,
per_page: 100,
});
const previousComments = comments.filter(
(comment) =>
comment.user?.login === "github-actions[bot]" &&
(comment.body?.includes(COMMENT_MARKER) ||
comment.body?.startsWith(COMMENT_HEADING))
);
let stats = { expected: 0, unexpected: 0, flaky: 0, skipped: 0 };
let failures = [];
@@ -97,7 +112,8 @@ export default async function postReportComment({ github, context, core }) {
: "_No failing tests were captured in the report._";
let body = [
"## Playwright E2E tests failed",
COMMENT_MARKER,
COMMENT_HEADING,
"",
summaryLine,
"",
@@ -118,4 +134,40 @@ export default async function postReportComment({ github, context, core }) {
issue_number: context.issue.number,
body,
});
const batches = Array.from(
{ length: Math.ceil(previousComments.length / 100) },
(_, index) => previousComments.slice(index * 100, (index + 1) * 100)
);
const commentsToMinimize = (
await Promise.all(
batches.map(async (batch) => {
const { nodes } = await github.graphql(
`query($ids: [ID!]!) {
nodes(ids: $ids) {
... on IssueComment {
id
isMinimized
}
}
}`,
{ ids: batch.map((comment) => comment.node_id) }
);
return nodes.filter((node) => !node.isMinimized);
})
)
).flat();
await Promise.all(
commentsToMinimize.map((comment) =>
github.graphql(
`mutation($id: ID!) {
minimizeComment(input: { subjectId: $id, classifier: OUTDATED }) {
clientMutationId
}
}`,
{ id: comment.id }
)
)
);
}
+90
View File
@@ -0,0 +1,90 @@
import { LitElement } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { LocalizeFunc } from "../../src/common/translations/localize";
import type { HaInitPage } from "../../src/layouts/ha-init-page";
vi.mock("../../src/components/ha-button", () => ({}));
customElements.define("ha-button", class extends LitElement {});
await import("../../src/layouts/ha-init-page");
const translations: Record<string, string> = {
"ui.init.loading": "Loading translated data",
"ui.init.migration": "Database migration translated\n\nPlease wait",
"ui.init.error.title": "Connection error translated",
"ui.init.error.retry_now": "Retry translated",
};
const localize: LocalizeFunc = (key, values) => {
if (key === "ui.init.error.retrying") {
return `Retry translated ${values?.seconds}`;
}
return translations[key] ?? "";
};
let host: HTMLDivElement | undefined;
let element: HaInitPage | undefined;
const mount = async (properties: Partial<HaInitPage> = {}) => {
host = document.createElement("div");
document.body.append(host);
element = document.createElement("ha-init-page");
Object.assign(element, { localize, ...properties });
host.append(element);
await element.updateComplete;
return element;
};
afterEach(() => {
host?.remove();
host = undefined;
element = undefined;
vi.useRealTimers();
});
describe("ha-init-page", () => {
it("renders localized loading and migration states", async () => {
const initPage = await mount();
expect(initPage.shadowRoot!.textContent).toContain(
"Loading translated data"
);
initPage.migration = true;
await initPage.updateComplete;
expect(
initPage.shadowRoot!.querySelector(".migration-text")!.textContent
).toBe("Database migration translated\n\nPlease wait");
});
it("preserves migration paragraph breaks without localization", async () => {
const initPage = await mount({ localize: undefined, migration: true });
expect(
initPage.shadowRoot!.querySelector(".migration-text")!.textContent
).toContain("completed.\n\nThe upgrade");
});
it("renders the localized connection error and countdown", async () => {
const initPage = await mount({ error: true });
expect(initPage.shadowRoot!.textContent).toContain(
"Connection error translated"
);
expect(initPage.shadowRoot!.textContent).toContain("Retry translated 60");
expect(
initPage.shadowRoot!.querySelector("ha-button")!.textContent
).toContain("Retry translated");
});
it("counts down once per second and stops after disconnecting", async () => {
vi.useFakeTimers();
const initPage = await mount({ error: true });
await vi.advanceTimersByTimeAsync(1000);
await initPage.updateComplete;
expect(initPage.shadowRoot!.textContent).toContain("Retry translated 59");
initPage.remove();
await vi.advanceTimersByTimeAsync(1000);
expect(initPage.shadowRoot!.textContent).toContain("Retry translated 59");
});
});
@@ -46,7 +46,6 @@ const cumulativeOpts = (
getValue: (id) => values[id] ?? 0,
getLabel: (id, name) => name || id,
getEntityId: (id) => id,
findEffectiveParent: () => undefined,
...rest,
};
};
@@ -168,6 +167,56 @@ describe("buildSankeyDeviceNodes", () => {
expect(result.untrackedConsumption).toBe(0);
});
it("resolves the parent to its node id, not its stat_consumption", () => {
// Instantaneous cards key the hierarchy by stat_consumption but render
// stat_rate, so the effective parent must come back as the stat_rate.
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "p", stat_rate: "sensor.p" },
{
stat_consumption: "c",
stat_rate: "sensor.c",
included_in_stat: "p",
}
),
values: { "sensor.p": 100, "sensor.c": 40 },
minThreshold: 1,
initialUntracked: 100,
getId: (device) => device.stat_rate,
})
);
expect(result.parentLinks["sensor.c"]).toBe("sensor.p");
expect(result.links).toContainEqual({
source: "sensor.p",
target: "sensor.c",
});
expect(result.untrackedConsumption).toBe(0); // only the top-level parent
});
it("walks through an intermediate device that has no node id", () => {
// "middle" has no stat_rate, so the child must attach to the grandparent.
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "p", stat_rate: "sensor.p" },
{ stat_consumption: "middle", included_in_stat: "p" },
{
stat_consumption: "c",
stat_rate: "sensor.c",
included_in_stat: "middle",
}
),
values: { "sensor.p": 100, "sensor.c": 40 },
minThreshold: 1,
initialUntracked: 100,
getId: (device) => device.stat_rate,
})
);
expect(result.parentLinks["sensor.c"]).toBe("sensor.p");
expect(result.untrackedConsumption).toBe(0);
});
it("adds a per-parent untracked residual above the floor", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
@@ -177,7 +226,6 @@ describe("buildSankeyDeviceNodes", () => {
),
values: { parent: 10, child: 4 },
initialUntracked: 10,
findEffectiveParent: (includedInStat) => includedInStat,
})
);
expect(result.parentLinks.child).toBe("parent");
@@ -199,7 +247,6 @@ describe("buildSankeyDeviceNodes", () => {
values: { parent: 10, child: 9.5 },
untrackedFloor: 1,
initialUntracked: 10,
findEffectiveParent: (includedInStat) => includedInStat,
})
);
expect(result.deviceNodes.some((n) => n.id.startsWith("untracked_"))).toBe(
@@ -217,7 +264,6 @@ describe("buildSankeyDeviceNodes", () => {
),
values: { parent: 10, s1: 0.003, s2: 0.004 },
initialUntracked: 10,
findEffectiveParent: (includedInStat) => includedInStat,
})
);
const other = result.deviceNodes.find((n) => n.id === "other_parent");
@@ -231,6 +277,152 @@ describe("buildSankeyDeviceNodes", () => {
// the top-level parent (10), never the cluster total.
expect(result.untrackedConsumption).toBe(0);
});
it("counts a nested small device only once (via its small ancestor)", () => {
// child's consumption is already included in parent's statistic
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "parent" },
{ stat_consumption: "child", included_in_stat: "parent" }
),
values: { parent: 0.008, child: 0.006 },
initialUntracked: 10,
})
);
// Only the parent remains, so it is shown directly instead of "Other"
expect(result.deviceNodes.map((n) => n.id)).toEqual(["parent"]);
expect(result.deviceNodes[0].value).toBeCloseTo(0.008, 10);
expect(result.untrackedConsumption).toBeCloseTo(10 - 0.008, 10);
});
it("excludes nested small devices from the Other total", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "parent" },
{ stat_consumption: "child", included_in_stat: "parent" },
{ stat_consumption: "unrelated" }
),
values: { parent: 0.004, child: 0.003, unrelated: 0.002 },
initialUntracked: 10,
})
);
const other = result.deviceNodes.find((n) => n.id === "other_home");
// parent + unrelated only; child is inside parent's value
expect(other?.value).toBeCloseTo(0.006, 10);
expect(result.untrackedConsumption).toBeCloseTo(10 - 0.006, 10);
});
it("keeps only the top ancestor of a nested small-device chain", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "grandparent" },
{ stat_consumption: "parent", included_in_stat: "grandparent" },
{ stat_consumption: "child", included_in_stat: "parent" }
),
values: { grandparent: 0.005, parent: 0.004, child: 0.003 },
initialUntracked: 10,
})
);
expect(result.deviceNodes.map((n) => n.id)).toEqual(["grandparent"]);
expect(result.untrackedConsumption).toBeCloseTo(10 - 0.005, 10);
});
it("detects a small ancestor across a device the card skips entirely", () => {
// Power-card scenario: "outlet" has no stat_rate so it is never rendered
// or collected, but the plug's consumption still flows through it into
// the heater group. Requires walking the chain, not a direct-parent check.
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "heater_group" },
{ stat_consumption: "outlet", included_in_stat: "heater_group" },
{ stat_consumption: "plug", included_in_stat: "outlet" }
),
values: { heater_group: 0.008, plug: 0.005 },
initialUntracked: 10,
getId: (device) =>
device.stat_consumption === "outlet"
? undefined
: device.stat_consumption,
})
);
expect(result.deviceNodes.map((n) => n.id)).toEqual(["heater_group"]);
expect(result.untrackedConsumption).toBeCloseTo(10 - 0.008, 10);
});
it("excludes a nested small device from a rendered parent's cluster", () => {
// big(rendered) > b(small) > c(small): c is inside b's value, so only b
// may be attributed under big.
const devs = devices(
{ stat_consumption: "big" },
{ stat_consumption: "b", included_in_stat: "big" },
{ stat_consumption: "c", included_in_stat: "b" }
);
const values = { big: 10, b: 0.004, c: 0.003 };
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devs,
values,
initialUntracked: 10,
})
);
// b is the lone survivor, so it is shown directly instead of "Other"
expect(result.deviceNodes.map((n) => n.id)).toEqual([
"big",
"b",
"untracked_big",
]);
expect(result.parentLinks.b).toBe("big");
expect(
result.deviceNodes.find((n) => n.id === "untracked_big")?.value
).toBeCloseTo(10 - 0.004, 10);
expect(result.untrackedConsumption).toBe(0);
});
it("keeps a small device whose chain reaches a small ancestor past a rendered one", () => {
// g(small) > a(rendered) > c(small): c hangs off rendered a, so it never
// touches untracked and cannot be double-counted through g.
const devs = devices(
{ stat_consumption: "g" },
{ stat_consumption: "a", included_in_stat: "g" },
{ stat_consumption: "c", included_in_stat: "a" }
);
const values = { g: 0.0005, a: 0.5, c: 0.0004 };
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devs,
values,
initialUntracked: 1,
})
);
expect(result.deviceNodes.map((n) => n.id)).toEqual([
"a",
"g",
"c",
"untracked_a",
]);
expect(result.parentLinks.c).toBe("a");
// only the two top-level values (a and g) come off untracked
expect(result.untrackedConsumption).toBeCloseTo(1 - 0.5 - 0.0005, 10);
});
it("leaves a cyclic small-device cluster in untracked instead of looping", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "a", included_in_stat: "b" },
{ stat_consumption: "b", included_in_stat: "a" }
),
values: { a: 0.003, b: 0.004 },
initialUntracked: 10,
})
);
expect(result.deviceNodes).toEqual([]);
expect(result.untrackedConsumption).toBe(10);
});
});
describe("groupSankeyDevicesByFloorAndArea", () => {
+32 -32
View File
@@ -2660,12 +2660,12 @@ __metadata:
languageName: node
linkType: hard
"@eslint/config-helpers@npm:^0.6.0":
version: 0.6.0
resolution: "@eslint/config-helpers@npm:0.6.0"
"@eslint/config-helpers@npm:^0.7.0":
version: 0.7.0
resolution: "@eslint/config-helpers@npm:0.7.0"
dependencies:
"@eslint/core": "npm:^1.2.1"
checksum: 10/2daa66b0c3821313a6239beed2236ad7f3a45540050b5ce69527206ba7e58e9c17aff2f68be6d3f0f95d6294a911da86aa50863a1aeadd607faa943c11677a46
checksum: 10/57f9cdf303fd56f0da6712e7d6da350c18a332f7d1dc0f5a5f8a9c357f0d5ab9ff9e7515be4726ad4b0fc41c297570a118d7ede02246c5b7a37932c3df46271e
languageName: node
linkType: hard
@@ -4223,14 +4223,14 @@ __metadata:
languageName: node
linkType: hard
"@playwright/test@npm:1.61.1":
version: 1.61.1
resolution: "@playwright/test@npm:1.61.1"
"@playwright/test@npm:1.62.0":
version: 1.62.0
resolution: "@playwright/test@npm:1.62.0"
dependencies:
playwright: "npm:1.61.1"
playwright: "npm:1.62.0"
bin:
playwright: cli.js
checksum: 10/08915357031e1bc273a19bb428fb7e51031ca44f25750832e389bcd69c399f6a243f5f832e7832d46153b57b1305f1f5f5dd63f3a3261f928f61e464c6b3c64e
checksum: 10/90b504f3933b97687580d7e6ba42ce9e73a9cdd41da5ae59441104b3dfa1508142ed17e5742acc70fdc2aef79078365859c6a463cebf46a8f7db9506a3ece1d6
languageName: node
linkType: hard
@@ -8878,14 +8878,14 @@ __metadata:
languageName: node
linkType: hard
"eslint@npm:10.7.0":
version: 10.7.0
resolution: "eslint@npm:10.7.0"
"eslint@npm:10.8.0":
version: 10.8.0
resolution: "eslint@npm:10.8.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.8.0"
"@eslint-community/regexpp": "npm:^4.12.2"
"@eslint/config-array": "npm:^0.23.5"
"@eslint/config-helpers": "npm:^0.6.0"
"@eslint/config-helpers": "npm:^0.7.0"
"@eslint/core": "npm:^1.2.1"
"@eslint/plugin-kit": "npm:^0.7.2"
"@humanfs/node": "npm:^0.16.6"
@@ -8909,7 +8909,7 @@ __metadata:
imurmurhash: "npm:^0.1.4"
is-glob: "npm:^4.0.0"
json-stable-stringify-without-jsonify: "npm:^1.0.1"
minimatch: "npm:^10.2.4"
minimatch: "npm:^10.2.5"
natural-compare: "npm:^1.4.0"
optionator: "npm:^0.9.3"
peerDependencies:
@@ -8919,7 +8919,7 @@ __metadata:
optional: true
bin:
eslint: bin/eslint.js
checksum: 10/51cb70fbdd0de6586f0cb12625388faab18eb679cbdb523ecb631cae9f47fd9171d9ff02f570dc4d2e5700017908a81477511025ccb2a6603c5928fbfddcaebf
checksum: 10/6128174fffd198e2d10ac211fabdc7292e3214c62a5aede66d2397a0628cf4f704f85a2efb1d5733584a005a6edcf5ca70436894c8befe4ac9e374609c721687
languageName: node
linkType: hard
@@ -9952,7 +9952,7 @@ __metadata:
"@octokit/auth-oauth-device": "npm:8.0.3"
"@octokit/plugin-retry": "npm:8.1.0"
"@octokit/rest": "npm:22.0.1"
"@playwright/test": "npm:1.61.1"
"@playwright/test": "npm:1.62.0"
"@replit/codemirror-indentation-markers": "npm:6.5.3"
"@rsdoctor/rspack-plugin": "npm:1.6.1"
"@rspack/core": "npm:2.1.5"
@@ -9998,7 +9998,7 @@ __metadata:
dialog-polyfill: "npm:0.5.6"
echarts: "npm:6.1.0"
element-internals-polyfill: "npm:3.0.2"
eslint: "npm:10.7.0"
eslint: "npm:10.8.0"
eslint-config-prettier: "npm:10.1.8"
eslint-import-resolver-webpack: "npm:0.13.11"
eslint-plugin-import-x: "npm:4.17.1"
@@ -10056,7 +10056,7 @@ __metadata:
sortablejs: "patch:sortablejs@npm%3A1.15.6#~/.yarn/patches/sortablejs-npm-1.15.6-3235a8f83b.patch"
stacktrace-js: "npm:2.0.2"
superstruct: "npm:2.0.2"
tar: "npm:7.5.21"
tar: "npm:7.5.22"
terser-webpack-plugin: "npm:5.6.1"
tinykeys: "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch"
ts-lit-plugin: "npm:2.0.2"
@@ -11826,7 +11826,7 @@ __metadata:
languageName: node
linkType: hard
"minimatch@npm:^10.0.3, minimatch@npm:^10.1.1, minimatch@npm:^10.2.2, minimatch@npm:^10.2.4, minimatch@npm:^9.0.3 || ^10.1.2":
"minimatch@npm:^10.0.3, minimatch@npm:^10.1.1, minimatch@npm:^10.2.2, minimatch@npm:^10.2.4, minimatch@npm:^10.2.5, minimatch@npm:^9.0.3 || ^10.1.2":
version: 10.2.5
resolution: "minimatch@npm:10.2.5"
dependencies:
@@ -12763,27 +12763,27 @@ __metadata:
languageName: node
linkType: hard
"playwright-core@npm:1.61.1":
version: 1.61.1
resolution: "playwright-core@npm:1.61.1"
"playwright-core@npm:1.62.0":
version: 1.62.0
resolution: "playwright-core@npm:1.62.0"
bin:
playwright-core: cli.js
checksum: 10/b0e7b1d4de7ca6c1a57eb88f1709609a8b7637092f149e703d84d6c8e01835992ec5ca26b86f1a32fb5f5bc1aad042c3ae27311e131b3dda8a27824a543eb3c7
checksum: 10/1cf036e9ed51ea80d311ac25a871dddbc57d5e7e7a5ad23fc2741452aa8406c95e820901ec38aeecc6fca2ddc383ccc8acb0e2f29a4c12942dd0a1a63e7134a2
languageName: node
linkType: hard
"playwright@npm:1.61.1":
version: 1.61.1
resolution: "playwright@npm:1.61.1"
"playwright@npm:1.62.0":
version: 1.62.0
resolution: "playwright@npm:1.62.0"
dependencies:
fsevents: "npm:2.3.2"
playwright-core: "npm:1.61.1"
playwright-core: "npm:1.62.0"
dependenciesMeta:
fsevents:
optional: true
bin:
playwright: cli.js
checksum: 10/0cd1a8a7e106202e785450763973bf326d4aa112ed195bbd78b17af43dcfd12e29bc8204ae61c88f748dd0f2bdf69a7827bc3bbf8c1ec4c71b8e35586e05f13c
checksum: 10/52401b5f2b67abcb555c1b1c229e5204a3ac72a604343a99e60b00bd0cd46ad661a9471ced4e6c3ff39de957a0b1f413810d191ce81cd40e455700d73a6a80a5
languageName: node
linkType: hard
@@ -14501,16 +14501,16 @@ __metadata:
languageName: node
linkType: hard
"tar@npm:*, tar@npm:7.5.21, tar@npm:^7.4.3, tar@npm:^7.5.4":
version: 7.5.21
resolution: "tar@npm:7.5.21"
"tar@npm:*, tar@npm:7.5.22, tar@npm:^7.4.3, tar@npm:^7.5.4":
version: 7.5.22
resolution: "tar@npm:7.5.22"
dependencies:
"@isaacs/fs-minipass": "npm:^4.0.0"
chownr: "npm:^3.0.0"
minipass: "npm:^7.1.2"
minizlib: "npm:^3.1.0"
yallist: "npm:^5.0.0"
checksum: 10/a1b7dcee15e4f7dbef7f07c3cf0fe946248efabd1cb029b54c698d817dfc2876c75bcaa164a12dd21191e385759ce3e37fea562cf4aabaa00984ef9ed3c48d17
checksum: 10/129606384b3c895f422aaca48bf316357be6dc7aa35c1066c3f06c28acfff0be5b356e1b0a0be7c53a7a8945534ac06cec6d8e296d170d8a09c8bff67b29300e
languageName: node
linkType: hard