Compare commits

..
Author SHA1 Message Date
Bram Kragten 26690e06b0 Bumped version to 20260930.4 2026-10-16 13:23:16 +02:00
Maarten Lakerveld e7a89e0ac0 Stop the user condition editor from duplicating selected users (#54921)
When the users finish loading, the list picks up the pre-selected items and
fires a selected event for them. The handler appended that user again on
every open of the editor, so a single user condition grew one duplicate id
per edit. Ignore selection events that do not change the configured users.

Fixes #54894
2026-10-16 13:22:57 +02:00
Paulus SchoutsenandClaude eb90a50ca1 Keep hidden columns when reopening data table settings (#54874)
Keep column settings for tables without fixed last columns

The data table settings dialog dropped the stored column order when the
table had no lastFixed columns. With no column order, the dialog showed
every column as visible. Reopening the dialog on the statistics table
then showed hidden columns as enabled again.


Claude-Session: https://claude.ai/code/session_014uzH9wKHRBfTGqXypaYEgV

Co-authored-by: Claude <[email protected]>
2026-10-16 13:22:56 +02:00
MichaelandClaude Opus 5.5 e221ea2b19 Fix Matter add device button not opening the add device dialog (#54823)
The add device button on the Matter dashboard linked to
/config/matter/add, which navigated to the devices dashboard and opened
the Matter add device dialog without awaiting the navigation. The
navigation closes open dialogs before it completes, so the dialog was
closed again right away and the user ended up on the devices dashboard.

Open the dialog directly from the Matter dashboard, like the Z-Wave
dashboard does, and make the /config/matter/add route (used by the
add_matter_device my link) await the navigation before showing the
dialog from the app root, as its own element is detached by then.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-16 13:22:55 +02:00
Jan-Philipp Benecke abd5f9e985 Fix script graph crash on template shorthand conditions (#54798)
* Fix script graph crash on template shorthand conditions

* Move string to ConditionWithShorthand
2026-10-16 13:22:54 +02:00
Petar Petrov 2f9a4f99be Link Ethernet Matter nodes to Home Assistant in the network map (#54784) 2026-10-16 13:22:53 +02:00
Maarten Lakerveld 0cf9fcef97 Map overview fixes (#54774) 2026-10-16 13:21:22 +02:00
Bram KragtenandClaude Opus 5.5 3f7f4613d6 Remove invalid ::part() selector that broke CSS minification (#54772)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:29:05 +02:00
Bram Kragten 4a8501a6e5 Fix import from ui-support 2026-10-09 11:26:27 +02:00
Bram Kragten 4ae1775644 Bumped version to 20260930.3 2026-10-09 11:16:55 +02:00
Jan-Philipp Benecke 80cd338e77 Fix crash on null trigger entries in the automation editor (#54771) 2026-10-09 11:16:35 +02:00
Franck Nijhof cd8f785ce6 Don't count the characters of a string target (#54769) 2026-10-09 11:11:58 +02:00
Franck Nijhof 4f4f7bd633 Translate attribute names every entity can have (#54768)
* Translate attribute names every entity can have

* Translate the supported features attribute name
2026-10-09 11:11:57 +02:00
Franck Nijhof c2413fd7fa Greet in the interface language when it is a variant of the pipeline language (#54766) 2026-10-09 11:11:55 +02:00
Franck Nijhof ed596fe700 Hide hidden cards in grid sections without :has() (#54764) 2026-10-09 11:11:53 +02:00
Franck Nijhof e580fb8b5c Keep dropdown menus clear of safe areas (#54762) 2026-10-09 11:11:52 +02:00
Franck Nijhof 5ba7a906d4 Keep icon button labels off the ha-button host (#54761) 2026-10-09 11:11:51 +02:00
Franck Nijhof c6159615d7 Release streaming images when hui-image is detached (#54760) 2026-10-09 11:11:49 +02:00
Franck Nijhof b2f63cf345 Show the speech-to-text duration without VAD (#54759) 2026-10-09 11:11:48 +02:00
Franck Nijhof fbd7bb073e Give dialogs an accessible name (#54758) 2026-10-09 11:11:47 +02:00
Franck Nijhof 42fec4fb9c Expose the real invalid state of inputs to screen readers (#54757) 2026-10-09 11:11:46 +02:00
Franck Nijhof bcd7f0dc88 Show the ZHA config entry state when it isn't loaded (#54756) 2026-10-09 11:11:45 +02:00
Franck Nijhof 44b5dc9446 Fit the add integration list on short screens (#54755) 2026-10-09 11:11:43 +02:00
Franck Nijhof 4fd8732160 Fall back to local voice setup when the cloud status fails (#54754) 2026-10-09 11:11:42 +02:00
Petar Petrov 51675e0cd2 Fix negative upstream device untracked energy in devices detail graph (#54753) 2026-10-09 11:10:17 +02:00
Petar Petrov 36bddbff06 Hide Zigbee from the ESPHome device setup wizard (#54751) 2026-10-09 11:06:57 +02:00
Simon Lamon e8c15f10e2 Restore compatibillity with older chromium devices in ScrollFadeController (#54742)
Restore compatibillity with older chromium devices
2026-10-09 11:06:55 +02:00
Franck Nijhof 8f870146c6 Keep custom logbook messages for automations and scripts (#54739)
* Keep custom logbook messages for automations and scripts

* Only match the domain's own run message
2026-10-09 11:06:54 +02:00
Franck Nijhof 91c0d052df Convert CSS color names to hex when saving a label (#54738) 2026-10-09 11:06:53 +02:00
Franck Nijhof dfb5e7d768 Keep the label of color inputs raised (#54737) 2026-10-09 11:06:51 +02:00
Artur Pragacz 03fb6f5c29 Fix translation key for external config flow step description (#54722) 2026-10-09 11:06:50 +02:00
Maarten Lakerveld 11176a7593 Center the map on a single entity instead of fitting it (#54718)
The vector map's globe projection (new in 2026.10) cannot solve a camera
for a zero-area bound, so fitting a single-entity map warned "Map cannot
fit within canvas" and left the view on the default location. Center a
single point directly with easeTo/flyTo rather than routing it through
fitBounds, carrying the fit's padding as a transient offset and honouring
its fly request.
2026-10-09 11:06:48 +02:00
Paul Bottein 8a7b8ff2ac Add side padding to the statistics search field (#54717) 2026-10-09 11:06:47 +02:00
Franck Nijhof f61e1ab509 Respect custom icons for built-in panels in the sidebar (#54709) 2026-10-09 11:06:46 +02:00
Franck Nijhof 3e75d97b8a Show the right page title for more settings pages (#54708) 2026-10-09 11:06:45 +02:00
Franck Nijhof 8588d34fbe Only cache the frontend's own static files in the service worker (#54707) 2026-10-09 11:06:43 +02:00
Paul Bottein f4952764d4 Replace map overview more info button with a header icon (#54704)
* Replace map overview more info button with a header icon

* Reuse the show more information label

* Show a settings button for admins in the map overview

* Hide the map overview settings button in the demo
2026-10-09 11:06:42 +02:00
Franck Nijhof 52d38105ae Let long dropdown submenus scroll (#54692) 2026-10-09 11:06:41 +02:00
Franck Nijhof 8fa6c1d027 Keep a cleared input value when focusing it again (#54690) 2026-10-09 11:06:39 +02:00
Franck Nijhof 972b999195 Handle map labels without a name (#54689) 2026-10-09 11:06:38 +02:00
Petar Petrov fe6bc5530e Show the newest history states when the browser clock runs behind (#54688) 2026-10-09 11:06:37 +02:00
Franck Nijhof d5d7093ca3 Keep the key order when duplicating a script (#54687) 2026-10-09 11:06:36 +02:00
Franck Nijhof 52d30d5b6e Keep boolean fields inline in the narrow action editor (#54686) 2026-10-09 11:06:35 +02:00
Franck Nijhof 2a25673659 Fix ZHA reconfigure dialog errors after closing and on reconnect (#54684)
* Fix ZHA reconfigure dialog errors after closing and on reconnect

* Only handle reconfigure messages of the current run
2026-10-09 11:06:33 +02:00
Franck Nijhof 94838cf7d1 Load the HLS source once instead of on every media attach (#54683) 2026-10-09 11:06:32 +02:00
Franck Nijhof 9214bcac94 Wrap descriptions in voice assistant settings (#54678) 2026-10-09 11:06:30 +02:00
karwosts 3be6887df6 Fix create floor memoization issue (#54675) 2026-10-09 11:06:29 +02:00
Paul Bottein 33374693c9 Snap the map overview bottom sheet to fixed positions (#54663)
* Snap the map overview bottom sheet to fixed positions

* Include the safe area in the collapsed stop and follow reduced motion
2026-10-09 11:06:28 +02:00
Marcin BauerandClaude Opus 5.5 80516f1f00 Restore left-column timeline names on the history-graph card (#54661)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:06:26 +02:00
Petar Petrov 9253af6a19 Align history timeline X axis with line charts (#54657) 2026-10-09 11:06:25 +02:00
Bram KragtenandClaude Opus 5.5 58f1d51c7f Add realistic automations to the demo (#54647)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:04:03 +02:00
Bram KragtenandClaude Opus 5.5 05d6f614bc Show the next demo card on the home page demo again (#54646)
* Show the next demo card on the home page demo again

The home overview strategy now generates the favorites section itself
instead of returning a common-controls section strategy, so the demo
strategy no longer found the section to add its card to. Find the
favorites section by its heading instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Keep at least six favorites next to the demo card

The section strategy used to get a limit of two less, so seven tiles
became six. Only drop as many tiles as needed to keep six.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Keep the current user when the demo user subscription refreshes

The auth/current_user mock answered with the user from startup, so the
user subscription reset a changed user, like the non-admin scenario of
the e2e tests, to the initial admin user.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:00:44 +02:00
Bram KragtenandClaude Opus 5.5 6e2bb1b2f5 Mock editing the own profile in the demo (#54645)
The demo did not answer person/update_own_profile, so saving the profile
dialog showed an error. Also answer auth/current_user, so the user
subscription picks up the new name.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:00:43 +02:00
Bram KragtenandClaude Opus 5.5 f3a6e1028e Add focus indicator to entity and sensor cards (#54644)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:00:41 +02:00
WendelinandClaude f182b4600d Dismiss reboot/shutdown toast when frontend reconnects (#54640)
The "Rebooting system" toast was shown without an id and with no
timeout, so nothing could ever close it and it stayed on screen after
the host came back. Give it an id, dismiss it when the connection is
re-established, and also when the action fails with an error dialog.

Fixes #54630


Claude-Session: https://claude.ai/code/session_01SkS1AB8w2GPUuRBaDDmsEu

Co-authored-by: Claude <[email protected]>
2026-10-09 11:00:40 +02:00
Jan Čermák b7983e0906 Copy locale data to the landing page build (#54488)
The landing page bundle includes the Intl polyfill. When a browser
needs a polyfill, it loads locale data from /static/locale-data/.
The landing page build never copied that directory, so these requests
returned 404. The language picker then showed raw language codes.

Fixes home-assistant/landingpage#216
2026-10-09 11:00:38 +02:00
Bram KragtenandClaude Opus 5 6c988150e4 Draw the globe against a sky when the map is zoomed out (#54485)
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:00:37 +02:00
Bram KragtenandClaude Opus 5.5 b88a39e273 Cache map tiles per asset class and stop refetching what still works (#54451)
* Cache map tiles per asset class and stop refetching what still works

The service worker kept glyphs, sprites and tiles in one 500-entry LRU, so
panning around evicted the fonts that every label on every map needs. Glyphs
and sprites move to a cache of their own, on the 30 day age core already sends
them with, and the tile ceiling goes up.

The TileJSON was deliberately left on the network because it is the switching
point for the tile source. Every map waits on it before asking for a tile
though, and at a five minute max-age that is a round trip before the first one.
Served stale-while-revalidate instead: out of the cache immediately, refreshed
behind it, so a moved endpoint still arrives.

The raster fallback redrew on every token rotation, refetching every tile on
screen twice an hour whether or not anything had failed. Later tiles pick up a
new token by themselves, so only a layer that actually had one refused needs
asking again - and since a refused tile is the only sign that path gets that
its token is stale, it now asks for a new one instead of waiting for the
refresh interval to come round.

Style requests that are in flight are shared, so a dashboard of maps, or a
theme toggle switching all of them at once, fetches each style once. Only while
in flight: a settled request is dropped, or a style that failed once would
never be fetched again.

Co-Authored-By: Claude Opus 5 <[email protected]>

* Hand the raster fallback the current map tiles token

A rotation is only announced once, so a vector layer that later falls back
handed the raster layer the token it was created with, and the refresh that
follows a refused tile fetches the already-current token and announces
nothing. The vector layer now tracks rotations itself.

Also covers in-flight style sharing, and passes a map style rather than
`false` in the raster recovery tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-10-09 11:00:35 +02:00
Maarten Lakerveld ec6b258750 Zone editor: fit without passive zones, hide covered radius handles, name tooltips, and start new zones at the map center (#54597)
* Leave passive zones out of the zones map fit

Opening the zones page fitted the map around every zone, so a few remote
passive zones pushed the view out to the whole globe and adding a zone took
a lot of zooming. Passive zones are still drawn and editable, but no longer
count toward the fit unless they are the only zones there are. Editable
locations gained a fit flag for this.

Fixes #54595

* Hide a zone's radius handle while the circle is under its icon

Zoomed out on the zones page, every zone's radius handle collapsed onto the
zone icon and covered it. The handle now hides while the radius on screen is
shorter than the icon's half width, so it returns as soon as the circle's
edge shows past the icon, and it stays while the radius is being dragged.

* Show a zone's name in a tooltip when hovering its marker on the zones map

The marker only had a native title, which appears late and in the
browser's own style. Every named editable location now gets an ha-tooltip
anchored to its element, so hovering a zone icon on the zones page names
it promptly; the native title is dropped so the two do not stack.

* Start a new zone at the center of the zones map

Create zone placed the new zone at a random spot up to 500 meters from
home, so it landed somewhere unexpected and often off-screen. When the zones
page has its map open, a new zone now starts at the center of the current
view, so panning to the right place first works as expected. A location
handed in, as from a person's Create zone action, still wins; without a map
the random offset around home remains.
2026-10-07 10:12:54 +02:00
Maarten Lakerveld ea1fabdfdd Map: float every marker, keep the selection in the URL, pan-first focus, and fix attribution and zone radius display (#54596)
* Auto-hide compact map attribution and restyle it for dark mode

MapLibre decides compact mode from the map canvas width alone (640px), so
shrinking the window flipped the attribution from compact back to inline
when the sidebar became a drawer and the map got wider. The engine now adds
the control itself and forces compact on a narrow viewport (870px, the app's
own narrow layout), swapping it when the breakpoint changes.

MapLibre only collapsed the opened compact attribution on a drag; it now
also collapses by itself after five seconds, unless the user opened it from
the info button.

MapLibre's stylesheet hardcodes a white attribution with black text, which
Home Assistant never overrode, so it stood out on a dark map. The dark mode
rules now match the zoom control styling, and the inline text color is set
explicitly instead of inherited from a page that may be in the other mode.

* Add show_zone_radius option to the map card

In panel layout the map hides zone radii except on the Zones tab, for the
selected zone, and while editing. A user asked to always see them, so the
new option draws the radius of every visible zone on all tabs. Other layouts
already show radii and are unaffected.

* Regroup map clusters up front for every fit

Clicking a bubble already regrouped its members at the zoom the fit would
land on, so they parted as the animation started. Any other way of zooming
in, such as picking a person in the map overview, waited for the move to end
before the bubble split. The fit itself now does the regrouping, for all
markers, so every animated fit shows the bubbles as they will end up.

* Return the map to its previous view from the overview's back button

Leaving a selected person or zone cleared the selection but left the map
zoomed in on it. The card now remembers where the map was when the overview
drilled in: the exact camera, or the previous entity's fit when one selection
followed another, and the back button eases back there. A camera that was
still auto-fitting resumes auto-fit instead of freezing. Clicking the map to
deselect stays put, since that is the user looking around.

Engines gain getView, and setView can animate.

* Float the selected map marker above a dot at its location

Selecting a person or device marked it with a ring only, and inside a zone
it stayed hidden in the zone's bubble. The selected marker is now excluded
from clustering, drawn a quarter larger, and hangs above a small dot at the
exact coordinate with a tail pointing down to it. The accuracy circle around
the dot carries the entity color, so the ring is dropped; an entity that
reports no accuracy gets a nominal 5 meter circle so the color still shows.
A fix of 10 meters or better draws that circle as a soft disc without an
outline, since a hard edge would claim more precision than is known; map
circles gained an outline option for this. The engine's marker anchor puts
the dot on the coordinate; both engines support it. Bubble avatars keep
their size.

* Open more info from a button in the map overview detail

The selected person, device or zone's name was the only way to the more-info
dialog, which made the title read as a link. The name is plain text now and
a filled More info button sits below the activity, where the user expects an
action. Focus still moves to the name when the detail view opens.

* Always show the selected marker's accuracy circle

The circle only appeared for a GPS fix looser than 100 meters, so most
phones never showed one. The selected marker now floats above a dot at its
coordinate, so even a small circle around that dot reads clearly; it is
never hidden for the selected entity. Focusing still fits the circle into
view only above 100 meters, since a tighter circle fits at the focus zoom.

* Describe every toggle in the map card editor

Only Show all and Show zone radius explained themselves; Scale ruler, Auto
fit, Fit zones and Cluster markers now have helper text too.

* Float every map marker above its location

Only the selected marker hung above a dot with a tail; the rest sat
centered on their coordinate, so a marker's frame always hid the spot it
marked. Every entity marker is now a pin whose tail points just above its
coordinate. Selecting one scales its frame up and shows the dot at the
exact location. Cluster bubble avatars keep their plain frames.

* Keep the map overview selection in browser history

The card remembered a single camera to restore from the overview's back
button, so after selecting one entity and then another the original view
was lost, and back from the second left the map zoomed on the first while
the overview showed the list. The selected entity now lives in the URL as
an entity_id query parameter; selecting pushes a history entry, and so
does deselecting, whether from the overview's back arrow or a click on
the map, so the browser's back and forward step through every selection.
The overview's arrow itself always returns to the list. Selecting focuses
the entity; deselecting leaves the map where it is. Only selectable
domains are accepted from the URL; anything else is dropped from it. The
saved camera, the animated setView and the autoFit view flag had no
other users.

* Pan to a selected map entity instead of zooming in on it

Focusing a person jumped to zoom 19 and a zone fitted its circle, which
often zoomed much further in than the user had been. Selecting now keeps
the current zoom and pans the entity into the free part of the view. It
only zooms in when another marker sits within 80 screen pixels, and then
just far enough to clear it, capped at zoom 17. Markers at the very same
position cannot be separated by zooming and are left alone. A zone is
fitted as its whole circle with a little room around it under that same
zoom cap, so a zone that fits the view keeps the zoom and a bigger one
zooms out just enough. A marker already on screen eases over; one off
screen flies there in an arc. Fits gain a fly option for this, and every
other fit now eases straight to its bounds on both engines, where
MapLibre used to fly. Map-engine gains pixelDistance, the Web Mercator
screen distance at a zoom level, with a test for its scale.

* Float every cluster bubble above its location

Only a bubble attached to a zone had a tail and hung above the zone's
circle; a bubble of nearby markers sat centered on the cluster, hiding the
spot it stood for. Every bubble now has the tail and floats above its
location with the same lift as a single marker's pin, so the two read as
one style. A zone's bubble keeps floating above the zone circle.
2026-10-07 10:12:53 +02:00
Bram Kragten af4f86743b Bumped version to 20260930.2 2026-10-07 10:07:02 +02:00
Artur Pragacz ed492d5b94 Consider next name part when unprefixing entity name (#54633)
* Consider next name part when unprefixing entity name

* Adjust
2026-10-07 10:05:34 +02:00
Paul Botteinandcopilot-swe-agent[bot] db00a6b7e7 Fix section editor crash while typing a strategy in YAML (#54622)
* Fix section editor crash while typing a strategy in YAML

* Reject array-valued section strategies in YAML

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-10-07 10:05:33 +02:00
WendelinandClaude 864f0877f6 Increase touch area of ha-checkbox (#54617)
Co-authored-by: Claude <[email protected]>
2026-10-07 10:05:31 +02:00
Wendelin d7062f3d9d Fix HaChipBase border radius (#54603)
Add border-radius to HaChipBase styles for improved appearance
2026-10-07 10:05:30 +02:00
Jonathan 3051032360 Show the requested run when a trace page switches item (#54591) 2026-10-07 10:05:29 +02:00
Paul Bottein 54c575efd1 Show referenced triggers instead of generated IDs in automation and trace descriptions (#54587) 2026-10-07 10:05:28 +02:00
Paul Bottein e6bc47e6ba Treat an entity name equal to its device name as no own name (#54585)
* Treat an entity name equal to its device name as no own name

* Use the same device name check when computing entity names

* Only check the user-set name against the device name

* Add tests for the device name check

* Clarify why only the user-set name is matched loosely
2026-10-07 10:05:26 +02:00
WendelinandClaude Sonnet 5.5 6244a8db04 Fix config entry state styling (#54584)
* Restore needs attention color on config entry rows

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Restore config entry row state styling

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Highlight config entry state with border color instead of tint

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Add quiet fill background to config entry states

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Use semantic colors for config entry state borders

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Drop state border colors and use semantic text colors

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Use semantic color for config entry link icon

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Add divider between discovered and attention flow rows

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Use semantic border and fill colors for discovered and attention lists

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Use warning colors for discovered list too

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Use standard divider color for flow list borders

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Use border width token for flow list borders

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Restore warning icon color for failed unload state

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* review

---------

Co-authored-by: Claude <[email protected]>
2026-10-07 10:05:25 +02:00
Paul Bottein 3f2c1ea538 Show then branch before else in trace graph (#54583) 2026-10-07 10:05:24 +02:00
Paul Bottein aac947ee4d Fix option labels overflowing horizontal control selects (#54582) 2026-10-07 10:05:23 +02:00
Maarten Lakerveld 594d9be859 Bump home-assistant-js-websocket to 9.7.1 (#54577)
Exclude our own packages from the 3-day minimumReleaseAge rule so
fixes we publish ourselves can be picked up immediately. The
no-downgrade trust policy still rejects unsigned releases.
2026-10-07 10:05:22 +02:00
Paul Bottein 80ae2543e5 Fix map history paths flickering on every update (#54573)
Update map paths and zone circles in place instead of redrawing them
2026-10-07 10:05:21 +02:00
Wendelin 94d176c3d1 Fix styling for config entry device rows (#54570) 2026-10-07 10:05:20 +02:00
Jan-Philipp Benecke 192d9f785f Hide dangling trace graph connectors when nothing follows (#54568) 2026-10-07 10:05:18 +02:00
Paul Bottein 398ffa045b Fix first badges cut off in centered scrolling view header (#54565) 2026-10-07 10:05:17 +02:00
Wendelin e67538fb7c config-area-page fix missing list-item import (#54563) 2026-10-07 10:05:16 +02:00
Bram Kragten 025db7ffe7 Bumped version to 20260930.1 2026-10-05 11:02:45 +02:00
Paulus SchoutsenandClaude f9c958c80c Hide MCP URL hint until the server is enabled (#54532)
When the MCP server is not set up, the card has no URLs to show.
The description told the user to add one of the URLs below. Show
only the documentation link until the server is enabled.


Claude-Session: https://claude.ai/code/session_01MeyiXPWWRNRpD2XHVrtPSr

Co-authored-by: Claude <[email protected]>
2026-10-05 11:02:26 +02:00
Jan-Philipp Benecke 652897a3cb Handle undefined trigger condition IDs in rendering (#54527)
* Handle undefined trigger condition IDs in rendering

* Handle trigger conditions without ID in editor and updates
2026-10-05 11:02:25 +02:00
Maarten Lakerveld 3426449315 Keep padded map fits within the poles (#54526)
The MapLibre engine pads a fit by a fraction of its span. A zone whose
circle reaches a pole, or markers far apart, pushed the padded
latitude past 90, and MapLibre throws on such bounds, so the fit never
happened and the console filled with errors. The padded latitude is
now clamped to the poles.

Fixes #54498
2026-10-05 11:02:24 +02:00
Jan-Philipp Benecke 8e19b0249d Fix misplaced trace graph icons on iOS (#54520) 2026-10-05 11:02:23 +02:00
Paul Bottein bba034eeca Show entered and left in map zone activity (#54516)
* Show arrived and left in map zone activity

* Use entered instead of arrived in map zone activity
2026-10-05 11:02:21 +02:00
karwosts 31f2767038 Remove spurious color clear button (#54515)
* Improve number selectors in section settings

* Fix sections editor default color
2026-10-05 11:00:21 +02:00
Petar Petrov e0571044ae Detect the ESPHome Z-Wave discovery flow by the device MAC (#54513) 2026-10-05 10:57:27 +02:00
Maarten Lakerveld 0f00286641 Fix map overview tabs, activity timeline, cluster counts, zone fit and the new-zone route (#54512)
* Show four avatars in a cluster bubble before counting

A bubble capped at three avatars added a +1 chip for the fourth
member, taking a fourth slot to hide one avatar. Four members now show
as four avatars; from five on, three avatars and a count fill the same
four slots.

* Open the new zone dialog on every visit to /config/zone/new

The route was only handled in firstUpdated. The config panel is kept
alive between visits and the router reuses the zone page when it is
already the current one, so "Create zone from current location" did
nothing once the zone list had been opened earlier in the session. The
route is now handled on every route change, like /edit/ already was.

The dialog also opened before the more-info dialog it was launched
from had finished leaving the history, and that departure closed the
new dialog again. The page now awaits the replace navigation, which
waits for closing dialogs, before opening it.

* Hide the map overview while the dashboard is being edited

In panel view edit mode the map card kept rendering its overview. On
phones that is a bottom sheet fixed over the bottom of the screen,
which covered the card's edit bar and the view's add button, so the
map dashboard could not be edited in portrait. The overview is for
using the map, not configuring it, so it is left out while editing.

* Show only the overview tabs that have something to list

People and Zones were always offered, with an empty message when the
map card's entities held none of them; only Devices was hidden when
empty. All three now appear only with entries, the first remaining tab
takes over when the selected one empties, and the overview tells the
card so its marker filter follows. The empty-tab messages are gone
with the empty tabs.

* List the person of the current user first, as "Me"

The overview's People tab sorted everyone alike. The person linked to
the user looking at the map now comes first and is named "Me" in the
list and the detail header, through a localized string. Their initials
still come from their real name.

* Draw the overview activity as a timeline

The activity list reused logbook rows: the person's name on every row,
the state pushed to the right, dividers between rows. It is now a
timeline of its own. Each row names where the person went, with the
clock time and how long ago underneath, hung off a dot in the zone's
color with a rail to the next row. A zone's rows name who came or
left and where they are. Five rows show at first, with a "Show more"
button for the rest. The overview no longer needs the broad hass
object, which only the logbook row took.

* Fit the map to its zones when they are all it shows

Zones only joined the auto-fit with the fit_zones option. The panel
map does not set it, so on the Zones tab, with no people or devices
left, the fit found nothing and jumped straight to the home
coordinates at the default zoom. Zones now stand in whenever nothing
else is on the map, which goes through the animated fit like the
other tabs. With fit_zones they still join a fit of people and devices.
2026-10-05 10:57:26 +02:00
Petar Petrov 9cc166d3cf Fix double side padding around more-info history and activity (#54511) 2026-10-05 10:57:25 +02:00
Bram Kragten 3ad1ed5aa6 Resolve split devices in the logbook card (#54507) 2026-10-05 10:57:24 +02:00
Wendelin 8ee84c2462 Use optional short issue title localization to repairs dialog (#54506)
Add short issue title localization to repairs dialog
2026-10-05 10:57:22 +02:00
Paul Bottein eca108032f Fix area switch for entities without their own name (#54503) 2026-10-05 10:57:21 +02:00
Paul Bottein 3bfe67f784 Give zones their own map colors and prefer white zone icons (#54499)
* Give zones their own map color sequence

* Prefer white zone icons when contrast allows
2026-10-05 10:57:20 +02:00
Paul Bottein c27191de6b Show people and zones on the demo map (#54496) 2026-10-05 10:57:19 +02:00
Bram Kragten 31f626c877 Bump saturation of the default light mode map (#54492)
bump saturation of the default light mode map
2026-10-05 10:57:17 +02:00
Tom Mittelstädtand1358 b081db7020 Give tentative calendar events a striped background (#54483)
* Give tentative calendar events a striped background

A tentative bar gets a light fill in its calendar color with diagonal stripes in the text color, instead of the outline alone. A tentative dot keeps its color and size and gets stripes in the calendar's contrast color. The stripes set a tentative event apart from past days, which are already drawn faded. They repeat on whole pixels, so every stripe looks the same.

* Strengthen tentative dot stripes and add a forced-colors fallback

The stripes on a tentative dot are its only cue, so they now reach at least 3:1 against the dot. In forced colors the stripes and the dot's fill are dropped, so a tentative bar falls back to a dashed border and a tentative dot to a ring of the same size.

---------

Co-authored-by: 1358 <[email protected]>
2026-10-05 10:57:16 +02:00
926 changed files with 12135 additions and 41176 deletions

No files matched your search

+2 -5
View File
@@ -16,7 +16,7 @@ Container components may keep `hass` when they own it and feed providers. Leaf c
## Core Files
- Context definitions: `src/data/context/index.ts`
- `consume` decorator, `ContextSubscriptionController`, and `consumeContext` / `ContextController` for reactive controllers: `src/common/decorators/consume.ts` (use instead of `@lit/context`'s `consume` and `ContextConsumer`, enforced by ESLint)
- `consume` decorator and `ContextSubscriptionController`: `src/common/decorators/consume.ts` (use instead of `@lit/context`'s `consume` and `ContextConsumer`, enforced by ESLint)
- Entity-scoped consume helpers: `src/common/decorators/consume-context-entry.ts`
- Transform decorator: `src/common/decorators/transform.ts`
- Canonical migration example: `src/panels/lovelace/cards/hui-button-card.ts`
@@ -37,7 +37,6 @@ Container components may keep `hass` when they own it and feed providers. Leaf c
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callApiRaw`, `hass.callWS`, `hass.sendWS`, `hass.fetchWithAuth` |
| `uiContext` | themes, selected theme, panels, sidebar, and UI state |
| `narrowViewportContext` | narrow-layout boolean |
| `reducedMotionContext` | `prefers-reduced-motion` media query |
Lazy contexts subscribe on first consumer and tear down after the last consumer: `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, `manifestsContext`, `triggerDescriptionsContext`, and `conditionDescriptionsContext`.
@@ -51,9 +50,7 @@ Import `consume` from `src/common/decorators/consume`, not from `@lit/context`.
- `@state()` plus `@consume` plus `@transform`: rerenders only when the transformed value changes.
- `@consume` alone: the field stays current but never triggers a render. Use this for values only read in event handlers or callbacks, such as `apiContext`, or for data read on demand, such as picker item callbacks.
In reactive controllers, extend `ContextController` and decorate fields with `consumeContext({ context, subscribe, transform })` from the same file. `transform` stores one part of the context value, and `contextUpdated()` runs only when that part changes. `@state()` and `@transform` do not work on controller fields; call `host.requestUpdate()` when the host needs to rerender. Example: `src/data/service-info-controller.ts`.
For lazily created subscriptions, use `ContextSubscriptionController` from the same file instead of `@lit/context`'s `ContextConsumer`, which also forces a host update. Its callback must store the value in a reactive field or call `host.requestUpdate()` itself when the host needs to rerender.
In controllers or for lazily created subscriptions, use `ContextSubscriptionController` from the same file instead of `@lit/context`'s `ContextConsumer`, which also forces a host update. Its callback must store the value in a reactive field or call `host.requestUpdate()` itself when the host needs to rerender.
Use entity-scoped helpers when the component watches an entity ID held on the host:
-226
View File
@@ -1,23 +1,3 @@
# Path rules for .github/workflows/labeler.yaml, which runs actions/labeler on
# every pull request. A label is added when a pull request changes a matching
# file, and removed again when it no longer does, including one added by hand.
#
# Do not add these labels here. .github/scripts/label-content.mts applies and
# removes them from paths and code. A rule here would remove them whenever its
# paths don't match, even when the script's code check does:
#
# - Companion App
# - Home Assistant Link
# - Labs
# - Supervisor
Activity:
- changed-files:
- any-glob-to-any-file:
- src/panels/logbook/**
- src/panels/lovelace/**/*logbook*
- src/data/logbook.ts
Agents:
- changed-files:
- any-glob-to-any-file:
@@ -36,36 +16,6 @@ Agents:
- .github/skills/**
- .github/workflows/copilot-setup-steps.yml
AI:
- changed-files:
- any-glob-to-any-file:
- "src/panels/config/core/{ha-config-section-ai,ai-task-pref,mcp-pref}.ts"
- src/components/ha-suggest-with-ai-button.ts
- "src/data/{ai_task,llm}.ts"
Areas:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/areas/**
- src/data/area/**
- "src/data/{area_floor_picker,floor_registry,ws-area_registry,ws-floor_registry}.ts"
Automations:
- changed-files:
- any-glob-to-any-file:
- "src/panels/config/{automation,script,scene,blueprint}/**"
- src/components/automation/**
- src/components/trace/**
- src/data/trace*.ts
- "src/data/{automation,automation_i18n,script,script_i18n,scene,blueprint,trigger,condition,action}.ts"
Backups:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/backup/**
- src/data/backup*.ts
- src/onboarding/onboarding-restore-backup.ts
Build:
- changed-files:
- any-glob-to-any-file:
@@ -73,37 +23,12 @@ Build:
- .browserslistrc
- gulpfile.js
Calendar:
- changed-files:
- any-glob-to-any-file:
- src/panels/calendar/**
- src/panels/lovelace/**/*calendar*
- src/data/calendar.ts
Cast:
- changed-files:
- any-glob-to-any-file:
- cast/src/**
- src/cast/**
Connectivity:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/connectivity/**
- src/panels/config/tags/**
- "src/panels/config/integrations/integration-panels/{bluetooth,infrared,matter,modbus,mqtt,radio_frequency,serial,thread,zha,zwave_js}/**"
- "src/data/{bluetooth,infrared,matter,matter-lock,modbus,mqtt,otbr,radio_frequency,tag,thread,usb,zha,zwave_js,zwave_js-credentials}.ts"
Dashboards:
- changed-files:
- any-glob-to-any-file:
- src/panels/lovelace/**
- "src/panels/{home,climate,light,security,maintenance,map}/**"
- src/panels/config/lovelace/**
- "src/panels/config/dashboard/{dashboard-card,dialog-new-dashboard,show-dialog-new-dashboard}.ts"
- src/data/lovelace/**
- src/data/lovelace*.ts
Demo:
- changed-files:
- any-glob-to-any-file:
@@ -132,29 +57,6 @@ Dependencies:
- "^renovate/"
- "^dependabot/"
Devices:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/devices/**
- src/data/device/device_registry.ts
- src/data/ws-device_registry.ts
Energy:
- changed-files:
- any-glob-to-any-file:
- src/panels/energy/**
- src/panels/config/energy/**
- src/panels/lovelace/cards/energy/**
- src/panels/lovelace/**/*energy*
- src/data/energy*.ts
Entities:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/entities/**
- src/data/entity/entity_registry*.ts
- src/data/ws-entity_registry_display.ts
GitHub Actions:
- changed-files:
- any-glob-to-any-file:
@@ -162,100 +64,6 @@ GitHub Actions:
- .github/workflows/**
- .github/*.yml
Helpers:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/helpers/**
- src/data/helpers_crud.ts
History:
- changed-files:
- any-glob-to-any-file:
- src/panels/history/**
- src/panels/lovelace/**/*history*
- src/data/history.ts
Integrations:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/integrations/**
- src/panels/config/application_credentials/**
- src/dialogs/config-flow/**
- src/data/application_credential.ts
- src/data/config_entries.ts
- "src/data/{config_flow,data_entry_flow,options_flow,sub_config_flow}.ts"
Labels:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/labels/**
- src/data/label/**
Landing Page:
- changed-files:
- any-glob-to-any-file:
- landing-page/**
Marketplace:
- changed-files:
- any-glob-to-any-file:
- src/panels/marketplace/**
- src/data/marketplace/**
- src/panels/config/integrations/offer-marketplace-uninstall.ts
Media:
- changed-files:
- any-glob-to-any-file:
- src/panels/media-browser/**
- src/components/media-player/**
- src/data/media_source.ts
Notifications:
- changed-files:
- any-glob-to-any-file:
- src/dialogs/notifications/**
- src/data/persistent_notification.ts
Onboarding:
- changed-files:
- any-glob-to-any-file:
- src/onboarding/**
- src/auth/**
- src/data/onboarding.ts
- src/data/auth.ts
People:
- changed-files:
- any-glob-to-any-file:
- "src/panels/config/{person,users}/**"
- "src/data/{person,user,ws-user}.ts"
Profile:
- changed-files:
- any-glob-to-any-file:
- src/panels/profile/**
Search:
- changed-files:
- any-glob-to-any-file:
- src/dialogs/quick-bar/**
- src/state/quick-bar-mixin.ts
- "src/data/{quick_bar,search}.ts"
Sidebar:
- changed-files:
- any-glob-to-any-file:
- src/components/ha-sidebar.ts
- src/dialogs/sidebar/**
System:
- changed-files:
- any-glob-to-any-file:
- "src/panels/config/{core,hardware,info,logs,network,repairs,storage}/**"
- src/panels/config/dashboard/ha-config-updates.ts
- "src/dialogs/{repairs-flow,restart}/**"
- "src/data/{analytics,entity_id_format,error_log,hardware,network,repairs,system_health,system_log,system_log_report,update}.ts"
"Tests: E2E":
- changed-files:
- any-glob-to-any-file:
@@ -285,37 +93,3 @@ System:
- any-glob-to-any-file:
- test/*.ts
- "test/!(e2e)/**"
Theming:
- changed-files:
- any-glob-to-any-file:
- src/resources/theme/**
- src/state/themes-mixin.ts
- src/common/dom/apply_themes_on_element.ts
- "src/data/{theme,ws-themes}.ts"
To-do:
- changed-files:
- any-glob-to-any-file:
- src/panels/todo/**
- src/panels/lovelace/**/*todo*
- src/data/todo.ts
Tools:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/tools/**
Voice:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/voice-assistants/**
- "src/dialogs/{voice-assistant-setup,voice-command-dialog}/**"
- src/components/ha-assist-chat.ts
- "src/data/{assist_pipeline,assist_satellite,conversation,stt,tts,wake_word}.ts"
Zones:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/zone/**
- src/data/zone.ts
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env node
// Fails the check when a pull request carries a label that blocks merging, and
// writes the outcome to the job summary. Invoked from the `check` job in
// .github/workflows/blocking-labels.yaml via actions/github-script:
//
// const { default: checkBlockingLabels } =
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`);
// await checkBlockingLabels({ github, context, core });
export default async function checkBlockingLabels({ context, core }) {
const blockingLabels = [
"wait for backend",
"Needs UX",
"Do Not Review",
"Blocked",
"has-parent",
];
const prLabels = context.payload.pull_request.labels.map((l) => l.name);
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
if (found.length > 0) {
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
await core.summary
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
.addRaw(message)
.write();
core.setFailed(message);
} else {
await core.summary
.addHeading(
":white_check_mark: Pull Request is clear to merge after review",
2
)
.addRaw(
"This Pull Request is not blocked by any labels which prevent it from being merged."
)
.write();
}
}
-289
View File
@@ -1,289 +0,0 @@
#!/usr/bin/env node
// Checks npm registry metadata for the direct dependencies in package.json:
// deprecation, source repository and provenance. On pull requests only newly
// added packages are checked, and a deprecated one fails the check. Scheduled
// and manual runs report on every direct dependency without failing. Invoked
// from the `npm-metadata` job in .github/workflows/dependency-review.yaml via
// actions/github-script:
//
// const { default: checkNpmMetadata } =
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-npm-metadata.mts`);
// await checkNpmMetadata({ github, context, core });
import type { Octokit } from "@octokit/rest";
import type {
Context,
Core,
GitHubScriptArgs,
PullRequestPayload,
} from "./github-script.d.ts";
import { withRetry } from "./github-retry.mts";
// Scheduled and manual runs have no pull request
type Payload = Partial<PullRequestPayload>;
type Section =
| "dependencies"
| "devDependencies"
| "optionalDependencies"
| "peerDependencies";
type PackageJson = Partial<Record<Section, Record<string, string>>>;
interface Dependency {
name: string;
// The package npm installs, which differs from name for npm: aliases
registryName: string;
version: string;
section: Section;
}
// The fields read from a version document on registry.npmjs.org
interface NpmManifest {
deprecated?: string;
repository?: string | { url?: string };
dist?: { attestations?: object };
}
interface Result {
name: string;
version: string;
section: Section;
skipped?: string;
deprecated?: string;
repository?: string;
provenance?: boolean;
}
const SECTIONS: Section[] = [
"dependencies",
"devDependencies",
"optionalDependencies",
"peerDependencies",
];
const EXACT_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
const NPM_ALIAS = /^npm:((?:@[^/]+\/)?[^@]+)@(.+)$/;
const CONCURRENCY = 8;
// Returns undefined when the registry reports 404, and throws on any other
// failure so a registry outage fails the job instead of skipping the check.
const fetchJson = async <T,>(url: string): Promise<T | undefined> => {
const response = await fetch(url);
if (response.status === 404) {
return undefined;
}
if (!response.ok) {
throw new Error(
`Request to ${url} failed: ${response.status} ${response.statusText}`
);
}
return JSON.parse(await response.text());
};
const readPackageJson = async (
github: Octokit,
context: Context<Payload>,
ref: string
): Promise<PackageJson> => {
const { data } = await withRetry(`package.json lookup at ${ref}`, () =>
github.rest.repos.getContent({
...context.repo,
path: "package.json",
ref,
mediaType: { format: "raw" },
})
);
// The raw media type returns the file contents as a string, but Octokit
// types the response as the JSON form
if (typeof data !== "string") {
throw new Error("Expected raw package.json contents");
}
return JSON.parse(data);
};
const listDependencies = (pkg: PackageJson): Dependency[] =>
SECTIONS.flatMap((section) =>
Object.entries(pkg[section] ?? {}).map(([name, spec]) => {
// npm: aliases install another package, e.g. npm:[email protected]
const alias = spec.match(NPM_ALIAS);
return {
name,
registryName: alias?.[1] ?? name,
version: alias?.[2] ?? spec,
section,
};
})
);
const repositoryUrl = (repository: NpmManifest["repository"]) => {
const url = typeof repository === "string" ? repository : repository?.url;
return url?.replace(/^git\+/, "").replace(/\.git$/, "");
};
const checkPackage = async ({
name,
registryName,
version,
section,
}: Dependency): Promise<Result> => {
const result = {
name: registryName === name ? name : `${name} (npm:${registryName})`,
version,
section,
};
if (!EXACT_VERSION.test(version)) {
return { ...result, skipped: "not an exact version" };
}
const manifest = await fetchJson<NpmManifest>(
`https://registry.npmjs.org/${encodeURIComponent(registryName)}/${version}`
);
if (!manifest) {
return { ...result, skipped: "not found on the npm registry" };
}
return {
...result,
deprecated: manifest.deprecated,
repository: repositoryUrl(manifest.repository),
provenance: Boolean(manifest.dist?.attestations),
};
};
const checkAll = async (dependencies: Dependency[]) => {
const results: Result[] = [];
for (let i = 0; i < dependencies.length; i += CONCURRENCY) {
results.push(
// eslint-disable-next-line no-await-in-loop
...(await Promise.all(
dependencies.slice(i, i + CONCURRENCY).map(checkPackage)
))
);
}
return results;
};
const writeSummary = async (core: Core, heading: string, results: Result[]) => {
await core.summary
.addHeading(heading, 2)
.addTable([
[
"Package",
"Version",
"Section",
"Deprecated",
"Provenance",
"Repository",
].map((data) => ({ data, header: true })),
...results.map((result) =>
result.skipped
? [
result.name,
result.version,
result.section,
`skipped: ${result.skipped}`,
"-",
"-",
]
: [
result.name,
result.version,
result.section,
result.deprecated ? "yes" : "no",
result.provenance ? "yes" : "no",
result.repository ?? "none",
]
),
])
.write();
};
export default async function checkNpmMetadata({
github,
context,
core,
}: GitHubScriptArgs<Payload>) {
const pr = context.payload.pull_request;
if (!pr) {
const pkg = await readPackageJson(github, context, context.sha);
const results = await checkAll(listDependencies(pkg));
const flagged = (result: Result) =>
Number(
Boolean(result.skipped || result.deprecated || !result.repository)
);
results.sort(
(a, b) => flagged(b) - flagged(a) || a.name.localeCompare(b.name)
);
await writeSummary(core, "Direct dependencies", results);
return;
}
const [base, head] = await Promise.all([
readPackageJson(github, context, pr.base.sha),
readPackageJson(github, context, pr.head.sha),
]);
// Compare the packages npm installs, so pointing an npm: alias at another
// package counts as new, but moving a package between sections does not
const existing = new Set(
listDependencies(base).map(({ registryName }) => registryName)
);
const added = listDependencies(head).filter(
({ registryName }) => !existing.has(registryName)
);
if (added.length === 0) {
core.info("No new dependencies");
return;
}
const results = await checkAll(added);
const annotation = { file: "package.json" };
for (const result of results) {
const label = `${result.name}@${result.version}`;
if (result.skipped) {
core.warning(`${label}: ${result.skipped}, not checked`, annotation);
continue;
}
if (result.deprecated) {
core.error(`${label} is deprecated: ${result.deprecated}`, annotation);
}
if (!result.repository) {
core.warning(`${label} has no source repository`, annotation);
}
if (!result.provenance) {
core.notice(`${label} was published without provenance`, annotation);
}
}
await writeSummary(core, "New dependencies", results);
if (results.some((result) => result.deprecated)) {
core.setFailed("New dependencies must not be deprecated on npm");
}
}
@@ -6,20 +6,14 @@
// via actions/github-script:
//
// const { default: checkStandards } =
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mts`);
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`);
// await checkStandards({ github, context, core });
import type {
GitHubScriptArgs,
PullRequestPayload,
} from "./github-script.d.ts";
import { withRetry } from "./github-retry.mts";
export default async function checkPullRequestStandards({
github,
context,
core,
}: GitHubScriptArgs<PullRequestPayload>) {
}) {
const pr = context.payload.pull_request;
// Exempt bots (Copilot agent, dependabot), drafts, and maintainers.
@@ -32,12 +26,10 @@ export default async function checkPullRequestStandards({
return;
}
try {
await withRetry("organization membership check", () =>
github.rest.orgs.checkMembershipForUser({
org: "home-assistant",
username: pr.user.login,
})
);
await github.rest.orgs.checkMembershipForUser({
org: "home-assistant",
username: pr.user.login,
});
core.info(`Skipping organization member: ${pr.user.login}`);
return;
} catch (_error) {
@@ -60,16 +52,11 @@ export default async function checkPullRequestStandards({
const normalized = body.toLowerCase();
// Ignore 404s from mutations that race manual edits or cancelled runs.
const ignoreMissing = async <T,>(fn: () => Promise<T>) => {
const ignoreMissing = async (fn) => {
try {
await fn();
} catch (error) {
if (
typeof error === "object" &&
error !== null &&
"status" in error &&
error.status === 404
) {
if (error.status === 404) {
core.info("Target already removed, nothing to do");
return;
}
@@ -78,7 +65,7 @@ export default async function checkPullRequestStandards({
};
// Hide/restore our comment via GraphQL (REST cannot minimize).
const setMinimized = async (subjectId: string, minimized: boolean) => {
const setMinimized = async (subjectId, minimized) => {
const mutation = minimized
? `mutation($id: ID!) {
minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) {
@@ -91,18 +78,16 @@ export default async function checkPullRequestStandards({
}
}`;
try {
await withRetry(`comment ${minimized ? "minimize" : "restore"}`, () =>
github.graphql(mutation, { id: subjectId })
);
await github.graphql(mutation, { id: subjectId });
} catch (error) {
core.info(
`Could not ${minimized ? "minimize" : "restore"} comment: ${error instanceof Error ? error.message : error}`
`Could not ${minimized ? "minimize" : "restore"} comment: ${error.message}`
);
}
};
// Content of a "## <name>" section, or null when the heading is absent.
const section = (name: string) => {
const section = (name) => {
const match = body.match(
new RegExp(`##\\s${name}([\\s\\S]*?)(?=\\n##\\s|$)`, "i")
);
@@ -137,16 +122,13 @@ export default async function checkPullRequestStandards({
const isValid = problems.length === 0;
const comments = await withRetry("comment list", () =>
github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
})
);
const existing = comments.find((c) => c.body?.includes(marker));
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});
const existing = comments.find((c) => c.body.includes(marker));
const hasLabel = pr.labels.some((l) => l.name === label);
if (isValid) {
@@ -154,14 +136,12 @@ export default async function checkPullRequestStandards({
if (hasLabel) {
await ignoreMissing(() =>
withRetry("label removal", () =>
github.rest.issues.removeLabel({
owner,
repo,
issue_number,
name: label,
})
)
github.rest.issues.removeLabel({
owner,
repo,
issue_number,
name: label,
})
);
}
if (existing) {
@@ -173,14 +153,12 @@ export default async function checkPullRequestStandards({
core.info(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
if (!hasLabel) {
await withRetry("label addition", () =>
github.rest.issues.addLabels({
owner,
repo,
issue_number,
labels: [label],
})
);
await github.rest.issues.addLabels({
owner,
repo,
issue_number,
labels: [label],
});
}
const message =
@@ -196,17 +174,14 @@ export default async function checkPullRequestStandards({
`for more on creating a great pull request (see point 6).`;
if (existing) {
await withRetry("comment update", () =>
github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body: message,
})
);
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body: message,
});
await setMinimized(existing.node_id, false);
} else {
// Not retried, as a retry after GitHub created the comment would post it twice
await github.rest.issues.createComment({
owner,
repo,
@@ -5,35 +5,29 @@
// actions/github-script:
//
// const { default: checkTaskAuthorization } =
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mts`);
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`);
// await checkTaskAuthorization({ github, context, core });
import type { GitHubScriptArgs, IssuePayload } from "./github-script.d.ts";
import { withRetry } from "./github-retry.mts";
export default async function checkTaskAuthorization({
github,
context,
core,
}: GitHubScriptArgs<IssuePayload>) {
}) {
const issueAuthor = context.payload.issue.user.login;
// Check if user is an organization member
try {
await withRetry("organization membership check", () =>
github.rest.orgs.checkMembershipForUser({
org: "home-assistant",
username: issueAuthor,
})
);
await github.rest.orgs.checkMembershipForUser({
org: "home-assistant",
username: issueAuthor,
});
core.info(`✅ ${issueAuthor} is an organization member`);
return; // Authorized
} catch (_error) {
core.info(`❌ ${issueAuthor} is not authorized to create Task issues`);
}
// Close the issue with a comment. Not retried, as a retry after GitHub
// created the comment would post it twice.
// Close the issue with a comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
@@ -47,22 +41,18 @@ export default async function checkTaskAuthorization({
`If you believe you should have access to create Task issues, please contact the maintainers.`,
});
await withRetry("issue close", () =>
github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: "closed",
})
);
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: "closed",
});
// Add a label to indicate this was auto-closed
await withRetry("auto-closed label", () =>
github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ["auto-closed"],
})
);
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ["auto-closed"],
});
}
-69
View File
@@ -1,69 +0,0 @@
// Retries GitHub API requests with jittered exponential backoff. Shared by the
// workflow scripts in this directory, test/e2e/post-report-comment.mjs and the
// fetch-nightly-translations gulp task. Only wrap requests that are safe to
// repeat: a create that fails after GitHub has applied it would run twice.
//
// This replaces @octokit/plugin-retry, which only retries HTTP error statuses
// on a fixed 1s, 4s, 9s delay with no jitter or logging. GitHub can also
// answer 200 with briefly stale data, such as the nightly translations lookup
// returning an old run with no artifact, and the plugin cannot retry that.
// github-script also leaves its built-in retries off by default.
const RETRIES = 3;
const BASE_DELAY = 1000; // ms
const MAX_DELAY = 10000; // ms
const TRANSIENT_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
// Throw from a request to retry a response GitHub may answer differently on
// the next attempt, such as briefly stale data
export class RetryableError extends Error {}
// Octokit reports network failures as status 500
const isRetryable = (err: unknown) => {
if (err instanceof RetryableError) {
return true;
}
if (!(err instanceof Error) || !("status" in err)) {
return false;
}
const status = Number(err.status);
return (
TRANSIENT_STATUSES.has(status) ||
(status === 403 && /rate limit/i.test(err.message))
);
};
export async function withRetry<T>(
label: string,
request: () => Promise<T>
): Promise<T> {
for (let attempt = 1; ; attempt++) {
try {
// eslint-disable-next-line no-await-in-loop
return await request();
} catch (err) {
if (attempt > RETRIES || !isRetryable(err)) {
throw err;
}
const delay =
Math.min(BASE_DELAY * 2 ** (attempt - 1), MAX_DELAY) *
(0.8 + Math.random() * 0.4);
// eslint-disable-next-line no-console
console.log(
`Retrying ${label} after ${(delay / 1000).toFixed(1)}s (attempt ${attempt}/${RETRIES}): ${err instanceof Error ? err.message : err}`
);
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, delay);
});
}
}
}
-61
View File
@@ -1,61 +0,0 @@
// The arguments actions/github-script passes to the scripts in this directory,
// limited to the parts they use.
import type { Octokit } from "@octokit/rest";
export type SummaryTableCell = string | { data: string; header?: boolean };
export interface Summary {
addHeading(text: string, level?: number): Summary;
addRaw(text: string): Summary;
addTable(rows: SummaryTableCell[][]): Summary;
write(): Promise<Summary>;
}
export interface AnnotationProperties {
file?: string;
}
export interface Core {
info(message: string): void;
notice(message: string, properties?: AnnotationProperties): void;
warning(message: string, properties?: AnnotationProperties): void;
error(message: string, properties?: AnnotationProperties): void;
setFailed(message: string): void;
summary: Summary;
}
export interface User {
login: string;
type: string;
}
export interface PullRequestPayload {
pull_request: {
number: number;
body: string | null;
draft: boolean;
user: User;
labels: { name: string }[];
base: { sha: string };
head: { sha: string };
};
}
export interface IssuePayload {
issue: {
user: User;
};
}
export interface Context<Payload> {
sha: string;
repo: { owner: string; repo: string };
issue: { owner: string; repo: string; number: number };
payload: Payload;
}
export interface GitHubScriptArgs<Payload> {
github: Octokit;
context: Context<Payload>;
core: Core;
}
-56
View File
@@ -1,56 +0,0 @@
/**
* Builds a section of a workflow run's summary: a heading, a table of rows and
* the warnings raised along the way.
*
* Warnings are also logged as annotations, so a script can report problems
* without failing its step.
*/
import type { AnnotationProperties, Core } from "./github-script.d.ts";
interface SummaryOptions {
heading: string;
columns: string[];
// Shown instead of the table when no rows were added
empty: string;
}
export const createSummary = (
core: Core,
{ heading, columns, empty }: SummaryOptions
) => {
const rows: string[][] = [];
const warnings: string[] = [];
return {
addRow: (...cells: string[]) => {
rows.push(cells);
},
warn: (message: string, properties?: AnnotationProperties) => {
core.warning(message, properties);
warnings.push(message);
},
write: async () => {
core.summary.addHeading(heading, 2);
if (rows.length > 0) {
core.summary.addTable([
columns.map((data) => ({ data, header: true })),
...rows,
]);
} else {
core.summary.addRaw(`${empty}\n`);
}
if (warnings.length > 0) {
core.summary
.addHeading("Warnings", 3)
.addRaw(`${warnings.map((warning) => `- ${warning}`).join("\n")}\n`);
}
await core.summary.write();
},
};
};
-324
View File
@@ -1,324 +0,0 @@
#!/usr/bin/env node
/**
* Applies the Companion App, Home Assistant Link, Labs and Supervisor labels
* to pull requests.
*
* These areas are often changed from code outside their folders, which
* actions/labeler can't see, so this script owns them and .github/labeler.yml
* leaves them out.
*
* A label matches when the pull request:
* - changes a file in one of the area's paths, or
* - adds or removes a line that uses the area's API, such as gating a feature
* behind Labs or only showing it on Supervisor installs.
*
* Like actions/labeler, a label is removed once the pull request no longer
* matches, including one added by hand.
*
* Invoked from the `triage` job in .github/workflows/labeler.yaml.
*/
import type {
GitHubScriptArgs,
PullRequestPayload,
} from "./github-script.d.ts";
import { withRetry } from "./github-retry.mts";
import { createSummary } from "./github-summary.mts";
const RULES: { label: string; paths: RegExp[]; pattern: RegExp }[] = [
{
label: "Companion App",
paths: [
/^src\/external_app\//,
/^src\/data\/external\.ts$/,
/^src\/util\/is_ios\.ts$/,
],
// Reads or messages the companion app the frontend is running in, or
// imports its modules. Matches imports of data/external rather than
// isExternal, which is also a common local name.
pattern:
/\bauth\??\.external\b(?:[!?]*\.\w+)*(?:\([^)]*)?|\bfireExternalBusMessage\([^)]*|(?:\bimport\s[^;]*?from\s*|\bimport\(\s*)["'][^"']*\/(?:external_app\/[^"']*|data\/external)["']/g,
},
{
label: "Home Assistant Link",
paths: [
/^src\/panels\/config\/cloud\//,
/^src\/data\/cloud\//,
/^src\/data\/cloud\.ts$/,
],
// Only runs when Home Assistant Link is set up
pattern: /isComponentLoaded\([^)]*["']cloud["']/g,
},
{
label: "Labs",
paths: [/^src\/panels\/config\/labs\//, /^src\/data\/labs\.ts$/],
// Gates a feature behind, or toggles, a Labs preview feature
pattern:
/\b(?:subscribeLabFeatures?|fetchLabFeatures|labsUpdatePreviewFeature)\([^)]*/g,
},
{
label: "Supervisor",
paths: [
/^src\/panels\/config\/apps\//,
/^src\/panels\/app\//,
/^src\/data\/hassio\//,
/^src\/data\/supervisor\//,
],
// Only runs on installs with the Supervisor
pattern: /isComponentLoaded\([^)]*["']hassio["']/g,
},
];
interface HunkSide {
lines: string[];
changed: boolean[];
}
interface CodeChange {
code: string;
filename: string;
}
const parseHunks = (patch: string) => {
const hunks: { before: HunkSide; after: HunkSide }[] = [];
for (const line of patch.split("\n")) {
if (line.startsWith("@@")) {
hunks.push({
before: { lines: [], changed: [] },
after: { lines: [], changed: [] },
});
continue;
}
const hunk = hunks.at(-1);
if (!hunk || line.startsWith("\\")) {
continue;
}
const text = line.slice(1);
if (!line.startsWith("+")) {
hunk.before.lines.push(text);
hunk.before.changed.push(line.startsWith("-"));
}
if (!line.startsWith("-")) {
hunk.after.lines.push(text);
hunk.after.changed.push(line.startsWith("+"));
}
}
return hunks;
};
// Ignores whitespace and trailing commas outside strings, as Prettier changes
// them when wrapping
const normaliseCode = (code: string) => {
const parts = code.split(
/("(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`)/
);
return parts
.map((part, i) => {
if (i % 2 === 1) {
return part;
}
const stripped = part.replace(/\s+/g, "").replace(/,(?=[}\])])/g, "");
return i === parts.length - 1 ? stripped.replace(/,$/, "") : stripped;
})
.join("");
};
// Matches whole calls, which can span lines, that touch a changed line
const changedMatches = (side: HunkSide, pattern: RegExp) => {
const text = side.lines.join("\n");
const matches: string[] = [];
for (const match of text.matchAll(pattern)) {
const first = text.slice(0, match.index).split("\n").length - 1;
const last = first + match[0].split("\n").length - 1;
if (side.changed.slice(first, last + 1).some(Boolean)) {
matches.push(normaliseCode(match[0]));
}
}
return matches;
};
// Matches that were only moved, reindented or reformatted cancel out in pairs,
// returning a file with a match that doesn't
const unmatchedChange = (added: CodeChange[], removed: CodeChange[]) => {
const remaining = [...removed];
for (const change of added) {
const index = remaining.findIndex(({ code }) => code === change.code);
if (index === -1) {
return change.filename;
}
remaining.splice(index, 1);
}
return remaining[0]?.filename;
};
const describeError = (cause: unknown) =>
cause instanceof Error ? cause.message : String(cause);
const isNotFound = (cause: unknown) =>
cause instanceof Error && "status" in cause && cause.status === 404;
export default async function labelContent({
github,
context,
core,
}: GitHubScriptArgs<PullRequestPayload>) {
const pr = context.payload.pull_request;
const summary = createSummary(core, {
heading: "Content labels",
columns: ["Label", "Change", "Reason"],
empty: "No content labels changed.",
});
if (process.env.LABELER_OUTCOME === "failure") {
summary.warn(
"Applying labels from .github/labeler.yml failed, see its step"
);
}
// Bot pull requests, such as Prettier bumps, rewrite code they don't change
const checkCode = pr.user.type !== "Bot";
const { owner, repo } = context.repo;
let files;
let existing: Set<string>;
try {
// Read the current labels, as the event's snapshot can be stale by now
const [fileList, labels] = await Promise.all([
withRetry("pull request files", () =>
github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: pr.number,
per_page: 100,
})
),
withRetry("pull request labels", () =>
github.paginate(github.rest.issues.listLabelsOnIssue, {
owner,
repo,
issue_number: pr.number,
per_page: 100,
})
),
]);
files = fileList;
existing = new Set(labels.map((l) => l.name));
} catch (error) {
summary.warn(
`Could not read the pull request's files and labels: ${describeError(error)}`
);
await summary.write();
return;
}
const add: { label: string; reason: string }[] = [];
const remove: string[] = [];
for (const rule of RULES) {
const added: CodeChange[] = [];
const removed: CodeChange[] = [];
if (checkCode) {
for (const { filename, patch } of files) {
for (const { before, after } of parseHunks(patch ?? "")) {
for (const code of changedMatches(after, rule.pattern)) {
added.push({ code, filename });
}
for (const code of changedMatches(before, rule.pattern)) {
removed.push({ code, filename });
}
}
}
}
// Renames count for both locations, as moving a file out of an area changes it
const match =
files.find(({ filename, previous_filename }) =>
rule.paths.some(
(path) =>
path.test(filename) ||
(previous_filename !== undefined && path.test(previous_filename))
)
)?.filename ?? unmatchedChange(added, removed);
if (match && !existing.has(rule.label)) {
core.info(`Adding ${rule.label} for ${match}`);
add.push({ label: rule.label, reason: match });
} else if (!match && existing.has(rule.label)) {
core.info(`Removing ${rule.label}, no longer matched`);
remove.push(rule.label);
}
}
const removals = await Promise.allSettled(
remove.map((name) =>
withRetry("label removal", () =>
github.rest.issues.removeLabel({
owner,
repo,
issue_number: pr.number,
name,
})
)
)
);
removals.forEach((removal, i) => {
const name = remove[i];
// Already removed, such as by hand or a run that raced this one
if (removal.status === "fulfilled" || isNotFound(removal.reason)) {
summary.addRow(name, "Removed", "No longer matched");
} else {
summary.warn(
`Could not remove ${name}: ${describeError(removal.reason)}`
);
}
});
if (add.length > 0) {
try {
await withRetry("label addition", () =>
github.rest.issues.addLabels({
owner,
repo,
issue_number: pr.number,
labels: add.map(({ label }) => label),
})
);
for (const { label, reason } of add) {
summary.addRow(label, "Added", reason);
}
} catch (error) {
summary.warn(
`Could not add ${add.map(({ label }) => label).join(", ")}: ${describeError(error)}`
);
}
}
await summary.write();
}
-19
View File
@@ -1,19 +0,0 @@
{
"compilerOptions": {
"target": "ES2024",
"lib": ["ES2024"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
// actions/github-script imports these directly, relying on Node's type stripping
"erasableSyntaxOnly": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["*.mts", "*.d.ts"]
}
+35
View File
@@ -0,0 +1,35 @@
name: Blocking labels
on:
pull_request:
types:
- opened
- synchronize
- reopened
- labeled
- unlabeled
branches:
- dev
- master
permissions:
contents: read
jobs:
check:
name: Check for labels which block the Pull Request from being merged
runs-on: ubuntu-latest
steps:
- name: Check out workflow scripts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Check for blocking labels
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { default: checkBlockingLabels } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`
);
await checkBlockingLabels({ github, context, core });
+57 -3
View File
@@ -100,9 +100,12 @@ jobs:
run: pnpm run test
build:
name: Build frontend
needs:
- lint
- test
# Runs alongside lint and test rather than after them: the build only needs
# the dependency tree, and with the rspack cache it is no longer expensive
# enough to be worth serialising behind the other checks. The
# cancel-on-failure job below stops the run as soon as a check fails, so a
# broken pull request does not finish building.
needs: prepare-dependencies
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
@@ -148,3 +151,54 @@ jobs:
path: hass_frontend/
if-no-files-found: error
retention-days: 7
# Now that the checks run in parallel, a failing lint or test no longer stops
# the build from finishing on its own, so this watches them and cancels the
# whole run on the first failure.
#
# It is a separate job on purpose. Cancelling needs `actions: write`, and the
# other jobs check out the pull request and run its build scripts — handing
# them that scope would give PR-controlled code (or a compromised dependency)
# write access to Actions. This job never checks out the repository, so the
# elevated token stays away from PR code. It also cannot be a job that
# `needs` the checks: that would only start once they have all finished, which
# is exactly too late to cancel anything.
cancel-on-failure:
name: Cancel run on failure
needs: prepare-dependencies
runs-on: ubuntu-latest
permissions:
actions: write
timeout-minutes: 30
steps:
- name: Cancel the run when a check fails
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
run: |
watched='^(Lint and check format|Run tests|Build frontend)$'
while :; do
jobs=$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
--paginate --jq '.jobs[] | [.name, .status, (.conclusion // "")] | @tsv' \
2>/dev/null || true)
failed=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
'$1 ~ w && ($3 == "failure" || $3 == "timed_out") { print $1 }')
if [ -n "$failed" ]; then
echo "Cancelling the run, these checks failed:"
printf '%s\n' "$failed"
gh run cancel "$RUN_ID" --repo "$REPO" || true
exit 0
fi
found=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" '$1 ~ w' | wc -l)
running=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
'$1 ~ w && $2 != "completed" { print $1 }')
if [ "$found" -ge 3 ] && [ -z "$running" ]; then
echo "All checks finished without failure"
exit 0
fi
sleep 15
done
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
with:
category: "/language:javascript-typescript"
-55
View File
@@ -1,55 +0,0 @@
name: Dependency review
on:
pull_request:
branches:
- dev
paths:
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
schedule:
- cron: "0 6 1 * *" # Monthly report on all direct dependencies
workflow_dispatch:
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
dependency-review:
name: Review dependency changes
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read # To compare the dependency graph between base and head
steps:
- name: Dependency review
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
license-check: false # Covered by pnpm lint:licenses
fail-on-severity: high
show-openssf-scorecard: true
warn-on-openssf-scorecard-level: 3
npm-metadata:
name: Check npm metadata
runs-on: ubuntu-latest
permissions:
contents: read # To read package.json
steps:
- name: Check out workflow scripts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Check npm metadata
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { default: checkNpmMetadata } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-npm-metadata.mts`
);
await checkNpmMetadata({ github, context, core });
+4 -4
View File
@@ -38,7 +38,7 @@ jobs:
name: Prepare container dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.0-noble
image: mcr.microsoft.com/playwright:v1.63.0-noble
options: --user 1001
defaults:
run:
@@ -155,7 +155,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.0-noble
image: mcr.microsoft.com/playwright:v1.63.0-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -207,7 +207,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.0-noble
image: mcr.microsoft.com/playwright:v1.63.0-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -261,7 +261,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.0-noble
image: mcr.microsoft.com/playwright:v1.63.0-noble
options: --user 1001 --ipc=host
defaults:
run:
+1 -24
View File
@@ -1,11 +1,6 @@
name: "Pull Request Labeler"
on: pull_request_target # zizmor: ignore[dangerous-triggers] -- safe: checks out base repo scripts only and reads PR files through the API, never PR head code
# Runs one at a time per pull request, so an older run can't undo a newer one
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
on: pull_request_target # zizmor: ignore[dangerous-triggers] -- safe: only runs actions/labeler, no PR code checkout
jobs:
triage:
@@ -15,24 +10,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Apply labels
id: labeler
continue-on-error: true
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
with:
sync-labels: true
- name: Check out workflow scripts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Apply content labels
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
LABELER_OUTCOME: ${{ steps.labeler.outcome }}
with:
script: |
const { default: labelContent } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/label-content.mts`
);
await labelContent({ github, context, core });
@@ -33,6 +33,6 @@ jobs:
with:
script: |
const { default: checkStandards } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mts`
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`
);
await checkStandards({ github, context, core });
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60 # v3.2.4
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
with:
key: ${{ secrets.RELATIVE_CI_KEY_frontend_modern }}
token: ${{ github.token }}
@@ -31,7 +31,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60 # v3.2.4
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
with:
key: ${{ secrets.RELATIVE_CI_KEY_frontend_legacy }}
token: ${{ github.token }}
+1 -1
View File
@@ -19,6 +19,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@72967cdc98ddd3160f1fc3dff619de365e137dd0 # v7.9.0
- uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@06749dd8c0b54f350bc69c8752456cee498808a3 # master
uses: home-assistant/actions/helpers/verify-version@58bff37c8947f690ace498be413a9b78d6f30f93 # master
- name: Setup Node and install
uses: ./.github/actions/setup
+7 -18
View File
@@ -15,33 +15,22 @@ jobs:
name: Add no-stale label
runs-on: ubuntu-latest
permissions:
contents: read # To check out workflow scripts
issues: write # To add labels to issues
if: >-
github.event.issue.type.name == 'Task'
|| github.event.issue.type.name == 'Epic'
|| github.event.issue.type.name == 'Opportunity'
steps:
- name: Check out workflow scripts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Add no-stale label
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { withRetry } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/github-retry.mts`
);
await withRetry("no-stale label", () =>
github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['no-stale']
})
);
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['no-stale']
});
check-authorization:
name: Check authorization
@@ -62,6 +51,6 @@ jobs:
with:
script: |
const { default: checkTaskAuthorization } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mts`
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`
);
await checkTaskAuthorization({ github, context, core });
+2 -3
View File
@@ -15,12 +15,11 @@ jobs:
if: github.repository == 'home-assistant/frontend'
runs-on: ubuntu-latest
steps:
- name: Stale policy
- name: 90 days stale policy
uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-issue-stale: 90
days-before-pr-stale: 60
days-before-stale: 90
days-before-close: 7
operations-per-run: 25
remove-stale-when-updated: true
-1
View File
@@ -14,7 +14,6 @@ dist/
# Node
node_modules/
npm-debug.log
.pnpm-store/
# Python stuff
*.py[cod]
+162 -169
View File
@@ -1,201 +1,194 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Apache License
==============
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
_Version 2.0, January 2004_
_&lt;<http://www.apache.org/licenses/>&gt;_
1. Definitions.
### Terms and Conditions for use, reproduction, and distribution
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
#### 1. Definitions
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
“License” shall mean the terms and conditions for use, reproduction, and
distribution as defined by Sections 1 through 9 of this document.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
“Licensor” shall mean the copyright owner or entity authorized by the copyright
owner that is granting the License.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
“Legal Entity” shall mean the union of the acting entity and all other entities
that control, are controlled by, or are under common control with that entity.
For the purposes of this definition, “control” means **(i)** the power, direct or
indirect, to cause the direction or management of such entity, whether by
contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the
outstanding shares, or **(iii)** beneficial ownership of such entity.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
“You” (or “Your”) shall mean an individual or Legal Entity exercising
permissions granted by this License.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
“Source” form shall mean the preferred form for making modifications, including
but not limited to software source code, documentation source, and configuration
files.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
“Object” form shall mean any form resulting from mechanical transformation or
translation of a Source form, including but not limited to compiled object code,
generated documentation, and conversions to other media types.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
“Work” shall mean the work of authorship, whether in Source or Object form, made
available under the License, as indicated by a copyright notice that is included
in or attached to the work (an example is provided in the Appendix below).
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
“Derivative Works” shall mean any work, whether in Source or Object form, that
is based on (or derived from) the Work and for which the editorial revisions,
annotations, elaborations, or other modifications represent, as a whole, an
original work of authorship. For the purposes of this License, Derivative Works
shall not include works that remain separable from, or merely link (or bind by
name) to the interfaces of, the Work and Derivative Works thereof.
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
“Contribution” shall mean any work of authorship, including the original version
of the Work and any modifications or additions to that Work or Derivative Works
thereof, that is intentionally submitted to Licensor for inclusion in the Work
by the copyright owner or by an individual or Legal Entity authorized to submit
on behalf of the copyright owner. For the purposes of this definition,
“submitted” means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems, and
issue tracking systems that are managed by, or on behalf of, the Licensor for
the purpose of discussing and improving the Work, but excluding communication
that is conspicuously marked or otherwise designated in writing by the copyright
owner as “Not a Contribution.”
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
“Contributor” shall mean Licensor and any individual or Legal Entity on behalf
of whom a Contribution has been received by Licensor and subsequently
incorporated within the Work.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
#### 2. Grant of Copyright License
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
Subject to the terms and conditions of this License, each Contributor hereby
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
irrevocable copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the Work and such
Derivative Works in Source or Object form.
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
#### 3. Grant of Patent License
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
Subject to the terms and conditions of this License, each Contributor hereby
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
irrevocable (except as stated in this section) patent license to make, have
made, use, offer to sell, sell, import, and otherwise transfer the Work, where
such license applies only to those patent claims licensable by such Contributor
that are necessarily infringed by their Contribution(s) alone or by combination
of their Contribution(s) with the Work to which such Contribution(s) was
submitted. If You institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work or a
Contribution incorporated within the Work constitutes direct or contributory
patent infringement, then any patent licenses granted to You under this License
for that Work shall terminate as of the date such litigation is filed.
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
#### 4. Redistribution
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may reproduce and distribute copies of the Work or Derivative Works thereof
in any medium, with or without modifications, and in Source or Object form,
provided that You meet the following conditions:
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
* **(a)** You must give any other recipients of the Work or Derivative Works a copy of
this License; and
* **(b)** You must cause any modified files to carry prominent notices stating that You
changed the files; and
* **(c)** You must retain, in the Source form of any Derivative Works that You distribute,
all copyright, patent, trademark, and attribution notices from the Source form
of the Work, excluding those notices that do not pertain to any part of the
Derivative Works; and
* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any
Derivative Works that You distribute must include a readable copy of the
attribution notices contained within such NOTICE file, excluding those notices
that do not pertain to any part of the Derivative Works, in at least one of the
following places: within a NOTICE text file distributed as part of the
Derivative Works; within the Source form or documentation, if provided along
with the Derivative Works; or, within a display generated by the Derivative
Works, if and wherever such third-party notices normally appear. The contents of
the NOTICE file are for informational purposes only and do not modify the
License. You may add Your own attribution notices within Derivative Works that
You distribute, alongside or as an addendum to the NOTICE text from the Work,
provided that such additional attribution notices cannot be construed as
modifying the License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
You may add Your own copyright statement to Your modifications and may provide
additional or different license terms and conditions for use, reproduction, or
distribution of Your modifications, or for any such Derivative Works as a whole,
provided Your use, reproduction, and distribution of the Work otherwise complies
with the conditions stated in this License.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
#### 5. Submission of Contributions
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
Unless You explicitly state otherwise, any Contribution intentionally submitted
for inclusion in the Work by You to the Licensor shall be under the terms and
conditions of this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify the terms of
any separate license agreement you may have executed with Licensor regarding
such Contributions.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
#### 6. Trademarks
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
This License does not grant permission to use the trade names, trademarks,
service marks, or product names of the Licensor, except as required for
reasonable and customary use in describing the origin of the Work and
reproducing the content of the NOTICE file.
END OF TERMS AND CONDITIONS
#### 7. Disclaimer of Warranty
APPENDIX: How to apply the Apache License to your work.
Unless required by applicable law or agreed to in writing, Licensor provides the
Work (and each Contributor provides its Contributions) on an “AS IS” BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,
including, without limitation, any warranties or conditions of TITLE,
NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are
solely responsible for determining the appropriateness of using or
redistributing the Work and assume any risks associated with Your exercise of
permissions under this License.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
#### 8. Limitation of Liability
Copyright [yyyy] [name of copyright owner]
In no event and under no legal theory, whether in tort (including negligence),
contract, or otherwise, unless required by applicable law (such as deliberate
and grossly negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special, incidental,
or consequential damages of any character arising as a result of this License or
out of the use or inability to use the Work (including but not limited to
damages for loss of goodwill, work stoppage, computer failure or malfunction, or
any and all other commercial damages or losses), even if such Contributor has
been advised of the possibility of such damages.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
#### 9. Accepting Warranty or Additional Liability
http://www.apache.org/licenses/LICENSE-2.0
While redistributing the Work or Derivative Works thereof, You may choose to
offer, and charge a fee for, acceptance of support, warranty, indemnity, or
other liability obligations and/or rights consistent with this License. However,
in accepting such obligations, You may act only on Your own behalf and on Your
sole responsibility, not on behalf of any other Contributor, and only if You
agree to indemnify, defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason of your
accepting any such warranty or additional liability.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
_END OF TERMS AND CONDITIONS_
### APPENDIX: How to apply the Apache License to your work
To apply the Apache License to your work, attach the following boilerplate
notice, with the fields enclosed by brackets `[]` replaced with your own
identifying information. (Don't include the brackets!) The text should be
enclosed in the appropriate comment syntax for the file format. We also
recommend that a file or class name and description of purpose be included on
the same “printed page” as the copyright notice for easier identification within
third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+8 -8
View File
@@ -1,15 +1,15 @@
{
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
"frontend-modern": {
"app": 624240,
"core": 55144,
"authorize": 594400,
"onboarding": 705499
"app": 593517,
"core": 54884,
"authorize": 557490,
"onboarding": 676131
},
"frontend-legacy": {
"app": 776696,
"core": 188309,
"authorize": 763316,
"onboarding": 879807
"app": 738388,
"core": 188119,
"authorize": 716748,
"onboarding": 840680
}
}
-10
View File
@@ -1,10 +0,0 @@
// @ts-check
import { noUnusedElementImport } from "./no-unused-element-import.mjs";
export default {
meta: { name: "eslint-plugin-ha" },
rules: {
"no-unused-element-import": noUnusedElementImport,
},
};
@@ -1,188 +0,0 @@
// @ts-check
import fs from "node:fs";
import path from "node:path";
const TAG_PATTERN =
/@customElement\(\s*["'`]([a-z0-9_-]+)["'`]|customElements\.define\(\s*["'`]([a-z0-9_-]+)["'`]/g;
const EXTENSIONS = /\.(ts|js)$/;
// Entry points register the root element that the served HTML renders.
const ENTRY_POINT = /(^|[\\/])(entrypoints[\\/]|entrypoint\.(ts|js)$)/;
// These resolve a tag from config at runtime, so they register every candidate
// they may be asked for.
const RUNTIME_REGISTRY =
/[\\/]create-element[\\/]|createLovelaceElement\(|getLovelaceElementClass\(/;
/** @type {Map<string, string[]>} */
const tagCache = new Map();
/** Tags the module at `file` registers when imported. */
const registeredTags = (file) => {
let tags = tagCache.get(file);
if (!tags) {
try {
const source = fs.readFileSync(file, "utf8");
tags = [...source.matchAll(TAG_PATTERN)].map((m) => m[1] || m[2]);
} catch {
tags = [];
}
tagCache.set(file, tags);
}
return tags;
};
const isFile = (candidate) => {
try {
return fs.statSync(candidate).isFile();
} catch {
return false;
}
};
const resolveModule = (fromFile, request) => {
const base = path.resolve(path.dirname(fromFile), request);
return [
base,
`${base}.ts`,
`${base}.js`,
base.replace(/\.js$/, ".ts"),
path.join(base, "index.ts"),
path.join(base, "index.js"),
].find((candidate) => EXTENSIONS.test(candidate) && isFile(candidate));
};
// `hui-${type}-card` or "state-card-" + domain never spell the tag out.
const buildsTagAtRuntime = (source, tag) => {
const segments = tag.split("-");
for (let end = 1; end < segments.length; end++) {
const prefix = `${segments.slice(0, end).join("-")}-`;
if (
source.includes(`\`${prefix}\${`) ||
source.includes(`"${prefix}" +`) ||
source.includes(`'${prefix}' +`)
) {
return true;
}
}
return false;
};
// Plain substring matching, so <ha-icon-button> counts as a use of ha-icon.
// That direction is deliberate: it misses dead imports rather than asking for
// a removal that breaks registration.
const usesTag = (body, tag) => body.includes(tag);
// A gallery page registers the elements its sibling markdown demos render, so
// that markdown is part of what the module is used by.
const siblingMarkup = (file) => {
const markdown = file.replace(EXTENSIONS, ".markdown");
try {
return fs.readFileSync(markdown, "utf8");
} catch {
return "";
}
};
/** Blanks out the given ranges so an import path cannot count as a usage. */
const withoutRanges = (source, ranges) => {
let result = source;
for (const [start, end] of ranges) {
result =
result.slice(0, start) + " ".repeat(end - start) + result.slice(end);
}
return result;
};
/**
* Side-effect imports register a custom element. ESLint's unused-import rules
* cannot judge them — there is no binding to be unused — so a registration
* outlives the template that needed it and nothing reports the dead import.
*
* Deliberately not fixable: removing the import is only safe once every file
* that renders the element imports it itself, which this rule cannot see.
*
* Off in eslint.config.mjs while a backlog of those paired cases remains;
* `pnpm lint:element-imports` enables it as a warning to list them. Flip it to
* "error" there once the list is empty.
*
* No disable comments while it is off: ESLint reports them as unused
* directives, which fails lint:eslint. Imports that must stay get theirs in
* the change that turns the rule on.
*/
export const noUnusedElementImport = {
meta: {
type: "suggestion",
docs: {
description:
"Disallow side-effect imports of a custom element the importing file never uses",
},
schema: [],
messages: {
unused:
'"{{request}}" only registers <{{tags}}>, which this file never uses. Remove the import, adding it to whichever file renders the element if that file relies on this registration.',
},
},
create(context) {
const filename = context.filename;
if (!EXTENSIONS.test(filename) || ENTRY_POINT.test(filename)) {
return {};
}
return {
Program(program) {
const imports = program.body.filter(
(node) => node.type === "ImportDeclaration"
);
const sideEffectImports = imports.filter(
(node) =>
node.specifiers.length === 0 &&
typeof node.source.value === "string" &&
node.source.value.startsWith(".")
);
if (!sideEffectImports.length) {
return;
}
const source = context.sourceCode.getText();
if (RUNTIME_REGISTRY.test(filename) || RUNTIME_REGISTRY.test(source)) {
return;
}
// Every import declaration, not just the side-effect ones: a module
// path is never a usage, and `import type { HaCard } from "./ha-card"`
// would otherwise pass off its own path as one.
const body =
withoutRanges(
source,
imports.map((node) => node.range)
) + siblingMarkup(filename);
for (const node of sideEffectImports) {
const request = node.source.value;
const target = resolveModule(filename, request);
if (!target) {
continue;
}
// No tags means no registration to judge: styles, polyfills, mocks.
const tags = registeredTags(target);
if (
!tags.length ||
tags.some(
(tag) => usesTag(body, tag) || buildsTagAtRuntime(body, tag)
)
) {
continue;
}
context.report({
node,
messageId: "unused",
data: { request, tags: tags.join(">, <") },
});
}
},
};
},
};
@@ -1,6 +1,7 @@
// Task to download the latest Lokalise translations from the nightly workflow artifacts
import { createOAuthDeviceAuth } from "@octokit/auth-oauth-device";
import { retry } from "@octokit/plugin-retry";
import { Octokit } from "@octokit/rest";
import { deleteAsync } from "del";
import { mkdir, readFile, writeFile } from "fs/promises";
@@ -9,20 +10,11 @@ import jszip from "jszip";
import path from "path";
import process from "process";
import { extract } from "tar";
import {
RetryableError,
withRetry,
} from "../../.github/scripts/github-retry.mts";
type Artifact = Awaited<
ReturnType<Octokit["rest"]["actions"]["listArtifactsForRepo"]>
>["data"]["artifacts"][number];
const MAX_AGE = 24; // hours
const OWNER = "home-assistant";
const REPO = "frontend";
const BRANCH = "dev";
const WORKFLOW_NAME = "nightly.yaml";
const ARTIFACT_NAME = "translations";
const CLIENT_ID = "Iv1.3914e28cb27834d1";
const EXTRACT_DIR = "translations";
@@ -44,16 +36,11 @@ gulp.task("fetch-nightly-translations", async function () {
// Read current translations artifact info if it exists,
// and stop if they are not old enough
let currentArtifact: Artifact | null;
let currentArtifact;
try {
const artifact: Artifact = JSON.parse(
await readFile(ARTIFACT_FILE, "utf-8")
);
currentArtifact = artifact;
currentArtifact = JSON.parse(await readFile(ARTIFACT_FILE, "utf-8"));
const currentAge =
(Date.now() - Date.parse(artifact.created_at ?? "")) / 3600000;
(Date.now() - Date.parse(currentArtifact.created_at)) / 3600000;
if (currentAge < MAX_AGE) {
console.log(
"Keeping current translations (only %s hours old)",
@@ -76,19 +63,19 @@ gulp.task("fetch-nightly-translations", async function () {
}
console.warn(
"Failed to fetch nightly translations, continuing with English only:",
err instanceof Error ? err.message : err
err?.message || err
);
}
});
async function fetchTranslations(currentArtifact: Artifact | null) {
async function fetchTranslations(currentArtifact) {
// To store file writing promises
const createExtractDir = mkdir(EXTRACT_DIR, { recursive: true });
const writings: Promise<void>[] = [];
const writings = [];
// Authenticate to GitHub using GitHub action token if it exists,
// otherwise look for a saved user token or generate a new one if none
let tokenAuth: { token: string };
let tokenAuth;
if (process.env.GITHUB_TOKEN) {
tokenAuth = { token: process.env.GITHUB_TOKEN };
} else {
@@ -117,86 +104,69 @@ async function fetchTranslations(currentArtifact: Artifact | null) {
});
tokenAuth = await auth({ type: "oauth" });
writings.push(
createExtractDir.then(() =>
createExtractDir.then(
writeFile(TOKEN_FILE, JSON.stringify(tokenAuth, null, 2))
)
);
}
}
// Authenticate with token and request workflow runs from GitHub
console.log("Fetching new translations...");
// Authenticate with token and find the newest translations artifact
const octokit = new Octokit({
const octokit = new (Octokit.plugin(retry))({
userAgent: "Fetch Nightly Translations",
auth: tokenAuth.token,
});
// Only the nightly workflow uploads this artifact. Requiring a run from this
// repository's own branch excludes artifacts uploaded by fork pull requests.
const latestArtifact = await withRetry(
"translations artifact lookup",
async () => {
const { data } = await octokit.rest.actions.listArtifactsForRepo({
owner: OWNER,
repo: REPO,
name: ARTIFACT_NAME,
per_page: 10,
});
const workflowRunsResponse = await octokit.rest.actions.listWorkflowRuns({
owner: OWNER,
repo: REPO,
workflow_id: WORKFLOW_NAME,
status: "success",
event: "schedule",
per_page: 1,
exclude_pull_requests: true,
});
if (workflowRunsResponse.data.total_count === 0) {
throw Error("No successful nightly workflow runs found");
}
const latestNightlyRun = workflowRunsResponse.data.workflow_runs[0];
const artifact = data.artifacts.find(
({ expired, workflow_run: run }) =>
!expired &&
run?.head_branch === BRANCH &&
run.head_repository_id === run.repository_id
);
if (!artifact) {
throw new RetryableError(
`No ${ARTIFACT_NAME} artifact found from ${BRANCH}`
);
}
return artifact;
}
);
console.log(
"Latest translations artifact is %s from workflow run %s (%s)",
latestArtifact.id,
latestArtifact.workflow_run?.id,
latestArtifact.created_at
);
// Stop if current is already the latest
if (currentArtifact?.id === latestArtifact.id) {
// Stop if current is already the latest, otherwise Find the translations artifact
if (currentArtifact?.workflow_run.id === latestNightlyRun.id) {
console.log("Stopping because current translations are still the latest");
return;
}
const latestArtifact = (
await octokit.actions.listWorkflowRunArtifacts({
owner: OWNER,
repo: REPO,
run_id: latestNightlyRun.id,
})
).data.artifacts.find((artifact) => artifact.name === ARTIFACT_NAME);
if (!latestArtifact) {
throw Error("Latest nightly workflow run has no translations artifact");
}
// Remove the current translations
const deleteCurrent = Promise.all(writings).then(() =>
const deleteCurrent = Promise.all(writings).then(
deleteAsync([`${EXTRACT_DIR}/*`, `!${ARTIFACT_FILE}`, `!${TOKEN_FILE}`])
);
// Get the download URL and follow the redirect to download (stored as ArrayBuffer)
const downloadResponse = await withRetry("translations download", () =>
octokit.rest.actions.downloadArtifact({
owner: OWNER,
repo: REPO,
artifact_id: latestArtifact.id,
archive_format: "zip",
})
);
// Octokit types the redirect, but fetch follows it to the archive
if ((downloadResponse.status as number) !== 200) {
const downloadResponse = await octokit.actions.downloadArtifact({
owner: OWNER,
repo: REPO,
artifact_id: latestArtifact.id,
archive_format: "zip",
});
if (downloadResponse.status !== 200) {
throw Error("Failure downloading translations artifact");
}
// Artifact is a tarball, but GitHub adds it to a zip file
console.log("Unpacking downloaded translations...");
const zip = await jszip.loadAsync(downloadResponse.data as ArrayBuffer);
const zip = await jszip.loadAsync(downloadResponse.data);
await deleteCurrent;
const extractStream = zip.file(/.*/)[0].nodeStream().pipe(extract());
await new Promise((resolve, reject) => {
+4
View File
@@ -77,6 +77,10 @@ async function copyMapPanel(staticDir) {
// Style, glyphs and sprites for the vector base map
await ensureMapAssets();
fs.copySync(mapAssetsDir, staticPath("map/"));
copyFileDir(
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
staticPath("map/")
);
// Controls and popups of the native MapLibre engine
copyFileDir(npmPath("maplibre-gl/dist/maplibre-gl.css"), staticPath("map/"));
}
@@ -69,7 +69,7 @@ export const SENSOR_STATE_CLASSES: string[] = [
${stateClasses.map((stateClass) => ` "${stateClass}",`).join("\n")}
];
export const SENSOR_STATE_CLASS_UNITS: Record<string, (string | null)[]> = {
export const SENSOR_STATE_CLASS_UNITS: Record<string, string[]> = {
${Object.entries(stateClassUnits)
.map(
([stateClass, units]) =>
+1 -1
View File
@@ -6,7 +6,7 @@ import "./demo.js";
import "./download-translations.js";
import "./e2e-test-app.js";
import "./entry-html.js";
import "./fetch-nightly-translations.mts";
import "./fetch-nightly-translations.js";
import "./gallery.js";
import "./gather-static.js";
import "./gen-demo-core-data.js";
+1 -1
View File
@@ -29,7 +29,7 @@ const LICENSE_OVERRIDES = [
// type-fest ships two license files (MIT for code, CC0 for types).
// We use the MIT license since that covers the bundled code.
packageName: "type-fest",
version: "5.10.0",
version: "5.8.0",
licenseFile: "license-mit",
},
];
+1 -1
View File
@@ -10,7 +10,7 @@ import { finished } from "node:stream/promises";
import env from "../env.cjs";
import paths from "../paths.cjs";
import { mergeTranslations } from "./merge-translations.js";
import "./fetch-nightly-translations.mts";
import "./fetch-nightly-translations.js";
const inFrontendDir = "translations/frontend";
const inBackendDir = "translations/backend";
+6 -9
View File
@@ -54,9 +54,9 @@ const CONFIG_FILES = [
path.join(__dirname, "babel-plugins", "inline-constants-plugin.cjs"),
];
// Patches change a package's files but not its version, which is all the
// Yarn patches change a package's files but not its version, which is all the
// node_modules snapshot checks, so their contents go into the version too.
const PATCHES_DIR = path.join(paths.root_dir, "patches");
const YARN_PATCHES_DIR = path.join(paths.root_dir, ".yarn", "patches");
// Content hash of the toolchain versions and our own build files, used as the
// persistent cache `version`. Everything here is path-independent so the cache
@@ -69,12 +69,12 @@ const cacheVersion = () => {
...CONFIG_FILES.map(
(file) => `${path.basename(file)}:${fs.readFileSync(file, "utf8")}`
),
...(existsSync(PATCHES_DIR) ? fs.readdirSync(PATCHES_DIR) : [])
...(existsSync(YARN_PATCHES_DIR) ? fs.readdirSync(YARN_PATCHES_DIR) : [])
.filter((file) => file.endsWith(".patch"))
.sort()
.map(
(file) =>
`${file}:${fs.readFileSync(path.join(PATCHES_DIR, file), "utf8")}`
`${file}:${fs.readFileSync(path.join(YARN_PATCHES_DIR, file), "utf8")}`
),
];
return require("crypto")
@@ -115,10 +115,7 @@ const createRspackConfig = ({
dontHash = new Set();
}
const ignorePackages = bundle.ignorePackages({ latestBuild });
// pnpm links node_modules/lit-html into its store; rspack compares real paths
const litHtmlRoot = fs.realpathSync(
path.resolve(__dirname, "../node_modules/lit-html")
);
const litHtmlRoot = path.resolve(__dirname, "../node_modules/lit-html");
const litHtmlDevelopmentRoot = path.join(litHtmlRoot, "development");
const litDisableDevModeLoader = path.join(
__dirname,
@@ -492,7 +489,7 @@ const createRspackConfig = ({
// `name` is already unique per variant (frontend-modern/-legacy).
name,
// Content-based version (node major + toolchain versions + our own
// build files + patches). Everything is path-independent, so
// build files + Yarn patches). Everything is path-independent, so
// the cache stays valid when reused on another machine/checkout.
// Runtime deps are deliberately absent — rspack's node_modules
// snapshot invalidates their modules per-package, so a single
+2 -2
View File
@@ -27,7 +27,6 @@ import type {
LovelaceConfig,
LovelaceDashboardStrategyConfig,
} from "../../../../src/data/lovelace/config/types";
import { normalizeLovelaceConfig } from "../../../../src/data/lovelace/config/normalize";
import { isStrategyDashboard } from "../../../../src/data/lovelace/config/types";
import { fetchResources } from "../../../../src/data/lovelace/resource";
import { loadLovelaceResources } from "../../../../src/panels/lovelace/common/load-resources";
@@ -35,6 +34,7 @@ import { HassElement } from "../../../../src/state/hass-element";
import { castContext } from "../cast_context";
import "./hc-launch-screen";
import { getPanelTitleFromUrlPath } from "../../../../src/data/panel";
import { checkLovelaceConfig } from "../../../../src/panels/lovelace/common/check-lovelace-config";
const DEFAULT_CONFIG: LovelaceDashboardStrategyConfig = {
strategy: {
@@ -359,7 +359,7 @@ export class HcMain extends HassElement {
this._urlPath || "lovelace"
);
castContext.setApplicationState(title || "");
this._lovelaceConfig = normalizeLovelaceConfig(
this._lovelaceConfig = checkLovelaceConfig(
lovelaceConfig
) as LovelaceConfig;
}
-13
View File
@@ -84,7 +84,6 @@ const CONFIG_PANEL_COMMANDS = [
"assist_pipeline/",
"config/entity_registry/settings/",
"slugify",
"marketplace/",
...connectivityCommands,
];
@@ -115,23 +114,11 @@ export class HaDemo extends HomeAssistantAppEl {
"assist_pipeline",
"hassio",
"hardware",
"marketplace",
...connectivityComponents,
...automationPlatformDomains,
]),
],
},
panels: {
...hass.panels,
// Opened from the settings page, not listed in the sidebar
marketplace: {
component_name: "marketplace",
icon: null,
title: null,
config: null,
url_path: "marketplace",
},
},
});
const localizePromise =
-3
View File
@@ -39,9 +39,6 @@
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
/* Prevent iOS Safari from inflating text when rotating to landscape */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
@media (prefers-color-scheme: dark) {
html {
+6 -10
View File
@@ -8,21 +8,17 @@ export const mockAuth = (hass: MockHomeAssistant) => {
path: msg.path,
}));
let name: string | undefined;
let user = hass.user!;
// The profile dialog refreshes the user subscription after saving, which
// writes the updated user back to hass.user. Read the current user on each
// call so later changes to it, such as a non-admin user, are kept.
hass.mockWS("auth/current_user", (_msg, currentHass: MockHomeAssistant) =>
name && currentHass.user ? { ...currentHass.user, name } : currentHass.user
);
// writes the updated user back to hass.user.
hass.mockWS("auth/current_user", () => user);
hass.mockWS(
"person/update_own_profile",
(msg: Partial<OwnProfileMutableParams>, currentHass: MockHomeAssistant) => {
(msg: Partial<OwnProfileMutableParams>) => {
if (msg.name) {
name = msg.name;
user = { ...user, name: msg.name };
}
return { user_name: name ?? currentHass.user?.name, person: null };
return { user_name: user.name, person: null };
}
);
};
-1
View File
@@ -39,7 +39,6 @@ const backupInfo: BackupInfo = {
},
],
agent_errors: {},
agent_error_translations: {},
last_attempted_automatic_backup: recent,
last_completed_automatic_backup: recent,
last_action_event: { manager_state: "idle" },
-2
View File
@@ -12,7 +12,6 @@ import { mockDeviceAutomation } from "./device_automation";
import { mockEntityRegistrySettings } from "./entity_registry_settings";
import { mockEntitySources } from "./entity_sources";
import { mockExpose } from "./expose";
import { mockMarketplace } from "./marketplace";
import { mockNetwork } from "./network";
import { mockPerson } from "./person";
import { mockScene } from "./scene";
@@ -46,7 +45,6 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
mockAssist(hass);
mockEntityRegistrySettings(hass);
mockSlugify(hass);
mockMarketplace(hass);
hass.mockWS("llm/api/list", () => ({
apis: [
{ id: "assist", name: "Assist" },
-9
View File
@@ -91,15 +91,6 @@ export const demoConfigEntries: {
supports_options: true,
},
},
{
type: "system",
entry: {
...baseEntry,
entry_id: "mock-marketplace",
domain: "marketplace",
title: "Marketplace",
},
},
...connectivityConfigEntries,
];
+1 -8
View File
@@ -221,14 +221,7 @@ const personEntity = (person: DemoPerson, now: number): EntityInput => {
const personHistory = (person: DemoPerson, now: number): EntityHistoryState[] =>
person.timeline.map(([hoursAgo, state]) => {
const time = (now - hoursAgo * HOUR) / 1000;
const zoneId =
state === "home" ? "home" : zones.find((zone) => zone.name === state)?.id;
return {
s: state,
a: { in_zones: zoneId ? [`zone.${zoneId}`] : [] },
lu: time,
lc: time,
};
return { s: state, a: {}, lu: time, lc: time };
});
export const mockMap = (hass: MockHomeAssistant) => {
-209
View File
@@ -1,209 +0,0 @@
import type { MarketplaceInfo } from "../../../src/data/marketplace/marketplace";
import type {
MarketplaceRelease,
RepositoryBase,
RepositoryInfo,
} from "../../../src/data/marketplace/repository";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const baseRepository = {
can_install: true,
config_flow: false,
custom: false,
domain: null,
file_name: "",
hide: false,
homeassistant: null,
installed: false,
installed_version: "",
local_path: "",
new: false,
pending_upgrade: false,
status: "default" as const,
};
const repositories: RepositoryBase[] = [
{
...baseRepository,
id: "1",
category: "integration",
full_name: "AlexxIT/WebRTC",
name: "WebRTC Camera",
description: "Watch camera streams with low latency through WebRTC.",
authors: ["@AlexxIT"],
domain: "webrtc",
config_flow: true,
available_version: "v3.6.1",
downloads: 98000,
stars: 1900,
last_updated: "2026-09-12T08:21:00Z",
topics: ["camera", "webrtc"],
},
{
...baseRepository,
id: "2",
category: "integration",
full_name: "basnijholt/adaptive-lighting",
name: "Adaptive Lighting",
description:
"Change the brightness and color temperature of your lights over the day.",
authors: ["@basnijholt"],
domain: "adaptive_lighting",
config_flow: true,
available_version: "v1.26.0",
installed: true,
installed_version: "v1.26.0",
status: "installed",
downloads: 120000,
stars: 2300,
last_updated: "2026-08-30T17:02:00Z",
topics: ["lights", "circadian-rhythm"],
},
{
...baseRepository,
id: "3",
category: "integration",
full_name: "blakeblackshear/frigate-hass-integration",
name: "Frigate",
description: "Use the cameras and events of a Frigate NVR.",
authors: ["@blakeblackshear", "@dermotduffy", "@NickM-27"],
domain: "frigate",
config_flow: true,
available_version: "v5.9.0",
installed: true,
installed_version: "v5.8.0",
pending_upgrade: true,
status: "pending-upgrade",
downloads: 150000,
stars: 900,
last_updated: "2026-09-25T11:45:00Z",
topics: ["camera", "nvr"],
},
{
...baseRepository,
id: "4",
category: "plugin",
full_name: "piitaya/lovelace-mushroom",
name: "Mushroom",
description: "A collection of cards to build a clean dashboard.",
authors: ["@piitaya"],
file_name: "mushroom.js",
available_version: "v5.1.0",
installed: true,
installed_version: "v5.1.0",
status: "installed",
downloads: 310000,
stars: 4400,
last_updated: "2026-09-02T09:10:00Z",
topics: ["cards", "dashboard"],
},
{
...baseRepository,
id: "5",
category: "plugin",
full_name: "thomasloven/lovelace-card-mod",
name: "card-mod",
description: "Add CSS styles to any card of your dashboard.",
authors: ["@thomasloven"],
file_name: "card-mod.js",
available_version: "v4.0.0",
downloads: 280000,
stars: 1500,
last_updated: "2026-07-19T14:33:00Z",
topics: ["css", "dashboard"],
},
{
...baseRepository,
id: "6",
category: "plugin",
full_name: "custom-cards/button-card",
name: "button-card",
description: "A button card that you can change in almost every way.",
authors: ["@RomRider"],
file_name: "button-card.js",
available_version: "v5.0.0",
new: true,
status: "new",
downloads: 240000,
stars: 2100,
last_updated: "2026-09-28T19:05:00Z",
topics: ["button", "cards"],
},
{
...baseRepository,
id: "7",
category: "theme",
full_name: "catppuccin/home-assistant",
name: "Catppuccin",
description: "Soothing pastel themes for Home Assistant.",
authors: ["@catppuccin"],
file_name: "catppuccin.yaml",
available_version: "v2.1.0",
downloads: 45000,
stars: 300,
last_updated: "2026-06-11T07:48:00Z",
topics: ["theme"],
},
];
const repositoryInfo = (repository: RepositoryBase): RepositoryInfo => ({
...repository,
additional_info: `# ${repository.name}\n\n${repository.description}\n\nThis is a demo. Installing is not available.`,
default_branch: "main",
issues: 12,
releases: [repository.available_version],
ref: repository.available_version,
replaces_built_in: false,
selected_tag: null,
update_entity_id: null,
version_or_commit: "version",
});
const info: MarketplaceInfo = {
categories: ["integration", "plugin", "theme"],
disabled_reason: null,
github_connected: false,
has_pending_tasks: false,
lovelace_mode: "storage",
stage: "running",
startup: false,
version: "2.0.0",
warning_accepted: true,
};
// Commands that change the Marketplace are not mocked, so the data never
// changes and no signal fires.
export const mockMarketplace = (hass: MockHomeAssistant) => {
hass.mockWS("marketplace/info", () => info);
hass.mockWS("marketplace/repositories/list", () => repositories);
hass.mockWS(
"marketplace/repository/info",
({ repository_id }: { repository_id: string }) => {
const repository = repositories.find((repo) => repo.id === repository_id);
return repository
? repositoryInfo(repository)
: Promise.reject({ code: "not_found", message: "Unknown repository" });
}
);
hass.mockWS(
"marketplace/repository/releases",
({ repository_id }: { repository_id: string }): MarketplaceRelease[] => {
const repository = repositories.find((repo) => repo.id === repository_id);
return repository
? [
{
tag: repository.available_version,
name: repository.available_version,
published_at: String(repository.last_updated),
prerelease: false,
},
]
: [];
}
);
hass.mockWS("marketplace/subscribe", () => () => undefined);
};
+4 -33
View File
@@ -60,7 +60,7 @@ const generateSumStatistics = (
end: Date,
period: "5minute" | "hour" | "day" | "month" = "hour",
initValue: number,
maxDiff: number | ((start: Date, end: Date) => number)
maxDiff: number
): StatisticValue[] => {
const statistics: StatisticValue[] = [];
let currentDate = new Date(start);
@@ -69,9 +69,7 @@ const generateSumStatistics = (
const now = new Date();
while (end > currentDate && currentDate < now) {
const nextDate = getNextDate(currentDate, period);
const bound =
typeof maxDiff === "function" ? maxDiff(currentDate, nextDate) : maxDiff;
const add = Math.random() * bound;
const add = Math.random() * maxDiff;
sum += add;
statistics.push({
start: currentDate.getTime(),
@@ -138,15 +136,6 @@ const statisticsFunctions: Record<
period: "5minute" | "hour" | "day" | "month"
) => StatisticValue[]
> = {
"sensor.energy_water": (_id, start, end, period = "hour") =>
generateSumStatistics(
start,
end,
period,
4000,
(bucketStart, bucketEnd) =>
(20 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
),
"sensor.energy_consumption_tarif_1": (
_id: string,
start: Date,
@@ -312,11 +301,6 @@ const statisticsFunctions: Record<
return [...morning, ...production, ...evening, ...rest];
},
};
// Prices are per source unit; water uses litres (4 currency units per m³).
const derivedCosts: Record<string, { source: string; price: number }> = {
"sensor.energy_water_cost": { source: "sensor.energy_water", price: 0.004 },
};
export const mockRecorder = (mockHass: MockHomeAssistant) => {
mockHass.mockWS(
"recorder/get_statistics_metadata",
@@ -333,23 +317,10 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
const end = end_time ? new Date(end_time) : new Date();
const statistics: Record<string, StatisticValue[]> = {};
const cache: Record<string, StatisticValue[]> = {};
const getStats = (id: string) => {
cache[id] ??= statisticsFunctions[id](id, start, end, period);
return cache[id];
};
statistic_ids.forEach((id: string) => {
if (id in derivedCosts) {
const { source, price } = derivedCosts[id];
let cost = 0;
statistics[id] = getStats(source).map((statistic) => {
const change = statistic.change! * price;
cost += change;
return { ...statistic, change, state: cost, sum: cost };
});
} else if (id in statisticsFunctions) {
statistics[id] = getStats(id);
if (id in statisticsFunctions) {
statistics[id] = statisticsFunctions[id](id, start, end, period);
} else {
const entityState = hass.states[id];
const state = entityState ? Number(entityState.state) : 1;
+1 -7
View File
@@ -12,7 +12,6 @@ import { configs as wcConfigs } from "eslint-plugin-wc";
import { configs as a11yConfigs } from "eslint-plugin-lit-a11y";
import html from "@html-eslint/eslint-plugin";
import importX from "eslint-plugin-import-x";
import ha from "./build-scripts/eslint-rules/index.mjs";
const rspackConfigPath = fileURLToPath(
new URL("./rspack.config.cjs", import.meta.url)
@@ -34,7 +33,6 @@ export default tseslint.config(
{
plugins: {
"unused-imports": unusedImports,
ha,
},
languageOptions: {
@@ -233,10 +231,6 @@ export default tseslint.config(
],
"unused-imports/no-unused-imports": "error",
// Off because the existing backlog would fail lint:eslint's
// --max-warnings=0; run lint:element-imports to see it. Disable
// comments for it would be reported as unused, so none until it is on.
"ha/no-unused-element-import": "off",
"lit/attribute-names": "error",
"lit/attribute-value-entities": "off",
"lit/no-template-map": "off",
@@ -296,7 +290,7 @@ export default tseslint.config(
},
},
{
files: [".github/scripts/*.mts"],
files: [".github/scripts/*.mjs"],
languageOptions: {
globals: globals.node,
},
-2
View File
@@ -40,7 +40,6 @@ export default [
"ha-select-box",
"ha-input",
"ha-textarea",
"ha-picker-combo-box",
],
},
{
@@ -53,7 +52,6 @@ export default [
"ha-control-switch",
"ha-slider",
"ha-control-slider",
"ha-control-scrubber",
"ha-control-circular-slider",
"ha-control-number-buttons",
"ha-control-select",
+5 -2
View File
@@ -1,5 +1,6 @@
import { html, css, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../src/components/ha-formfield";
import "../../../src/components/ha-switch";
import type { HomeAssistant } from "../../../src/types";
import "./demo-card";
@@ -17,7 +18,9 @@ class DemoCards extends LitElement {
render() {
return html`
<ha-demo-options>
<ha-switch @change=${this._showConfigToggled}>Show config</ha-switch>
<ha-formfield label="Show config">
<ha-switch @change=${this._showConfigToggled}> </ha-switch>
</ha-formfield>
</ha-demo-options>
<div id="container">
<div class="cards">
@@ -48,7 +51,7 @@ class DemoCards extends LitElement {
demo-card {
margin: 16px 16px 32px;
}
ha-switch {
ha-formfield {
margin-right: 16px;
}
#container {
+5 -2
View File
@@ -1,5 +1,6 @@
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../src/components/ha-formfield";
import "../../../src/components/ha-switch";
import type { HomeAssistant } from "../../../src/types";
import "../ha-demo-options";
@@ -16,7 +17,9 @@ class DemoMoreInfos extends LitElement {
render() {
return html`
<ha-demo-options>
<ha-switch @change=${this._showConfigToggled}>Show config</ha-switch>
<ha-formfield label="Show config">
<ha-switch @change=${this._showConfigToggled}> </ha-switch>
</ha-formfield>
</ha-demo-options>
<div id="container">
<div class="cards">
@@ -46,7 +49,7 @@ class DemoMoreInfos extends LitElement {
demo-more-info {
margin: var(--ha-space-4) var(--ha-space-4) var(--ha-space-8);
}
ha-switch {
ha-formfield {
margin-right: var(--ha-space-4);
}
`;
+4 -1
View File
@@ -6,10 +6,12 @@ import "../../../src/components/ha-settings-row";
import "../../../src/components/ha-switch";
import type { HaSwitch } from "../../../src/components/ha-switch";
import "../../../src/components/ha-theme-settings";
import type { ThemeSettings } from "../../../src/types";
import type { HomeAssistant, ThemeSettings } from "../../../src/types";
@customElement("gallery-settings")
class GallerySettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public themeSettings!: ThemeSettings;
@property({ type: Boolean }) public narrow = false;
@@ -24,6 +26,7 @@ class GallerySettings extends LitElement {
Configure how the gallery renders component previews and examples.
</div>
<ha-theme-settings
.hass=${this.hass}
.selectedTheme=${this.themeSettings}
.narrow=${this.narrow}
.heading=${"Theme"}
-10
View File
@@ -151,16 +151,6 @@ export const createMediaPlayerEntities = () => [
supported_features: 289,
},
},
{
entity_id: "media_player.theater_off_assumed",
state: "off",
attributes: {
friendly_name: "TV Off (assumed state, cannot be switched on)",
assumed_state: true,
// Off + Next + Pause
supported_features: 289,
},
},
{
entity_id: "media_player.theater_on_static",
state: "on",
+1 -15
View File
@@ -9,7 +9,6 @@ import { ifDefined } from "lit/directives/if-defined";
import { applyThemesOnElement } from "../../src/common/dom/apply_themes_on_element";
import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
import type { HASSDomEvent } from "../../src/common/dom/fire_event";
import { listenMediaQuery } from "../../src/common/dom/media_query";
import { computeEntityNameDisplayWithoutContext } from "../../src/common/entity/compute_entity_name_display";
import { setDirectionStyles } from "../../src/common/util/compute_rtl";
import "../../src/components/ha-button";
@@ -32,7 +31,6 @@ import {
floorsContext,
formattersContext,
internationalizationContext,
reducedMotionContext,
registriesContext,
servicesContext,
statesContext,
@@ -163,13 +161,6 @@ class HaGallery extends LitElement {
floors: new ContextProvider(this, { context: floorsContext }),
};
private _reducedMotionProvider = new ContextProvider(this, {
context: reducedMotionContext,
initialValue: false,
});
private _unsubReducedMotion?: () => void;
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Refresh the fallback contexts before each render so theme/page changes in
@@ -242,6 +233,7 @@ class HaGallery extends LitElement {
${
isSettingsPage
? html`<gallery-settings
.hass=${this._galleryHass}
.themeSettings=${this._themeSettings}
.narrow=${this._narrow}
.rtl=${this._rtl}
@@ -275,10 +267,6 @@ class HaGallery extends LitElement {
connectedCallback() {
super.connectedCallback();
mql.addEventListener("change", this._systemDarkChanged);
this._unsubReducedMotion = listenMediaQuery(
"(prefers-reduced-motion: reduce)",
(matches) => this._reducedMotionProvider.setValue(matches)
);
}
firstUpdated(changedProps: PropertyValues<this>) {
@@ -312,8 +300,6 @@ class HaGallery extends LitElement {
super.disconnectedCallback();
mql.removeEventListener("change", this._systemDarkChanged);
window.removeEventListener("hashchange", this._hashChanged);
this._unsubReducedMotion?.();
this._unsubReducedMotion = undefined;
}
private _hashChanged = () => {
-5
View File
@@ -12,11 +12,6 @@
<script type="module" src="<%= entry %>"></script>
<% } %>
<style>
html {
/* Prevent iOS Safari from inflating text when rotating to landscape */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
font-family: Roboto, Noto, sans-serif;
margin: 0;
@@ -1,6 +1,7 @@
import type { TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators";
import "../../../../src/components/ha-formfield";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
import "../../components/demo-black-white-row";
@@ -63,12 +64,13 @@ class DemoHaAutomationEditorAction extends LitElement {
protected render(): TemplateResult {
return html`
<div class="options">
<ha-switch
.name=${"disabled"}
.checked=${this._disabled}
@change=${this._handleOptionChange}
>Disabled</ha-switch
>
<ha-formfield label="Disabled">
<ha-switch
.name=${"disabled"}
.checked=${this._disabled}
@change=${this._handleOptionChange}
></ha-switch>
</ha-formfield>
</div>
${SCHEMAS.map(
(info, sampleIdx) => html`
@@ -109,7 +111,7 @@ class DemoHaAutomationEditorAction extends LitElement {
max-width: 800px;
margin: 16px auto;
}
.options ha-switch {
.options ha-formfield {
margin-right: 16px;
}
`;
@@ -5,6 +5,7 @@ import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import "../../../../src/components/ha-formfield";
import type { ConditionWithShorthand } from "../../../../src/data/automation";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../../../src/panels/config/automation/condition/ha-automation-condition";
@@ -105,12 +106,13 @@ export class DemoAutomationEditorCondition extends LitElement {
protected render(): TemplateResult {
return html`
<div class="options">
<ha-switch
.name=${"disabled"}
.checked=${this._disabled}
@change=${this._handleOptionChange}
>Disabled</ha-switch
>
<ha-formfield label="Disabled">
<ha-switch
.name=${"disabled"}
.checked=${this._disabled}
@change=${this._handleOptionChange}
></ha-switch>
</ha-formfield>
</div>
${SCHEMAS.map(
(info, sampleIdx) => html`
@@ -151,7 +153,7 @@ export class DemoAutomationEditorCondition extends LitElement {
max-width: 800px;
margin: 16px auto;
}
.options ha-switch {
.options ha-formfield {
margin-right: 16px;
}
`;
@@ -8,6 +8,7 @@ import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import { mockTags } from "../../../../demo/src/stubs/tags";
import "../../../../src/components/ha-formfield";
import type { Trigger } from "../../../../src/data/automation";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../../../src/panels/config/automation/trigger/ha-automation-trigger";
@@ -145,12 +146,13 @@ export class DemoAutomationEditorTrigger extends LitElement {
protected render(): TemplateResult {
return html`
<div class="options">
<ha-switch
.name=${"disabled"}
.checked=${this._disabled}
@change=${this._handleOptionChange}
>Disabled</ha-switch
>
<ha-formfield label="Disabled">
<ha-switch
.name=${"disabled"}
.checked=${this._disabled}
@change=${this._handleOptionChange}
></ha-switch>
</ha-formfield>
</div>
${SCHEMAS.map(
(info, sampleIdx) => html`
@@ -191,7 +193,7 @@ export class DemoAutomationEditorTrigger extends LitElement {
max-width: 800px;
margin: 16px auto;
}
.options ha-switch {
.options ha-formfield {
margin-right: 16px;
}
`;
@@ -9,6 +9,7 @@ import "../../../../src/components/ha-form/ha-form";
import "../../../../src/components/ha-icon-button";
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
const SCHEMA: HaFormSchema[] = [
{ type: "string", name: "Name", default: "", autofocus: true },
@@ -30,8 +31,11 @@ type DialogType =
export class DemoHaAdaptiveDialog extends LitElement {
@state() private _openDialog: DialogType = false;
@state() private _hass?: HomeAssistant;
protected firstUpdated() {
provideHass(this);
const hass = provideHass(this);
this._hass = hass;
}
protected render() {
@@ -85,6 +89,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-card>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "basic"}
header-title="Basic adaptive dialog"
@closed=${this._handleClosed}
@@ -93,6 +98,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "basic-subtitle-below"}
header-title="Adaptive dialog with subtitle"
header-subtitle="This is an adaptive dialog with a subtitle below"
@@ -102,6 +108,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "basic-subtitle-above"}
header-title="Adaptive dialog with subtitle above"
header-subtitle="This is an adaptive dialog with a subtitle above"
@@ -112,6 +119,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "small"}
width="small"
header-title="Small adaptive dialog"
@@ -121,6 +129,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "large"}
width="large"
header-title="Large adaptive dialog"
@@ -130,6 +139,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "form"}
header-title="Adaptive dialog with form"
header-subtitle="This is an adaptive dialog with a form"
@@ -153,6 +163,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.allowModeChange=${this._openDialog === "allow-mode-change"}
header-title="Adaptive dialog with allow mode change"
header-subtitle="Resize the window while this dialog is open"
@@ -165,6 +176,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "actions"}
header-title="Adaptive dialog with actions"
header-subtitle="This is an adaptive dialog with header actions"
@@ -383,6 +395,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
<h3>Example usage</h3>
<pre><code>&lt;ha-adaptive-dialog
.hass=\${this.hass}
open
header-title="Dialog title"
header-subtitle="Dialog subtitle"
@@ -594,7 +607,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
<p>Example:</p>
<pre><code>&lt;ha-adaptive-dialog open&gt;
<pre><code>&lt;ha-adaptive-dialog .hass=\${this.hass} open&gt;
&lt;ha-form autofocus .schema=\${schema}&gt;&lt;/ha-form&gt;
&lt;/ha-adaptive-dialog&gt;</code></pre>
</div>
@@ -9,6 +9,7 @@ import "../../../../src/components/ha-form/ha-form";
import "../../../../src/components/ha-icon-button";
import type { HASSDomCurrentTargetEvent } from "../../../../src/common/dom/fire_event";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
const SCHEMA: HaFormSchema[] = [
@@ -22,10 +23,12 @@ type PopoverType = false | "basic" | "small" | "large" | "form" | "actions";
export class DemoHaAdaptivePopover extends LitElement {
@state() private _openPopover: PopoverType = false;
@state() private _hass?: HomeAssistant;
@state() private _dialogAnchor?: HTMLElement;
protected firstUpdated() {
provideHass(this);
this._hass = provideHass(this);
}
protected render() {
@@ -69,6 +72,7 @@ export class DemoHaAdaptivePopover extends LitElement {
</ha-card>
<ha-adaptive-popover
.hass=${this._hass}
.open=${this._openPopover === "basic"}
.dialogAnchor=${this._dialogAnchor}
header-title="Basic adaptive popover"
@@ -82,6 +86,7 @@ export class DemoHaAdaptivePopover extends LitElement {
</ha-adaptive-popover>
<ha-adaptive-popover
.hass=${this._hass}
.open=${this._openPopover === "small"}
.dialogAnchor=${this._dialogAnchor}
width="small"
@@ -92,6 +97,7 @@ export class DemoHaAdaptivePopover extends LitElement {
</ha-adaptive-popover>
<ha-adaptive-popover
.hass=${this._hass}
.open=${this._openPopover === "large"}
.dialogAnchor=${this._dialogAnchor}
width="large"
@@ -102,6 +108,7 @@ export class DemoHaAdaptivePopover extends LitElement {
</ha-adaptive-popover>
<ha-adaptive-popover
.hass=${this._hass}
.open=${this._openPopover === "form"}
.dialogAnchor=${this._dialogAnchor}
header-title="Adaptive popover with form"
@@ -126,6 +133,7 @@ export class DemoHaAdaptivePopover extends LitElement {
</ha-adaptive-popover>
<ha-adaptive-popover
.hass=${this._hass}
.open=${this._openPopover === "actions"}
.dialogAnchor=${this._dialogAnchor}
header-title="Adaptive popover with actions"
@@ -51,12 +51,11 @@ The badge component is a small component that displays a number or status inform
**Properties/Attributes**
| Name | Type | Default | Description |
| --------------- | ----------------------- | ----------- | ------------------------------------------------------------- |
| type | `"badge"` or `"button"` | `"badge"` | If it's button it shows a ripple effect |
| label | string | `undefined` | Text label for the badge, only visible if `iconOnly = false` |
| iconOnly | boolean | `false` | Only show label |
| accessibleLabel | string | `undefined` | Accessible name of the button, only used if `iconOnly = true` |
| Name | Type | Default | Description |
| -------- | ----------------------- | ----------- | ------------------------------------------------------------ |
| type | `"badge"` or `"button"` | `"badge"` | If it's button it shows a ripple effect |
| label | string | `undefined` | Text label for the badge, only visible if `iconOnly = false` |
| iconOnly | boolean | `false` | Only show label |
**CSS Custom Properties**
@@ -1,7 +0,0 @@
---
title: Control scrubber
---
A horizontal control where the value sits under a fixed window in the middle and the background strip moves. Drag the strip to change the value, or tap a point of the strip to bring it under the window.
Set `--control-scrubber-track-width` to make the strip wider than the control, so only a part of the range is visible at a time and each pixel of drag changes the value less. Set `wrap` for cyclic ranges such as hue, where the strip repeats and the value wraps around at the bounds. Set `show-handle` to draw the window, and leave it off when there is no current value to point at, such as a light that is off.
@@ -1,96 +0,0 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-scrubber";
@customElement("demo-components-ha-control-scrubber")
export class DemoHaControlScrubber extends LitElement {
@state() private hue = 200;
@state() private position?: number;
handleHueChanged(e: CustomEvent) {
this.hue = e.detail.value as number;
}
handleMoved(e: CustomEvent) {
this.position = e.detail.value as number;
}
protected render(): TemplateResult {
return html`
<ha-card>
<div class="card-content">
<p><b>Scrubber values</b></p>
<table>
<tbody>
<tr>
<td>position</td>
<td>${this.position ?? "-"}</td>
</tr>
<tr>
<td>value</td>
<td>${this.hue}</td>
</tr>
</tbody>
</table>
</div>
</ha-card>
<ha-card>
<div class="card-content">
<label id="scrubber-hue">Hue (wrap)</label>
<pre>Config: {"wrap":true,"min":0,"max":360}</pre>
<ha-control-scrubber
wrap
show-handle
min="0"
max="360"
unit="°"
.value=${this.hue}
@value-changed=${this.handleHueChanged}
@slider-moved=${this.handleMoved}
label="Hue"
>
</ha-control-scrubber>
</div>
</ha-card>
`;
}
static styles = css`
ha-card {
max-width: 600px;
margin: 24px auto;
}
pre {
margin-top: 0;
margin-bottom: 8px;
}
p {
margin: 0;
}
label {
font-weight: var(--ha-font-weight-bold);
}
ha-control-scrubber {
--control-scrubber-track-width: 200%;
--control-scrubber-background: linear-gradient(
to right,
hsl(0 100% 50%),
hsl(60 100% 50%),
hsl(120 100% 50%),
hsl(180 100% 50%),
hsl(240 100% 50%),
hsl(300 100% 50%),
hsl(360 100% 50%)
);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-control-scrubber": DemoHaControlScrubber;
}
}
+4 -18
View File
@@ -17,18 +17,12 @@ A styled container with roving-tabindex keyboard navigation. Host role is
Home/End jump to the first/last enabled item; Enter/Space activates the
focused item.
With `virtual-focus`, DOM focus stays outside the list, for example in the
search field of a picker. The list then ignores key presses. The element that
owns the focus calls `moveActiveItem()`, and the list marks the row it points at
with the `active` attribute and scrolls it into view instead of focusing it.
**Attributes**
| Name | Type | Default | Description |
| --------------- | ------- | ------- | ---------------------------------------------------------------- |
| `wrap-focus` | Boolean | `false` | Arrow keys wrap past the ends. |
| `virtual-focus` | Boolean | `false` | Keep DOM focus outside the list and mark the active row instead. |
| `aria-label` | String | — | Accessible name. |
| Name | Type | Default | Description |
| ------------ | ------- | ------- | ------------------------------ |
| `wrap-focus` | Boolean | `false` | Arrow keys wrap past the ends. |
| `aria-label` | String | — | Accessible name. |
**Events**
@@ -42,10 +36,6 @@ with the `active` attribute and scrolls it into view instead of focusing it.
- `getActiveItemIndex()` — current active index, or `-1`.
- `setActiveItemIndex(index, focusItem?)` — move the active index without
necessarily focusing.
- `moveActiveItem(target)` — move the active row to `"next"`, `"previous"`,
`"first"`, `"last"`, `"next-page"` or `"previous-page"`, skipping rows that
are not interactive or disabled. Meant for `virtual-focus`.
- `clearActiveItem()` — clear the active row.
- `updateListItems()` — re-discover slotted items (called automatically on
slotchange).
@@ -144,8 +134,6 @@ Non-interactive list row. Host role is `listitem`. Inherits everything from
- `interactive` (Boolean, default `false`) — opt this row into the parent
list's roving tabindex. Useful for sortable rows that need keyboard focus
but no click action. Interactive subclasses set this automatically.
- `active` (Boolean) — set by a parent list with `virtual-focus` on the row
that keyboard navigation points at.
**CSS custom properties**
@@ -155,8 +143,6 @@ Non-interactive list row. Host role is `listitem`. Inherits everything from
the focus-in animation.
- `--ha-list-item-focus-offset` — focus outline offset.
- `--ha-list-item-focus-background` — background color on keyboard focus.
- `--ha-list-item-active-background` — background color of the `active` row.
Defaults to `--ha-list-item-focus-background`.
### `<ha-list-item-button>`
@@ -1,91 +0,0 @@
---
title: Picker combo box
---
# Picker combo box `<ha-picker-combo-box>`
The searchable list used inside pickers. `ha-generic-picker` renders it in a
popover or dialog; entity, area, floor, label and device pickers all reach it
that way. It is shown directly here so the list and its keyboard model can be
inspected on their own.
It takes no `hass`. Items come from a `getItems` callback and locale comes from
`internationalizationContext`.
## Two render paths
The item count picks the renderer, and the two behave differently enough that
list changes need checking against both.
| Items | Renderer | Rows in the DOM |
| ------------------------------------------- | ----------------- | --------------------- |
| 12 or fewer rendered rows, no sections | `repeat()` | all of them |
| more than 12 rendered rows, or any sections | `lit-virtualizer` | only the visible ones |
The threshold is `MAX_PLAIN_LIST_ITEMS`, counted against the initial rendered
row set, including additional items and dialog padding. Filtering does not switch renderers.
## Two highlights
The list has two row backgrounds.
- **`selected`** — the row matching the `value` property, rendered onto its
`ha-list-item-option`.
- **`active`** — the keyboard cursor, and the row <kbd>Enter</kbd> picks. The
list runs in `virtual-focus` mode: DOM focus stays in the search field, and
the list owns the active row, skips disabled rows and section titles, and
scrolls it into view.
## Keyboard
| Key | Behavior |
| ------------------------------------- | ----------------------------------------------------- |
| <kbd>↑</kbd> <kbd>↓</kbd> | Move the cursor |
| <kbd>Home</kbd> <kbd>End</kbd> | Jump to the first or last item |
| <kbd>PageUp</kbd> <kbd>PageDown</kbd> | Move the cursor a page |
| <kbd>Enter</kbd> | Pick the row under the cursor |
| <kbd>Ctrl/⌘</kbd> + <kbd>Enter</kbd> | Pick it and report `newTab: true` in `index-selected` |
With no cursor yet, <kbd>↓</kbd> and <kbd>Enter</kbd> start from the `value`
row, or from the top match while searching. With one item in the list,
<kbd>Enter</kbd> picks it whether or not the cursor has moved.
## Implementation
Without `sections`, the component filters the items from `getItems` itself as the
user types, so `getItems` can ignore its search argument. With sections,
`getItems` receives the search string and selected section and must return the
matching rows.
The host is `display: flex` with `flex: 1` and expects a parent with a bounded
height. In the app that bound comes from the popover; embedding it directly
means supplying one.
### Properties/Attributes
| Name | Type | Default | Description |
| ------------------ | ------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| getItems | Function | - | Required. Returns the items, optionally filtered by search string and section. |
| getAdditionalItems | Function | - | Extra items appended to search results, for example "add new". |
| value | String | - | Id of the current item. Marks its row `selected`, and the virtualized list opens with that row in view. |
| label | String | - | Placeholder for the search field. Falls back to a localized "Search". |
| mode | "popover"/"dialog" | "popover" | Adjusts padding for the surface the list sits in. |
| shown | Boolean | true | Whether the surface finished animating. `ha-generic-picker` sets it so the virtualizer does not measure rows mid-animation. |
| sections | Array | - | Filter chips. Section headers are plain strings returned by `getItems`, and the sticky title shows the one above the first visible row. |
| selectedSection | String | - | Section filter selected when the list is initialized. |
| searchKeys | Array | - | Fuse weighted keys for fuzzy search. |
| searchFn | Function | - | Post-processes filtered results. |
| rowRenderer | Function | - | Replaces the default row template. |
| allowCustomValue | Boolean | false | Offers the typed string as an item. |
| customValueLabel | String | - | Label for that custom item. |
| notFoundLabel | String/Function | - | Shown when a search matches nothing. |
| emptyLabel | String | - | Shown when there are no items at all. |
| noSort | Boolean | false | Keeps the order `getItems` returned. |
| clearable | Boolean | false | Adjusts search field padding for a clear affordance. |
### Events
| Event | Detail | Description |
| ---------------- | ------------------------- | -------------------------------------------------------------- |
| `value-changed` | `{ value }` | An item was picked. |
| `index-selected` | `{ index, item, newTab }` | Which row was picked, and whether it should open in a new tab. |
@@ -1,273 +0,0 @@
import { ContextProvider } from "@lit/context";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-picker-combo-box";
import type { PickerComboBoxItem } from "../../../../src/components/ha-picker-combo-box";
import { internationalizationContext } from "../../../../src/data/context";
import {
DateFormat,
FirstWeekday,
NumberFormat,
TimeFormat,
TimeZone,
} from "../../../../src/data/translation";
import type {
HomeAssistantInternationalization,
ValueChangedEvent,
} from "../../../../src/types";
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
const LOCALIZE_KEYS: Record<string, string> = {
"ui.common.search": "Search",
"ui.components.combo-box.no_match": "No matching items found",
"ui.components.combo-box.no_items": "No items available",
};
const localize = (key: string) => LOCALIZE_KEYS[key] ?? key;
const DEMO_I18N: HomeAssistantInternationalization = {
localize,
language: "en",
selectedLanguage: null,
locale: {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
first_weekday: FirstWeekday.language,
time_zone: TimeZone.local,
},
translationMetadata: { fragments: [], translations: {} },
loadBackendTranslation: async () => localize,
loadFragmentTranslation: async () => localize,
};
const PLAIN_ITEMS: PickerComboBoxItem[] = [
{ id: "light.desk", primary: "Desk", secondary: "Office" },
{ id: "light.dining_table", primary: "Dining table", secondary: "Kitchen" },
{ id: "light.doorway", primary: "Doorway", secondary: "Hallway" },
{ id: "light.hallway", primary: "Hallway", secondary: "Hallway" },
{
id: "light.kitchen_counter",
primary: "Kitchen counter",
secondary: "Kitchen",
},
{ id: "light.porch", primary: "Porch", secondary: "Outside" },
{ id: "light.reading_lamp", primary: "Reading lamp", secondary: "Bedroom" },
{
id: "light.shed",
primary: "Shed (disabled)",
secondary: "Outside",
disabled: true,
},
{ id: "light.workbench", primary: "Workbench", secondary: "Garage" },
];
const AREAS = [
"Attic",
"Basement",
"Bedroom",
"Dining room",
"Garage",
"Hallway",
"Kitchen",
"Living room",
"Office",
"Outside",
];
const FIXTURES = [
"Ceiling",
"Corner lamp",
"Cupboard",
"Desk lamp",
"Downlight",
"Floor lamp",
"Shelf",
"Spotlight",
"Strip",
"Wall sconce",
];
const VIRTUALIZED_ITEMS: PickerComboBoxItem[] = AREAS.flatMap((area, a) =>
FIXTURES.flatMap((fixture) =>
[1, 2].map((offset) => {
const n = a * 2 + offset;
return {
id: `light.${fixture.toLowerCase().replace(/ /g, "_")}_${n}`,
primary: `${fixture} ${n}`,
secondary: area,
};
})
)
);
@customElement("demo-components-ha-picker-combo-box")
export class DemoHaPickerComboBox extends LitElement {
@state() private _plainValue?: string;
@state() private _virtualizedValue?: string;
@state() private _preselectedValue = "light.spotlight_13";
constructor() {
super();
// Provides internationalizationContext for ha-picker-combo-box and ha-input-search
new ContextProvider(this, {
context: internationalizationContext,
initialValue: DEMO_I18N,
});
}
protected render(): TemplateResult {
return html`
<demo-theme-comparison>
${THEME_COMPARISON_PANELS.map(
({ slot }) => html`
<div slot=${slot} class="panel-content">
<ha-card header="Plain list (9 items)">
<div class="card-content">
<p class="note">
At or below 12 items the list renders with
<code>repeat()</code>, so every row is in the DOM. One item
is disabled, so neither a click nor <kbd>Enter</kbd>
picks it.
</p>
<div class="picker">
<ha-picker-combo-box
.getItems=${this._getPlainItems}
.value=${this._plainValue}
@value-changed=${this._plainValueChanged}
></ha-picker-combo-box>
</div>
<p class="value">
Picked: <code>${this._plainValue ?? "—"}</code>
</p>
</div>
</ha-card>
<ha-card header="Virtualized list (200 items)">
<div class="card-content">
<p class="note">
Above 12 items the list switches to
<code>lit-virtualizer</code> and only the visible rows
exist.
</p>
<div class="picker">
<ha-picker-combo-box
.getItems=${this._getVirtualizedItems}
.value=${this._virtualizedValue}
@value-changed=${this._virtualizedValueChanged}
></ha-picker-combo-box>
</div>
<p class="value">
Picked: <code>${this._virtualizedValue ?? "—"}</code>
</p>
</div>
</ha-card>
<ha-card header="Empty">
<div class="card-content">
<p class="note">
With no items the list shows
<code>emptyLabel</code>, or a localized fallback. Searching
a non-empty list that matches nothing shows
<code>notFoundLabel</code> instead.
</p>
<div class="picker">
<ha-picker-combo-box
.getItems=${this._getNoItems}
></ha-picker-combo-box>
</div>
</div>
</ha-card>
<ha-card header="With a current value">
<div class="card-content">
<p class="note">
A set <code>value</code> marks its row
<code>selected</code>. The keyboard cursor is a separate
highlight, and <kbd>↓</kbd> starts it on this row.
</p>
<div class="picker">
<ha-picker-combo-box
.getItems=${this._getVirtualizedItems}
.value=${this._preselectedValue}
@value-changed=${this._preselectedValueChanged}
></ha-picker-combo-box>
</div>
<p class="value">
Current: <code>${this._preselectedValue}</code>
</p>
</div>
</ha-card>
</div>
`
)}
</demo-theme-comparison>
`;
}
private _getPlainItems = () => PLAIN_ITEMS;
private _getVirtualizedItems = () => VIRTUALIZED_ITEMS;
private _getNoItems = (): PickerComboBoxItem[] => [];
private _plainValueChanged(ev: ValueChangedEvent<string>) {
this._plainValue = ev.detail.value;
}
private _virtualizedValueChanged(ev: ValueChangedEvent<string>) {
this._virtualizedValue = ev.detail.value;
}
private _preselectedValueChanged(ev: ValueChangedEvent<string>) {
this._preselectedValue = ev.detail.value;
}
static styles = css`
:host {
display: block;
}
.panel-content {
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
}
ha-card {
margin: 0;
width: 100%;
}
.card-content {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.note {
margin: 0;
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
.picker {
display: flex;
flex-direction: column;
max-height: 320px;
border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-md);
overflow: hidden;
}
.value {
margin: 0;
font-size: var(--ha-font-size-s);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-picker-combo-box": DemoHaPickerComboBox;
}
}
+31 -119
View File
@@ -15,13 +15,13 @@ import {
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-formfield";
import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import type { BlueprintInput } from "../../../../src/data/blueprint";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
import { StatisticMeanType } from "../../../../src/data/recorder";
import { resolveSelectorContext } from "../../../../src/data/selector";
import type { SerialPort } from "../../../../src/data/usb";
import {
showDialog,
@@ -384,14 +384,6 @@ const SCHEMAS: {
selector: { config_entry: {} },
},
duration: { name: "Duration", selector: { duration: {} } },
signed_duration: {
name: "Signed duration",
selector: { duration: { mode: "signed" } },
},
offset_duration: {
name: "Offset",
selector: { duration: { mode: "offset", enable_day: true } },
},
app: { name: "App", selector: { app: {} } },
serial_port: {
name: "Serial port",
@@ -679,77 +671,6 @@ const SCHEMAS: {
},
},
},
{
name: "Unit of measurement",
input: {
sensor_device_class: {
name: "Device class",
selector: { device_class: { domain: "sensor" } },
},
sensor_state_class: {
name: "State class",
selector: { state_class: {} },
},
unit_linked: {
name: "Unit of the device class and state class above",
selector: {
unit_of_measurement: {
context: {
filter_device_class: "sensor_device_class",
filter_state_class: "sensor_state_class",
},
},
},
},
unit: {
name: "Any unit (custom allowed)",
selector: { unit_of_measurement: {} },
},
unit_temperature: {
name: "Temperature",
selector: { unit_of_measurement: { device_classes: ["temperature"] } },
},
unit_battery_humidity: {
name: "Battery or humidity",
selector: {
unit_of_measurement: { device_classes: ["battery", "humidity"] },
},
},
unit_aqi: {
name: "Air quality index (no unit)",
selector: { unit_of_measurement: { device_classes: ["aqi"] } },
},
unit_enum: {
name: "Enum (no unit)",
selector: { unit_of_measurement: { device_classes: ["enum"] } },
},
unit_timestamp: {
name: "Timestamp (no unit)",
selector: { unit_of_measurement: { device_classes: ["timestamp"] } },
},
unit_uptime: {
name: "Uptime (no unit)",
selector: { unit_of_measurement: { device_classes: ["uptime"] } },
},
unit_monetary: {
name: "Monetary (custom unit)",
selector: { unit_of_measurement: { device_classes: ["monetary"] } },
},
unit_angle: {
name: "Measurement angle",
selector: {
unit_of_measurement: { state_classes: ["measurement_angle"] },
},
},
unit_context: {
name: "Temperature or humidity, narrowed by context to humidity",
selector: {
unit_of_measurement: { device_classes: ["temperature", "humidity"] },
},
context: { filter_device_class: "humidity" },
},
},
},
{
name: "Multiples",
input: {
@@ -1062,30 +983,34 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
protected render(): TemplateResult {
return html`
<div class="options">
<ha-switch
.name=${"label"}
.checked=${this._label}
@change=${this._handleOptionChange}
>Labels</ha-switch
>
<ha-switch
.name=${"required"}
.checked=${this._required}
@change=${this._handleOptionChange}
>Required</ha-switch
>
<ha-switch
.name=${"disabled"}
.checked=${this._disabled}
@change=${this._handleOptionChange}
>Disabled</ha-switch
>
<ha-switch
.name=${"helper"}
.checked=${this._helper}
@change=${this._handleOptionChange}
>Helper text</ha-switch
>
<ha-formfield label="Labels">
<ha-switch
.name=${"label"}
.checked=${this._label}
@change=${this._handleOptionChange}
></ha-switch>
</ha-formfield>
<ha-formfield label="Required">
<ha-switch
.name=${"required"}
.checked=${this._required}
@change=${this._handleOptionChange}
></ha-switch>
</ha-formfield>
<ha-formfield label="Disabled">
<ha-switch
.name=${"disabled"}
.checked=${this._disabled}
@change=${this._handleOptionChange}
></ha-switch>
</ha-formfield>
<ha-formfield label="Helper text">
<ha-switch
.name=${"helper"}
.checked=${this._helper}
@change=${this._handleOptionChange}
></ha-switch>
</ha-formfield>
</div>
${SCHEMAS.map((info, idx) => {
const data = this.data[idx];
@@ -1106,7 +1031,7 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
<ha-selector
.hass=${this.hass}
.selector=${value!.selector}
.context=${this._context(value!, data)}
.context=${value!.context}
.key=${key}
.label=${this._label ? value!.name : undefined}
.value=${data[key] ?? value!.default}
@@ -1126,19 +1051,6 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
`;
}
private _context(
input: (typeof SCHEMAS)[number]["input"][string],
data: Record<string, unknown>
): Record<string, unknown> | undefined {
const selectorContext = input?.selector
? resolveSelectorContext(input.selector, data)
: undefined;
if (!selectorContext) {
return input?.context;
}
return { ...input?.context, ...selectorContext };
}
private _handleValueChanged(ev) {
const idx = ev.target.sampleIdx;
this.data[idx] = {
@@ -1157,7 +1069,7 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
max-width: 800px;
margin: 16px auto;
}
.options ha-switch {
.options ha-formfield {
margin-right: 16px;
}
`;
@@ -102,13 +102,6 @@ const CONFIGS = [
entity: "media_player.theater_off_static",
},
},
{
heading: "Player Off (assumed state, cannot be switched on)",
config: {
type: "media-control",
entity: "media_player.theater_off_assumed",
},
},
{
heading: "Player On (cannot be switched off)",
config: {
-18
View File
@@ -32,8 +32,6 @@ const ENTITIES = [
attributes: {
friendly_name: "Bed Light",
supported_color_modes: [LightColorMode.HS, LightColorMode.COLOR_TEMP],
color_mode: LightColorMode.HS,
hs_color: [210, 60],
},
},
{
@@ -277,22 +275,6 @@ const CONFIGS = [
features: [{ type: "light-brightness" }],
},
},
{
heading: "Light color feature",
config: {
type: "tile",
entity: "light.bed_light",
features: [{ type: "light-color" }],
},
},
{
heading: "Light color feature with hue and saturation",
config: {
type: "tile",
entity: "light.bed_light",
features: [{ type: "light-color", controls: "hue_saturation" }],
},
},
{
heading: "Light color temperature feature",
config: {
+8 -6
View File
@@ -1,6 +1,7 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-formfield";
import "../../../../src/components/ha-select";
import type { HaSelectSelectEvent } from "../../../../src/components/ha-select";
import "../../../../src/components/ha-switch";
@@ -387,12 +388,13 @@ export class DemoMiscCloudAccount
private _toggle(label: string, field: keyof CloudDemoScenario) {
return html`
<ha-switch
.checked=${this._scenario[field] as boolean}
data-field=${field}
@change=${this._switchChanged}
>${label}</ha-switch
>
<ha-formfield .label=${label}>
<ha-switch
.checked=${this._scenario[field] as boolean}
data-field=${field}
@change=${this._switchChanged}
></ha-switch>
</ha-formfield>
`;
}
+11 -6
View File
@@ -1,5 +1,6 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import "../../../../src/components/ha-formfield";
import "../../../../src/components/ha-switch";
import { customElement, property, state } from "lit/decorators";
@@ -257,10 +258,12 @@ export class DemoIntegrationCard extends LitElement {
return html`
<div class="container">
<div class="filters">
<ha-switch @change=${this._toggleCustomIntegration}
>Custom Integration</ha-switch
>
<ha-switch @change=${this._toggleCloud}>Relies on cloud</ha-switch>
<ha-formfield label="Custom Integration">
<ha-switch @change=${this._toggleCustomIntegration}></ha-switch>
</ha-formfield>
<ha-formfield label="Relies on cloud">
<ha-switch @change=${this._toggleCloud}></ha-switch>
</ha-formfield>
</div>
<ha-ignored-config-entry-card
@@ -290,6 +293,7 @@ export class DemoIntegrationCard extends LitElement {
class=${classMap({
highlight: info.highlight !== undefined,
})}
.hass=${this.hass}
domain="esphome"
.items=${info.items}
.manifest=${createManifest(
@@ -312,6 +316,7 @@ export class DemoIntegrationCard extends LitElement {
<!-- One that is standalone to see how it increases height if height
not defined by other cards. -->
<ha-integration-card
.hass=${this.hass}
domain="esphome"
.items=${[
loadedEntry,
@@ -364,9 +369,9 @@ export class DemoIntegrationCard extends LitElement {
max-width: 500px;
}
ha-switch {
ha-formfield {
margin: 8px 0;
display: flex;
display: block;
}
`;
}
+40 -38
View File
@@ -12,9 +12,8 @@
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
"lint:prettier": "prettier . --cache --check",
"format:prettier": "prettier . --cache --write",
"lint:types": "node ./node_modules/@typescript/native/bin/tsc && node ./node_modules/@typescript/native/bin/tsc -p .github/scripts",
"lint:types": "node ./node_modules/@typescript/native/bin/tsc",
"lint:lit": "lit-analyzer \"{.,*}/src/**/*.ts\"",
"lint:element-imports": "eslint \"**/src/**/*.{js,ts,html}\" --ignore-pattern=.gitignore --rule '{\"ha/no-unused-element-import\": \"warn\"}'",
"lint:licenses": "node --no-deprecation script/check-licenses",
"lint": "pnpm run lint:eslint && pnpm run lint:prettier && pnpm run lint:types && pnpm run lint:lit",
"format": "pnpm run format:eslint && pnpm run format:prettier",
@@ -40,20 +39,20 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "8.0.7",
"@babel/runtime": "8.0.5",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.11.1",
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.13.1",
"@codemirror/language": "6.12.4",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.2",
"@codemirror/state": "6.7.6",
"@codemirror/view": "6.43.14",
"@codemirror/view": "6.43.13",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.8.1",
"@formatjs/intl-datetimeformat": "7.8.0",
"@formatjs/intl-displaynames": "7.3.15",
"@formatjs/intl-durationformat": "0.11.1",
"@formatjs/intl-getcanonicallocales": "3.2.12",
@@ -68,33 +67,36 @@
"@fullcalendar/list": "6.1.21",
"@fullcalendar/luxon3": "6.1.21",
"@fullcalendar/timegrid": "6.1.21",
"@home-assistant/webawesome": "3.14.0-ha.0",
"@home-assistant/webawesome": "3.7.0-ha.1",
"@lezer/common": "1.5.3",
"@lezer/highlight": "1.2.5",
"@lezer/highlight": "1.2.4",
"@lit-labs/motion": "1.1.0",
"@lit-labs/observers": "2.1.0",
"@lit-labs/virtualizer": "2.1.1",
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@lit/task": "1.0.3",
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@material/mwc-formfield": "0.27.0",
"@material/mwc-list": "0.27.0",
"@material/web": "2.5.0",
"@mdi/js": "7.4.47",
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@tsparticles/engine": "4.4.0",
"@tsparticles/preset-links": "4.4.0",
"@versatiles/style": "6.4.0",
"@versatiles/style": "6.0.1",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.201.1",
"@vvo/tzdb": "6.198.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"barcode-detector": "3.2.2",
"cally": "0.9.2",
"color-name": "2.1.1",
"comlink": "4.4.2",
"core-js": "3.50.0",
"cropperjs": "1.7.0",
"cropperjs": "1.6.3",
"culori": "4.0.2",
"date-fns": "4.4.0",
"deep-clone-simple": "1.1.1",
@@ -106,17 +108,17 @@
"hls.js": "1.7.3",
"home-assistant-js-websocket": "9.7.1",
"idb-keyval": "6.3.0",
"intl-messageformat": "12.1.3",
"js-yaml": "5.4.3",
"intl-messageformat": "12.1.2",
"js-yaml": "5.4.2",
"leaflet": "1.9.4",
"leaflet.markercluster": "1.5.3",
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"maplibre-gl": "6.14.0",
"marked": "18.1.0",
"maplibre-gl": "6.11.2",
"marked": "18.0.14",
"memoize-one": "6.0.0",
"nanoid": "6.0.2",
"nanoid": "6.0.1",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
"punycode": "2.3.1",
@@ -124,10 +126,10 @@
"qrcode": "1.5.4",
"roboto-fontface": "0.10.0",
"rrule": "2.8.1",
"sortablejs": "1.15.7",
"sortablejs": "1.15.6",
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"tinykeys": "4.0.1",
"tinykeys": "4.0.0",
"weekstart": "2.0.0",
"workbox-cacheable-response": "7.4.1",
"workbox-core": "7.4.1",
@@ -137,25 +139,26 @@
"workbox-strategies": "7.4.1",
"xss": "1.0.15",
"zrender": "6.1.0",
"zxing-wasm": "3.1.5"
"zxing-wasm": "3.1.3"
},
"devDependencies": {
"@ampproject/remapping": "2.3.0",
"@babel/core": "8.0.7",
"@babel/helper-compilation-targets": "8.0.7",
"@babel/core": "8.0.6",
"@babel/helper-compilation-targets": "8.0.6",
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-runtime": "8.0.6",
"@babel/preset-env": "8.0.7",
"@babel/preset-env": "8.0.6",
"@bundle-stats/plugin-webpack-filter": "4.22.3",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.66.1",
"@lokalise/node-api": "16.6.0",
"@octokit/auth-oauth-device": "8.0.5",
"@octokit/plugin-retry": "8.1.1",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.64.0",
"@rsdoctor/rspack-plugin": "1.6.5",
"@rspack/core": "2.2.8",
"@playwright/test": "1.63.0",
"@rsdoctor/rspack-plugin": "1.6.4",
"@rspack/core": "2.2.7",
"@rspack/dev-server": "2.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -167,20 +170,19 @@
"@types/leaflet": "1.9.22",
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.6",
"@types/node": "24.19.2",
"@types/luxon": "3.7.5",
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:[email protected]",
"@vitest/coverage-v8": "5.0.4",
"@vitest/coverage-v8": "5.0.2",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.29.3",
"browserslist": "4.29.1",
"browserslist-useragent-regexp": "4.1.4",
"core-js-compat": "3.50.0",
"del": "9.0.1",
"eslint": "10.12.0",
"del": "8.0.1",
"eslint": "10.11.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
@@ -192,13 +194,13 @@
"fs-extra": "11.4.1",
"generate-license-file": "4.2.5",
"glob": "13.0.6",
"globals": "17.13.0",
"globals": "17.12.0",
"gulp": "5.0.1",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "30.1.2",
"jsdom": "30.1.1",
"jszip": "3.10.2",
"license-checker-rseidelsohn": "5.0.1",
"lightningcss": "1.33.0",
@@ -209,7 +211,7 @@
"map-stream": "0.0.7",
"minify-literals": "2.2.0",
"pinst": "3.0.0",
"prettier": "3.9.10",
"prettier": "3.9.9",
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.1.0",
@@ -217,15 +219,15 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.71.1",
"vite": "8.3.4",
"typescript-eslint": "8.70.1",
"vite": "8.3.1",
"vite-tsconfig-paths": "6.1.1",
"vitest": "5.0.4",
"vitest": "5.0.2",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "7.4.1"
},
"packageManager": "pnpm@12.11.2",
"packageManager": "[email protected]8.0",
"volta": {
"node": "24.21.0"
}
+22
View File
@@ -0,0 +1,22 @@
diff --git a/mwc-formfield-base.js b/mwc-formfield-base.js
index 7b763326d7d51835ad52646bfbc80fe21989abd3..f2baa8224e6d03df1fdb0b9fd03f5c6d77fc8747 100644
--- a/mwc-formfield-base.js
+++ b/mwc-formfield-base.js
@@ -9,7 +9,7 @@ import { BaseElement } from '@material/mwc-base/base-element.js';
import { FormElement } from '@material/mwc-base/form-element.js';
import { observer } from '@material/mwc-base/observer.js';
import { html } from 'lit';
-import { property, query, queryAssignedNodes } from 'lit/decorators.js';
+import { property, query, queryAssignedElements } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
export class FormfieldBase extends BaseElement {
constructor() {
@@ -96,7 +96,7 @@ __decorate([
query('.mdc-form-field')
], FormfieldBase.prototype, "mdcRoot", void 0);
__decorate([
- queryAssignedNodes('', true, '*')
+ queryAssignedElements({ slot: "", flatten: true, selector: "*" })
], FormfieldBase.prototype, "slottedInputs", void 0);
__decorate([
query('label')
File renamed without changes.
File renamed without changes.
+1130 -1324
View File
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -4,9 +4,8 @@ autoInstallPeers: false
# Supply-chain protection
minimumReleaseAge: 4320 # 3 days, in minutes
minimumReleaseAgeExclude:
# Our own packages, published from our own CI. Keep in sync with renovate.json
# Our own packages, published from our own CI
- home-assistant-js-websocket
- "@home-assistant/*"
trustPolicy: no-downgrade
trustPolicyExclude:
# Backports published after provenance-signed 7.x releases
@@ -30,8 +29,9 @@ overrides:
tslib: 2.8.1
patchedDependencies:
"@material/[email protected]": patches/@[email protected]
"@material/[email protected]": patches/@[email protected]
[email protected]: patches/[email protected]
[email protected].7: patches/[email protected].7.patch
[email protected].1: patches/[email protected].1.patch
[email protected].6: patches/[email protected].6.patch
[email protected].0: patches/[email protected].0.patch
[email protected]: patches/[email protected]
@@ -1,9 +0,0 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<rect x="8" y="8" width="78" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="12" y="13.25" width="6" height="1.5" rx="0.75" fill="black" fill-opacity="0.48"/>
<rect x="37" y="12.5" width="20" height="3" rx="1.5" fill="black" fill-opacity="0.32"/>
<rect x="76" y="13.25" width="6" height="1.5" rx="0.75" fill="black" fill-opacity="0.48"/>
<rect x="78.25" y="11" width="1.5" height="6" rx="0.75" fill="black" fill-opacity="0.48"/>
</svg>

Before

Width:  |  Height:  |  Size: 691 B

@@ -1,9 +0,0 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="#1C1C1C"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="white" stroke-opacity="0.24"/>
<rect x="8" y="8" width="78" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="12" y="13.25" width="6" height="1.5" rx="0.75" fill="white" fill-opacity="0.72"/>
<rect x="37" y="12.5" width="20" height="3" rx="1.5" fill="white" fill-opacity="0.48"/>
<rect x="76" y="13.25" width="6" height="1.5" rx="0.75" fill="white" fill-opacity="0.72"/>
<rect x="78.25" y="11" width="1.5" height="6" rx="0.75" fill="white" fill-opacity="0.72"/>
</svg>

Before

Width:  |  Height:  |  Size: 693 B

@@ -1,7 +0,0 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<rect x="8" y="8" width="78" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="8" y="8" width="46" height="12" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="49" y="10.5" width="2" height="7" rx="1" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 478 B

@@ -1,7 +0,0 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="#1C1C1C"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="white" stroke-opacity="0.24"/>
<rect x="8" y="8" width="78" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="8" y="8" width="46" height="12" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="49" y="10.5" width="2" height="7" rx="1" fill="#1C1C1C"/>
</svg>

Before

Width:  |  Height:  |  Size: 482 B

@@ -1,4 +0,0 @@
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 252 B

@@ -1,4 +0,0 @@
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

Before

Width:  |  Height:  |  Size: 252 B

@@ -1,5 +0,0 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 330 B

@@ -1,5 +0,0 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

Before

Width:  |  Height:  |  Size: 330 B

@@ -1,6 +0,0 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 408 B

@@ -1,6 +0,0 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

Before

Width:  |  Height:  |  Size: 408 B

@@ -1,3 +0,0 @@
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
</svg>

Before

Width:  |  Height:  |  Size: 174 B

@@ -1,3 +0,0 @@
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
</svg>

Before

Width:  |  Height:  |  Size: 174 B

@@ -1,4 +0,0 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 252 B

@@ -1,4 +0,0 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

Before

Width:  |  Height:  |  Size: 252 B

@@ -1,5 +0,0 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 330 B

@@ -1,5 +0,0 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

Before

Width:  |  Height:  |  Size: 330 B

@@ -1,3 +0,0 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="56" height="16" rx="3" fill="black" fill-opacity="0.32"/>
</svg>

Before

Width:  |  Height:  |  Size: 174 B

@@ -1,3 +0,0 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="56" height="16" rx="3" fill="white" fill-opacity="0.48"/>
</svg>

Before

Width:  |  Height:  |  Size: 174 B

@@ -1,4 +0,0 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="56" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 252 B

@@ -1,4 +0,0 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="56" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

Before

Width:  |  Height:  |  Size: 252 B

@@ -1,3 +0,0 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="76" height="16" rx="3" fill="black" fill-opacity="0.32"/>
</svg>

Before

Width:  |  Height:  |  Size: 174 B

@@ -1,3 +0,0 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="76" height="16" rx="3" fill="white" fill-opacity="0.48"/>
</svg>

Before

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 223 KiB

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260930.0"
version = "20260930.4"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
Loaded 100 of 926 files, more files were not shown because too many files have changed in this diff. Show more