@hashsome/ui 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ import type { EntityHandle } from '../entity-handle.ts';
3
3
  /** The upright "now playing" panel the media column and page share: the artwork on a record, what is playing, how
4
4
  * far along it is, the transport buttons and a volume bar that is always there. Not exported from
5
5
  * the package; `MediaPlayerColumn` and `MediaPlayerFull` are its public forms. */
6
- export declare function NowPlaying({ handle, fallback, name, extra, leading, onOpenArtwork, size, shuffle: shufflePlacement, }: {
6
+ export declare function NowPlaying({ handle, fallback, name, extra, leading, speakers, onOpenArtwork, size, shuffle: shufflePlacement, }: {
7
7
  handle: EntityHandle<'mediaPlayer'>;
8
8
  /** What to call the player before it reports a name. */
9
9
  fallback: string;
@@ -13,6 +13,8 @@ export declare function NowPlaying({ handle, fallback, name, extra, leading, onO
13
13
  extra?: ReactNode;
14
14
  /** One more button, to the left of the transport, in the slot shuffle has when it sits by the title. Not shown while shuffle is in the row. */
15
15
  leading?: ReactNode;
16
+ /** What goes under the time, above the buttons: the speakers pill. */
17
+ speakers?: ReactNode;
16
18
  /** Makes the artwork a button that calls this (opens the library). */
17
19
  onOpenArtwork?: () => void;
18
20
  /** The artwork ring's diameter in px, for a roomier player. Default 168. */
@@ -15,7 +15,7 @@ import { LoadingLine, useLoadingLabel } from './media-loading-line.js';
15
15
  /** The upright "now playing" panel the media column and page share: the artwork on a record, what is playing, how
16
16
  * far along it is, the transport buttons and a volume bar that is always there. Not exported from
17
17
  * the package; `MediaPlayerColumn` and `MediaPlayerFull` are its public forms. */
18
- export function NowPlaying({ handle, fallback, name, extra, leading, onOpenArtwork, size, shuffle: shufflePlacement = 'transport', }) {
18
+ export function NowPlaying({ handle, fallback, name, extra, leading, speakers, onOpenArtwork, size, shuffle: shufflePlacement = 'transport', }) {
19
19
  const player = handle.entity;
20
20
  const { iconCircle, space } = useTheme().density;
21
21
  const transportWidth = 5 * iconCircle + 4 * space;
@@ -35,7 +35,7 @@ export function NowPlaying({ handle, fallback, name, extra, leading, onOpenArtwo
35
35
  // No wider than the row of five buttons under it (268px: five circles and the gaps between them).
36
36
  maxSize: transportWidth }), _jsxs(Flex, { direction: "column", align: "center", gap: 0.5, color: ready ? 'text' : 'textMuted', children: [_jsxs(Flex, { align: "center", justify: "center", gap: 2, maxWidth: "100%", children: [_jsx(Typography, { as: "h2", variant: "title", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", m: 0, minWidth: 0, children: ready
37
37
  ? (player?.media?.title ?? 'Nothing playing')
38
- : statusLabels[status] }), caps?.shuffle && shufflePlacement === 'title' ? (_jsx(IconButton, { icon: "lu:shuffle", label: "Shuffle", size: 14, active: player?.shuffle === true, pressed: player?.shuffle === true, disabled: !ready, feedback: feedbackFor('setShuffle'), onClick: () => press('setShuffle', () => handle.command('setShuffle', { shuffle: player?.shuffle !== true })) })) : null] }), loading ? (_jsx(LoadingLine, { label: loading, variant: "body" })) : ready && player?.media?.artist ? (_jsx(Typography, { as: "span", variant: "body", color: "textMuted", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", children: [player.media.artist, player.media.album].filter(Boolean).join(' · ') })) : (_jsx(Typography, { as: "span", variant: "body", color: "textMuted", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", children: name ?? player?.name ?? fallback }))] }), seek.shown !== undefined && duration ? (_jsxs(Typography, { as: "span", variant: "secondary", color: "textMuted", align: "center", css: { fontVariantNumeric: 'tabular-nums' }, children: [formatDuration(seek.shown), " / ", formatDuration(duration)] })) : null, _jsxs(Grid, { gap: 3, css: ({ density }) => ({
38
+ : statusLabels[status] }), caps?.shuffle && shufflePlacement === 'title' ? (_jsx(IconButton, { icon: "lu:shuffle", label: "Shuffle", size: 14, active: player?.shuffle === true, pressed: player?.shuffle === true, disabled: !ready, feedback: feedbackFor('setShuffle'), onClick: () => press('setShuffle', () => handle.command('setShuffle', { shuffle: player?.shuffle !== true })) })) : null] }), loading ? (_jsx(LoadingLine, { label: loading, variant: "body" })) : ready && player?.media?.artist ? (_jsx(Typography, { as: "span", variant: "body", color: "textMuted", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", children: [player.media.artist, player.media.album].filter(Boolean).join(' · ') })) : (_jsx(Typography, { as: "span", variant: "body", color: "textMuted", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", children: name ?? player?.name ?? fallback }))] }), seek.shown !== undefined && duration ? (_jsxs(Typography, { as: "span", variant: "secondary", color: "textMuted", align: "center", css: { fontVariantNumeric: 'tabular-nums' }, children: [formatDuration(seek.shown), " / ", formatDuration(duration)] })) : null, speakers ? (_jsx(Flex, { justify: "center", css: { marginTop: -4 }, children: speakers })) : null, _jsxs(Grid, { gap: 3, css: ({ density }) => ({
39
39
  gridTemplateColumns: `repeat(5, ${density.iconCircle}px)`,
40
40
  justifyContent: 'center',
41
41
  }), children: [caps?.shuffle && shufflePlacement === 'transport' ? (_jsx(IconButton, { icon: "lu:shuffle", label: "Shuffle", glyph: 18, active: player?.shuffle === true, pressed: player?.shuffle === true, disabled: !ready, feedback: feedbackFor('setShuffle'), onClick: () => press('setShuffle', () => handle.command('setShuffle', { shuffle: player?.shuffle !== true })) })) : shufflePlacement === 'title' ? ((leading ?? _jsx("span", {}))) : (_jsx("span", {})), _jsx(IconButton, { icon: "lu:skip-back", label: "Previous", glyph: 18, disabled: !ready || caps?.previous === false || !can.previous, feedback: feedbackFor('previous'), onClick: () => press('previous', () => handle.command('previous')) }), _jsx(IconButton, { icon: playing ? 'lu:pause' : 'lu:play', label: playing ? 'Pause' : 'Play', glyph: 18, disabled: !ready || !can.play, primary: true, feedback: feedbackFor('togglePlay'), onClick: () => press('togglePlay', () => handle.command('togglePlay')) }), _jsx(IconButton, { icon: "lu:skip-forward", label: "Next", glyph: 18, disabled: !ready || caps?.next === false || !can.next, feedback: feedbackFor('next'), onClick: () => press('next', () => handle.command('next')) }), extra ?? _jsx("span", {})] }), caps?.volume !== false || caps.mute ? (_jsxs(Flex, { align: "center", gap: 2, children: [_jsx(PlainButton, { "aria-label": volume.muted ? 'Unmute' : 'Mute', "aria-pressed": volume.muted, title: volume.muted ? 'Unmute' : 'Mute', disabled: !ready || caps?.mute === false, onClick: volume.toggleMute, center: true, color: volume.muted ? 'accent' : 'textMuted', css: { flex: 'none' }, children: _jsx(Icon, { name: volume.muted ? 'lu:volume-x' : 'lu:volume-2', size: 16 }) }), _jsx(ValueBar, { label: "Volume level", value: volume.shown, min: 0, max: 100, keyStep: 5, height: 10, onDrag: volume.drag, onCommit: volume.commit }), _jsxs(Typography, { as: "span", variant: "secondary", color: "textMuted", minWidth: 32, align: "right", css: { flex: 'none', fontVariantNumeric: 'tabular-nums' }, children: [volume.shown, "%"] })] })) : null] }));
@@ -123,6 +123,56 @@ export function createGalleryIntegration() {
123
123
  name: 'Slow library',
124
124
  capabilities: { browse: true, search: true },
125
125
  }),
126
+ // Players that can be grouped, for the speakers picker: a stream led by the porch with the bedroom in it, one
127
+ // playing something else, one on its own, and one that is off.
128
+ 'media_player.porch': mockMediaPlayer({
129
+ name: 'Porch',
130
+ playback: 'playing',
131
+ media: { title: 'Dreams', artist: 'Fleetwood Mac', artworkUrl: DEMO_ARTWORK },
132
+ volume: 0.35,
133
+ group: { leader: 'ha:media_player.porch', members: ['ha:media_player.bedroom'] },
134
+ groupable: [
135
+ 'ha:media_player.bedroom',
136
+ 'ha:media_player.patio',
137
+ 'ha:media_player.office',
138
+ 'ha:media_player.garage',
139
+ ],
140
+ capabilities: { group: true },
141
+ }),
142
+ 'media_player.bedroom': mockMediaPlayer({
143
+ name: 'Bedroom',
144
+ playback: 'playing',
145
+ media: { title: 'Dreams', artist: 'Fleetwood Mac', artworkUrl: DEMO_ARTWORK },
146
+ volume: 0.2,
147
+ group: { leader: 'ha:media_player.porch', members: ['ha:media_player.bedroom'] },
148
+ groupable: ['ha:media_player.porch', 'ha:media_player.patio', 'ha:media_player.office'],
149
+ capabilities: { group: true },
150
+ }),
151
+ 'media_player.patio': mockMediaPlayer({
152
+ name: 'Patio',
153
+ playback: 'playing',
154
+ media: { title: 'Blue Monday', artist: 'New Order' },
155
+ volume: 0.5,
156
+ groupable: ['ha:media_player.porch', 'ha:media_player.bedroom', 'ha:media_player.office'],
157
+ capabilities: { group: true },
158
+ }),
159
+ 'media_player.office': mockMediaPlayer({
160
+ name: 'Office',
161
+ volume: 0.15,
162
+ groupable: [
163
+ 'ha:media_player.porch',
164
+ 'ha:media_player.bedroom',
165
+ 'ha:media_player.patio',
166
+ 'ha:media_player.garage',
167
+ ],
168
+ capabilities: { group: true },
169
+ }),
170
+ 'media_player.garage': mockMediaPlayer({
171
+ name: 'Garage',
172
+ availability: 'unavailable',
173
+ playback: 'off',
174
+ capabilities: { group: true },
175
+ }),
126
176
  'media_player.off': mockMediaPlayer({
127
177
  name: 'Kitchen Speaker',
128
178
  availability: 'unavailable',
@@ -13,6 +13,7 @@ import { AnimatedOutlet } from '../layout/animated-outlet.js';
13
13
  import { MediaPlayerColumn } from '../entities/media-player-column.js';
14
14
  import { MediaPlayerFull } from '../entities/media-player-full.js';
15
15
  import { MediaQueue } from '../entities/media-queue.js';
16
+ import { MediaSpeakers } from '../entities/media-speakers.js';
16
17
  import { WeatherForecast } from '../entities/weather-forecast.js';
17
18
  import { NavDock } from '../entities/nav-dock.js';
18
19
  import { NavRail } from '../entities/nav-rail.js';
@@ -400,7 +401,11 @@ export function Gallery({ density = 'comfortable' }) {
400
401
  boxShadow: '0 1px 3px rgba(0, 0, 0, 0.25)',
401
402
  transform: mode === 'dark' ? 'translate(28px, -50%)' : 'translateY(-50%)',
402
403
  transition: 'transform 0.2s ease',
403
- }) }), _jsx(Icon, { name: "lu:sun", size: 14 }), _jsx(Icon, { name: "lu:moon", size: 14 })] })] }), _jsx(ComponentDoc, { title: "Board & Cell", components: ['Board', 'Cell'], description: "A page for one known device, laid out on a grid. Each cell says only how big it is (`cols` wide, `rows` tall); the board places them in order, in the first spot they fit. Rows are as tall as their content, so a cell of `rows={2}` ends where the second row does, and `rows='fill'` runs from where the cell is placed down to the bottom of the page, whatever rows are beside it. Every card is a whole number of grid modules tall, the gap between columns and inside a cell is three modules and between rows six, so the result stays on the grid (turn on the debug menu's Show grid to see it).", children: _jsxs(Flex, { direction: "column", gap: 5, children: [_jsxs(Board, { children: [_jsxs(Cell, { cols: 8, children: [_jsx(RoomHeader, { title: "Kitchen", icon: "lu:utensils-crossed" }), _jsxs(Grid, { columns: 2, children: [_jsx(Tile, { label: "Lamp", icon: "lu:lightbulb" }), _jsx(Tile, { label: "Ceiling", icon: "lu:lightbulb" })] })] }), _jsx(Cell, { cols: 4, rows: 2, children: _jsx(MediaPlayerColumn, { entity: "ha:media_player.off" }) }), _jsxs(Cell, { cols: 8, children: [_jsx(RoomHeader, { title: "Stairs", icon: "lu:footprints" }), _jsxs(Grid, { columns: 2, children: [_jsx(Tile, { label: "Lamp", icon: "lu:lightbulb" }), _jsx(Tile, { label: "Spotlights", icon: "lu:lightbulb" })] })] })] }), _jsx(Flex, { direction: "column", height: 460, css: { outline: '1px dashed currentColor' }, children: _jsxs(Board, { children: [_jsx(Cell, { children: _jsxs(TopRow, { children: [_jsx(DateChip, {}), _jsx(Clock, { format: "24h" })] }) }), _jsxs(Cell, { cols: 8, children: [_jsx(RoomHeader, { title: "Kitchen", icon: "lu:utensils-crossed" }), _jsxs(Grid, { columns: 2, children: [_jsx(Tile, { label: "Lamp", icon: "lu:lightbulb" }), _jsx(Tile, { label: "Ceiling", icon: "lu:lightbulb" })] })] }), _jsx(Cell, { cols: 4, rows: "fill", children: _jsx(MediaPlayerColumn, { entity: "ha:media_player.off" }) })] }) })] }) }), _jsx(ComponentDoc, { title: "Top Row", components: ['TopRow'], description: "The row along the top of a page you build yourself instead of with `TopBar`: the date, the weather, the clock and the status in a line at the right, in the height the grid gives the top row (40px), with no padding of its own.", children: _jsxs(TopRow, { children: [_jsx(DateChip, {}), _jsx(Clock, { format: "24h" })] }) }), _jsx(ComponentDoc, { title: "Grid", components: ['Grid'], description: "Lays tiles out in equal-width columns (`columns`, default 2). Cells share the width and never overflow it.", children: _jsxs(Flex, { direction: "column", gap: 5, children: [_jsxs(Grid, { columns: 2, children: [_jsx(Tile, { label: "Two", icon: "lu:layout-grid", secondary: "columns={2}" }), _jsx(Tile, { label: "Columns", icon: "lu:layout-grid", secondary: "equal width" })] }), _jsxs(Grid, { columns: 3, children: [_jsx(Tile, { label: "Three", icon: "lu:layout-grid", secondary: "columns={3}" }), _jsx(Tile, { label: "Columns", icon: "lu:layout-grid", secondary: "equal width" }), _jsx(Tile, { label: "Wide", icon: "lu:layout-grid", secondary: "fits the row" })] })] }) }), _jsx(ComponentDoc, { title: "Room Header", components: ['RoomHeader'], description: "The header that starts a room: a muted icon and title, a hairline and readouts on the right, placed above a Grid (or any tiles). It adds its own space above, so consecutive rooms read as groups.", children: _jsxs(Flex, { direction: "column", gap: 4, children: [_jsx(RoomHeader, { title: "Kitchen", icon: "lu:utensils-crossed" }), _jsx(RoomHeader, { title: "Porch", icon: "lu:house", readouts: _jsxs(_Fragment, { children: [_jsx(SensorReadout, { entity: "ha:sensor.temperature" }), _jsx(SensorReadout, { entity: "ha:sensor.humidity" })] }) })] }) }), _jsx(ComponentDoc, { title: "Tile", components: ['Tile'], description: "The base card every entity tile is built from: icon, label, status line, optional fill bar (drag to change it), trailing controls, a hold-to-open drawer and pending / done / error feedback. Use it directly for something that isn't an entity.", children: _jsxs(Grid, { children: [_jsx(Tile, { label: "Plain", icon: "lu:lamp", secondary: "Off" }), _jsx(Tile, { label: "Active", icon: "lu:lamp", active: true }), _jsx(TileFillDemo, {}), _jsx(Tile, { label: "With controls", icon: "lu:fan", trailing: _jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: "trailing" }) }), _jsx(Tile, { label: "Pending", icon: "lu:lamp", feedback: "pending" }), _jsx(Tile, { label: "Error", icon: "lu:lamp", feedback: "error" }), _jsx(Tile, { label: "Unavailable", icon: "lu:lamp", status: "unavailable" }), _jsx(Tile, { label: "Not found", icon: "lu:lamp", status: "missing" })] }) }), _jsx(ComponentDoc, { title: "Task Tile", components: ['TaskTile'], description: "A recurring task (a filter to change, an additive to add) as a tile: its name, where it stands (up to date, due soon, overdue) and when it is due. Whoever has the task decides the state, since how soon is 'soon' differs between a weekly task and a yearly one. Tap one: it asks whether it is done, and for the day it was done on (today, or an earlier one if it was forgotten), and only a confirmation calls `onComplete`. Marking one done here only waits a moment.", children: _jsxs(Grid, { children: [_jsx(TaskTile, { label: "Filter", icon: "lu:wrench", state: "ok", dueAt: inDays(20), lastDoneAt: inDays(-10), onComplete: pause }), _jsx(TaskTile, { label: "Additive", icon: "lu:pipette", state: "dueSoon", dueAt: inDays(2), lastDoneAt: inDays(-5), onComplete: pause }), _jsx(TaskTile, { label: "Salt", icon: "lu:shaker", state: "overdue", dueAt: inDays(-3), lastDoneAt: inDays(-10), onComplete: pause }), _jsx(TaskTile, { label: "Not found", icon: "lu:wrench", state: "ok", status: "missing", onComplete: pause })] }) }), _jsx(ComponentDoc, { title: "Confirm Dialog", components: ['ConfirmDialog'], description: "A question that has to be answered before something is done, so a stray tap cannot do it. It is drawn over the whole page, with a way to back out (Cancel, a tap outside, Escape) and a button to go ahead; more to ask for (a date, say) goes inside it.", children: _jsx(ConfirmDialogDemo, {}) }), _jsx(ComponentDoc, { title: "Icon", components: ['Icon'], description: "A plain prefixed string, no import: `lu:` is Lucide, `tb:` is Tabler outline. It takes the surrounding text color and scales with `size` (default 24px).", children: _jsxs(Flex, { direction: "column", gap: 4, children: [_jsxs(Flex, { align: "center", gap: 4, children: [_jsx(Icon, { name: "lu:lightbulb" }), _jsx(Icon, { name: "lu:thermometer", size: 16 }), _jsx(Icon, { name: "lu:sofa", size: 32 }), _jsx(Icon, { name: "tb:vacuum-cleaner" }), _jsx(Icon, { name: "tb:car-fan", size: 16 })] }), _jsxs(Flex, { align: "center", gap: 4, css: { flexWrap: 'wrap' }, children: [_jsx(Typography, { as: "span", variant: "body", color: "textMuted", children: "Find an icon:" }), ICON_PACKS.map((pack) => (_jsx(Typography, { as: "span", variant: "body", children: _jsxs("a", { css: ({ palette }) => ({ color: palette.accent, textUnderlineOffset: 3 }), href: pack.href, target: "_blank", rel: "noreferrer", children: ["Search ", pack.name, " icons (", _jsxs("span", { css: codeStyles, children: [pack.prefix, ":"] }), ")"] }) }, pack.prefix)))] })] }) }), _jsx(ComponentDoc, { title: "Energy Chart", components: ['EnergyChart'], description: "A power section for a drawer, which tiles pass through `energy`: a live power reading, usage tiles and a sparkline. Expanded in a drawer it becomes the full chart with a range switcher.", children: _jsx(EnergyChart, { power: "ha:sensor.heater_power", energy: "ha:sensor.heater_energy", usage: heaterUsage, seriesByRange: heaterSeries }) }), _jsx(ComponentDoc, { title: "History Section", components: ['HistorySection'], description: "Recent activity for a drawer, which tiles pass through `history`: who or what changed the entity, and when. An empty list renders nothing.", children: _jsx(HistorySection, { entries: lampHistory }) }), _jsx(ComponentDoc, { title: "Light Tile", components: ['LightTile'], description: "Tap to toggle, hold to open its detail drawer. Dimmable lights drag to set brightness; color-capable lights add a palette button that opens swatches in the card (white temperatures by default, overridable with `colors`), and the card's icon shows the light's current color. The drawer has the full temperature and hue bars.", children: _jsxs(Grid, { children: [_jsx(LightTile, { entity: "ha:light.plain_on", energy: lampEnergy, history: lampHistory }), _jsx(LightTile, { entity: "ha:light.plain_off", history: stairsHistory }), _jsx(LightTile, { entity: "ha:light.dimmable", icon: "lu:sparkles", energy: ledEnergy, history: ledHistory }), _jsx(LightTile, { entity: "ha:light.color", icon: "lu:lamp-desk", history: nightLampHistory }), _jsx(LightTile, { entity: "ha:light.cct", icon: "lu:lamp-desk" }), _jsx(LightTile, { entity: "ha:light.unavailable" }), _jsx(LightTile, { entity: "ha:light.missing" })] }) }), _jsx(ComponentDoc, { title: "Climate Tile", components: ['ClimateTile'], description: "A heater/thermostat tile: a mode button that opens mode swatches in the card (like light colors), plus a target-temperature stepper. Hold for the drawer: modes, a target bar with fine +/- (hold to repeat), presets, energy and history.", children: _jsxs(Grid, { columns: 1, children: [_jsx(ClimateTile, { entity: "ha:climate.heater", energy: heaterEnergy, history: heaterHistory }), _jsx(ClimateTile, { entity: "ha:climate.off", energy: thermostatEnergy, history: heaterHistory }), _jsx(ClimateTile, { entity: "ha:climate.unavailable" })] }) }), _jsx(ComponentDoc, { title: "Animated Number", components: ['AnimatedNumber'], description: "A number that moves to its new value instead of jumping: a power reading, a temperature. The first value is shown at once; a new one is moved to from what is shown now, even if the last move has not finished. The text is changed directly, with no render for each step, so many of them cost little, and nothing moves when the app is set to reduced motion. `format` writes a number with its unit and should round, as it is called for each step. The drawer's power reading, the sensor readouts and a thermostat's current temperature use it.", children: _jsx(AnimatedNumberDemo, {}) }), _jsx(ComponentDoc, { title: "Sensor Readout", components: ['SensorReadout'], description: "An inline value with icon and unit, defaulting its icon by device class. Readings outside a safe range get a warning (humidity defaults to 30\u201360 %; override with `safeRange`). Pass `history` to make it tappable: the drawer charts it with min/max for today, this week and this month, and a custom date and time range when expanded.", children: _jsxs(Grid, { columns: 3, children: [_jsx(SensorReadout, { entity: "ha:sensor.temperature", history: temperatureHistory }), _jsx(SensorReadout, { entity: "ha:sensor.humidity", history: humidityHistory }), _jsx(SensorReadout, { entity: "ha:sensor.bedroom_humidity", history: bedroomHumidityHistory }), _jsx(SensorReadout, { entity: "ha:sensor.office_humidity", history: officeHumidityHistory }), _jsx(SensorReadout, { entity: "ha:sensor.unavailable" }), _jsx(SensorReadout, { entity: "ha:sensor.unknown" })] }) }), _jsx(ComponentDoc, { title: "Scene Button & Action Button", components: ['SceneButton', 'ActionButton'], description: "A scene is just an action entity. An action button triggers an action (or flips a switch) and shows pending / done / error feedback after a press; `onPress` runs your own logic instead.", children: _jsxs(Grid, { columns: 3, children: [_jsx(SceneButton, { entity: "ha:scene.tv_time" }), _jsx(ActionButton, { label: "Shower", icon: "tb:bath", entity: "ha:script.shower" }), _jsx(ActionButton, { label: "Always Fails", onPress: () => Promise.reject(new Error('That went wrong')) })] }) }), _jsx(ComponentDoc, { title: "Media Player Bar", components: ['MediaPlayerBar'], description: "Track info (with a small shuffle toggle by the title) and the time beside it, then volume, browse, previous, play/pause and next (the artwork opens the browser too), with a thin progress line along the bottom while something plays. The browse button opens the player's own library in the drawer; pass `browse` content to replace it, or `false` for no button. The volume button opens a volume slider in the bar (with a mute swatch, like the light colors and climate modes). Shows album art, or a note glyph when there is none. In a space narrower than 560px, or with `rows={2}`, the controls move to a second row under the track.", children: _jsxs(Flex, { direction: "column", gap: 3, children: [_jsx(MediaPlayerBar, { entity: "ha:media_player.living_room" }), _jsx(MediaPlayerBar, { entity: "ha:media_player.off" }), _jsx(Flex, { width: 380, children: _jsx(MediaPlayerBar, { entity: "ha:media_player.living_room", rows: 2 }) })] }) }), _jsx(ComponentDoc, { title: "Media Player Column", components: ['MediaPlayerColumn'], description: "The player as an upright card for a narrow column beside a dashboard: the artwork in a ring that shows how far along playback is (drag around it to seek), title, transport, and a volume bar that is always visible. The browse button opens the library in the drawer.", children: _jsx(Flex, { width: 320, children: _jsx(MediaPlayerColumn, { entity: "ha:media_player.living_room" }) }) }), _jsx(ComponentDoc, { title: "Media Player Full", components: ['MediaPlayerFull', 'MediaQueue'], description: "The big player: the player centered with its queue beside it (`MediaQueue`: tap a track to jump to it, the cross takes it out, Clear empties it), and its library below, where every track can be played, played next or added to the queue, and an open album or playlist can be played, shuffled or added whole. The media cards' drawers show the player and library too. Where the queue has no room beside the player (a narrow column, the side drawer), Library and Queue are tabs above the list; `tabs={false}` leaves only the library, `defaultTab` and `tab` / `onTabChange` choose which is open. A dashboard puts it in a page of its own, around it whatever it likes.", children: _jsx(Flex, { direction: "column", children: _jsx(MediaPlayerFull, { entity: "ha:media_player.living_room", wide: true, queue: _jsx(MediaQueue, { entity: "ha:media_player.living_room" }), browser: _jsx(MediaBrowser, { entity: "ha:media_player.living_room", layout: "theater" }) }) }) }), _jsx(ComponentDoc, { title: "Weather Forecast", components: ['WeatherForecast'], description: "A weather entity's forecast: the weather now, the next 24 hours as a chart (hover it for everything about an hour) and the days ahead. The top bar's weather chip shows it in a drawer; a dashboard can put it in a page of its own. Narrow, then with `expanded` (readings tiles, a card for each hour).", children: _jsxs(Flex, { direction: "column", gap: 6, children: [_jsx(Flex, { direction: "column", width: 380, children: _jsx(WeatherForecast, { entity: "ha:weather.home", expanded: false }) }), _jsx(WeatherForecast, { entity: "ha:weather.home", expanded: true })] }) }), _jsx(ComponentDoc, { title: "Media Browser", components: ['MediaBrowser'], description: "A player's own library in the app's style: tap a folder to open it, a track or station to play it, or the play button on an album, playlist or artist for all of it. The search box appears when the library can be searched; with `search='icon'` (the second demo) it is an icon at the end of the row of tabs, after a thin line, and pressing it turns that row into the field, so it takes no room of its own. The `theater` layout (third demo) is one row of large cards that scrolls sideways, for a wide space; `list` is the compact default. While a shelf loads, placeholders take the room the cards will (fourth demo, a shelf that never arrives), so nothing moves.", children: _jsxs(Flex, { direction: "column", gap: 6, children: [_jsx(Flex, { direction: "column", maxHeight: 420, overflow: "auto", children: _jsx(MediaBrowser, { entity: "ha:media_player.living_room" }) }), _jsx(Flex, { direction: "column", maxHeight: 420, overflow: "auto", children: _jsx(MediaBrowser, { entity: "ha:media_player.living_room", search: "icon" }) }), _jsx(MediaBrowser, { entity: "ha:media_player.living_room", layout: "theater" }), _jsx(MediaBrowser, { entity: "ha:media_player.loading", layout: "theater" })] }) }), _jsx(ComponentDoc, { title: "Navigation", components: ['NavRail', 'NavDock', 'Page'], description: "Routes within one dashboard. A dashboard includes one itself, usually in its layout route: NavRail is fixed to the left and NavDock floats at the bottom, and both reserve the space they take in the Page around them, which pads so content never sits under them. An item can carry a dot (`attention`: `notice` or `urgent`) when something on its page needs a look, so it is seen without opening the page.", children: _jsxs(Flex, { direction: "column", gap: 5, children: [_jsx(SubPreview, { label: "NavRail, in a Page", height: 240, children: _jsxs(Page, { height: "100%", children: [_jsx(NavRail, { base: "/gallery", items: NAV_ITEMS }), _jsx(Typography, { as: "p", variant: "body", color: "textMuted", children: "Page content, padded clear of the rail." })] }) }), _jsx(SubPreview, { label: "NavDock", height: 140, children: _jsx(NavDock, { base: "/gallery", items: NAV_ITEMS }) }), _jsx(SubPreview, { label: "NavRail, with something to look at on two pages", height: 240, children: _jsxs(Page, { height: "100%", children: [_jsx(NavRail, { base: "/gallery", items: ATTENTION_ITEMS }), _jsx(Typography, { as: "p", variant: "body", color: "textMuted", children: "A warm dot says something is coming up, a red one that it needs doing now." })] }) }), _jsx(SubPreview, { label: "NavDock, with something to look at on two pages", height: 140, children: _jsx(NavDock, { base: "/gallery", items: ATTENTION_ITEMS }) })] }) }), _jsx(ComponentDoc, { title: "Animated Outlet", components: ['AnimatedOutlet'], description: "A dashboard layout's `Outlet` with a transition between its pages: the page that was showing fades out, then the new one fades in, sliding from the side it sits on in the nav (a later page from the right, an earlier one from the left) when you give it the nav's `items`. Without them the pages only fade. It does nothing for a visitor who prefers reduced motion. Try the links.", children: _jsx(AnimatedOutletDemo, {}) }), _jsx(ComponentDoc, { title: "Top Bar", components: ['TopBar'], description: "Page chrome above a dashboard's content: a title (a dropdown to switch dashboards, once there's more than one to switch to), scene shortcuts, weather (tap it for the forecast), presence, and a clock.", children: _jsx(TopBar, { title: "Living Room", dashboards: [
404
+ }) }), _jsx(Icon, { name: "lu:sun", size: 14 }), _jsx(Icon, { name: "lu:moon", size: 14 })] })] }), _jsx(ComponentDoc, { title: "Board & Cell", components: ['Board', 'Cell'], description: "A page for one known device, laid out on a grid. Each cell says only how big it is (`cols` wide, `rows` tall); the board places them in order, in the first spot they fit. Rows are as tall as their content, so a cell of `rows={2}` ends where the second row does, and `rows='fill'` runs from where the cell is placed down to the bottom of the page, whatever rows are beside it. Every card is a whole number of grid modules tall, the gap between columns and inside a cell is three modules and between rows six, so the result stays on the grid (turn on the debug menu's Show grid to see it).", children: _jsxs(Flex, { direction: "column", gap: 5, children: [_jsxs(Board, { children: [_jsxs(Cell, { cols: 8, children: [_jsx(RoomHeader, { title: "Kitchen", icon: "lu:utensils-crossed" }), _jsxs(Grid, { columns: 2, children: [_jsx(Tile, { label: "Lamp", icon: "lu:lightbulb" }), _jsx(Tile, { label: "Ceiling", icon: "lu:lightbulb" })] })] }), _jsx(Cell, { cols: 4, rows: 2, children: _jsx(MediaPlayerColumn, { entity: "ha:media_player.off" }) }), _jsxs(Cell, { cols: 8, children: [_jsx(RoomHeader, { title: "Stairs", icon: "lu:footprints" }), _jsxs(Grid, { columns: 2, children: [_jsx(Tile, { label: "Lamp", icon: "lu:lightbulb" }), _jsx(Tile, { label: "Spotlights", icon: "lu:lightbulb" })] })] })] }), _jsx(Flex, { direction: "column", height: 460, css: { outline: '1px dashed currentColor' }, children: _jsxs(Board, { children: [_jsx(Cell, { children: _jsxs(TopRow, { children: [_jsx(DateChip, {}), _jsx(Clock, { format: "24h" })] }) }), _jsxs(Cell, { cols: 8, children: [_jsx(RoomHeader, { title: "Kitchen", icon: "lu:utensils-crossed" }), _jsxs(Grid, { columns: 2, children: [_jsx(Tile, { label: "Lamp", icon: "lu:lightbulb" }), _jsx(Tile, { label: "Ceiling", icon: "lu:lightbulb" })] })] }), _jsx(Cell, { cols: 4, rows: "fill", children: _jsx(MediaPlayerColumn, { entity: "ha:media_player.off" }) })] }) })] }) }), _jsx(ComponentDoc, { title: "Top Row", components: ['TopRow'], description: "The row along the top of a page you build yourself instead of with `TopBar`: the date, the weather, the clock and the status in a line at the right, in the height the grid gives the top row (40px), with no padding of its own.", children: _jsxs(TopRow, { children: [_jsx(DateChip, {}), _jsx(Clock, { format: "24h" })] }) }), _jsx(ComponentDoc, { title: "Grid", components: ['Grid'], description: "Lays tiles out in equal-width columns (`columns`, default 2). Cells share the width and never overflow it.", children: _jsxs(Flex, { direction: "column", gap: 5, children: [_jsxs(Grid, { columns: 2, children: [_jsx(Tile, { label: "Two", icon: "lu:layout-grid", secondary: "columns={2}" }), _jsx(Tile, { label: "Columns", icon: "lu:layout-grid", secondary: "equal width" })] }), _jsxs(Grid, { columns: 3, children: [_jsx(Tile, { label: "Three", icon: "lu:layout-grid", secondary: "columns={3}" }), _jsx(Tile, { label: "Columns", icon: "lu:layout-grid", secondary: "equal width" }), _jsx(Tile, { label: "Wide", icon: "lu:layout-grid", secondary: "fits the row" })] })] }) }), _jsx(ComponentDoc, { title: "Room Header", components: ['RoomHeader'], description: "The header that starts a room: a muted icon and title, a hairline and readouts on the right, placed above a Grid (or any tiles). It adds its own space above, so consecutive rooms read as groups.", children: _jsxs(Flex, { direction: "column", gap: 4, children: [_jsx(RoomHeader, { title: "Kitchen", icon: "lu:utensils-crossed" }), _jsx(RoomHeader, { title: "Porch", icon: "lu:house", readouts: _jsxs(_Fragment, { children: [_jsx(SensorReadout, { entity: "ha:sensor.temperature" }), _jsx(SensorReadout, { entity: "ha:sensor.humidity" })] }) })] }) }), _jsx(ComponentDoc, { title: "Tile", components: ['Tile'], description: "The base card every entity tile is built from: icon, label, status line, optional fill bar (drag to change it), trailing controls, a hold-to-open drawer and pending / done / error feedback. Use it directly for something that isn't an entity.", children: _jsxs(Grid, { children: [_jsx(Tile, { label: "Plain", icon: "lu:lamp", secondary: "Off" }), _jsx(Tile, { label: "Active", icon: "lu:lamp", active: true }), _jsx(TileFillDemo, {}), _jsx(Tile, { label: "With controls", icon: "lu:fan", trailing: _jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: "trailing" }) }), _jsx(Tile, { label: "Pending", icon: "lu:lamp", feedback: "pending" }), _jsx(Tile, { label: "Error", icon: "lu:lamp", feedback: "error" }), _jsx(Tile, { label: "Unavailable", icon: "lu:lamp", status: "unavailable" }), _jsx(Tile, { label: "Not found", icon: "lu:lamp", status: "missing" })] }) }), _jsx(ComponentDoc, { title: "Task Tile", components: ['TaskTile'], description: "A recurring task (a filter to change, an additive to add) as a tile: its name, where it stands (up to date, due soon, overdue) and when it is due. Whoever has the task decides the state, since how soon is 'soon' differs between a weekly task and a yearly one. Tap one: it asks whether it is done, and for the day it was done on (today, or an earlier one if it was forgotten), and only a confirmation calls `onComplete`. Marking one done here only waits a moment.", children: _jsxs(Grid, { children: [_jsx(TaskTile, { label: "Filter", icon: "lu:wrench", state: "ok", dueAt: inDays(20), lastDoneAt: inDays(-10), onComplete: pause }), _jsx(TaskTile, { label: "Additive", icon: "lu:pipette", state: "dueSoon", dueAt: inDays(2), lastDoneAt: inDays(-5), onComplete: pause }), _jsx(TaskTile, { label: "Salt", icon: "lu:shaker", state: "overdue", dueAt: inDays(-3), lastDoneAt: inDays(-10), onComplete: pause }), _jsx(TaskTile, { label: "Not found", icon: "lu:wrench", state: "ok", status: "missing", onComplete: pause })] }) }), _jsx(ComponentDoc, { title: "Confirm Dialog", components: ['ConfirmDialog'], description: "A question that has to be answered before something is done, so a stray tap cannot do it. It is drawn over the whole page, with a way to back out (Cancel, a tap outside, Escape) and a button to go ahead; more to ask for (a date, say) goes inside it.", children: _jsx(ConfirmDialogDemo, {}) }), _jsx(ComponentDoc, { title: "Icon", components: ['Icon'], description: "A plain prefixed string, no import: `lu:` is Lucide, `tb:` is Tabler outline. It takes the surrounding text color and scales with `size` (default 24px).", children: _jsxs(Flex, { direction: "column", gap: 4, children: [_jsxs(Flex, { align: "center", gap: 4, children: [_jsx(Icon, { name: "lu:lightbulb" }), _jsx(Icon, { name: "lu:thermometer", size: 16 }), _jsx(Icon, { name: "lu:sofa", size: 32 }), _jsx(Icon, { name: "tb:vacuum-cleaner" }), _jsx(Icon, { name: "tb:car-fan", size: 16 })] }), _jsxs(Flex, { align: "center", gap: 4, css: { flexWrap: 'wrap' }, children: [_jsx(Typography, { as: "span", variant: "body", color: "textMuted", children: "Find an icon:" }), ICON_PACKS.map((pack) => (_jsx(Typography, { as: "span", variant: "body", children: _jsxs("a", { css: ({ palette }) => ({ color: palette.accent, textUnderlineOffset: 3 }), href: pack.href, target: "_blank", rel: "noreferrer", children: ["Search ", pack.name, " icons (", _jsxs("span", { css: codeStyles, children: [pack.prefix, ":"] }), ")"] }) }, pack.prefix)))] })] }) }), _jsx(ComponentDoc, { title: "Energy Chart", components: ['EnergyChart'], description: "A power section for a drawer, which tiles pass through `energy`: a live power reading, usage tiles and a sparkline. Expanded in a drawer it becomes the full chart with a range switcher.", children: _jsx(EnergyChart, { power: "ha:sensor.heater_power", energy: "ha:sensor.heater_energy", usage: heaterUsage, seriesByRange: heaterSeries }) }), _jsx(ComponentDoc, { title: "History Section", components: ['HistorySection'], description: "Recent activity for a drawer, which tiles pass through `history`: who or what changed the entity, and when. An empty list renders nothing.", children: _jsx(HistorySection, { entries: lampHistory }) }), _jsx(ComponentDoc, { title: "Light Tile", components: ['LightTile'], description: "Tap to toggle, hold to open its detail drawer. Dimmable lights drag to set brightness; color-capable lights add a palette button that opens swatches in the card (white temperatures by default, overridable with `colors`), and the card's icon shows the light's current color. The drawer has the full temperature and hue bars.", children: _jsxs(Grid, { children: [_jsx(LightTile, { entity: "ha:light.plain_on", energy: lampEnergy, history: lampHistory }), _jsx(LightTile, { entity: "ha:light.plain_off", history: stairsHistory }), _jsx(LightTile, { entity: "ha:light.dimmable", icon: "lu:sparkles", energy: ledEnergy, history: ledHistory }), _jsx(LightTile, { entity: "ha:light.color", icon: "lu:lamp-desk", history: nightLampHistory }), _jsx(LightTile, { entity: "ha:light.cct", icon: "lu:lamp-desk" }), _jsx(LightTile, { entity: "ha:light.unavailable" }), _jsx(LightTile, { entity: "ha:light.missing" })] }) }), _jsx(ComponentDoc, { title: "Climate Tile", components: ['ClimateTile'], description: "A heater/thermostat tile: a mode button that opens mode swatches in the card (like light colors), plus a target-temperature stepper. Hold for the drawer: modes, a target bar with fine +/- (hold to repeat), presets, energy and history.", children: _jsxs(Grid, { columns: 1, children: [_jsx(ClimateTile, { entity: "ha:climate.heater", energy: heaterEnergy, history: heaterHistory }), _jsx(ClimateTile, { entity: "ha:climate.off", energy: thermostatEnergy, history: heaterHistory }), _jsx(ClimateTile, { entity: "ha:climate.unavailable" })] }) }), _jsx(ComponentDoc, { title: "Animated Number", components: ['AnimatedNumber'], description: "A number that moves to its new value instead of jumping: a power reading, a temperature. The first value is shown at once; a new one is moved to from what is shown now, even if the last move has not finished. The text is changed directly, with no render for each step, so many of them cost little, and nothing moves when the app is set to reduced motion. `format` writes a number with its unit and should round, as it is called for each step. The drawer's power reading, the sensor readouts and a thermostat's current temperature use it.", children: _jsx(AnimatedNumberDemo, {}) }), _jsx(ComponentDoc, { title: "Sensor Readout", components: ['SensorReadout'], description: "An inline value with icon and unit, defaulting its icon by device class. Readings outside a safe range get a warning (humidity defaults to 30\u201360 %; override with `safeRange`). Pass `history` to make it tappable: the drawer charts it with min/max for today, this week and this month, and a custom date and time range when expanded.", children: _jsxs(Grid, { columns: 3, children: [_jsx(SensorReadout, { entity: "ha:sensor.temperature", history: temperatureHistory }), _jsx(SensorReadout, { entity: "ha:sensor.humidity", history: humidityHistory }), _jsx(SensorReadout, { entity: "ha:sensor.bedroom_humidity", history: bedroomHumidityHistory }), _jsx(SensorReadout, { entity: "ha:sensor.office_humidity", history: officeHumidityHistory }), _jsx(SensorReadout, { entity: "ha:sensor.unavailable" }), _jsx(SensorReadout, { entity: "ha:sensor.unknown" })] }) }), _jsx(ComponentDoc, { title: "Scene Button & Action Button", components: ['SceneButton', 'ActionButton'], description: "A scene is just an action entity. An action button triggers an action (or flips a switch) and shows pending / done / error feedback after a press; `onPress` runs your own logic instead.", children: _jsxs(Grid, { columns: 3, children: [_jsx(SceneButton, { entity: "ha:scene.tv_time" }), _jsx(ActionButton, { label: "Shower", icon: "tb:bath", entity: "ha:script.shower" }), _jsx(ActionButton, { label: "Always Fails", onPress: () => Promise.reject(new Error('That went wrong')) })] }) }), _jsx(ComponentDoc, { title: "Media Player Bar", components: ['MediaPlayerBar'], description: "Track info (with a small shuffle toggle by the title) and the time beside it, then volume, browse, previous, play/pause and next (the artwork opens the browser too), with a thin progress line along the bottom while something plays. The browse button opens the player's own library in the drawer; pass `browse` content to replace it, or `false` for no button. The volume button opens a volume slider in the bar (with a mute swatch, like the light colors and climate modes). Shows album art, or a note glyph when there is none. In a space narrower than 560px, or with `rows={2}`, the controls move to a second row under the track.", children: _jsxs(Flex, { direction: "column", gap: 3, children: [_jsx(MediaPlayerBar, { entity: "ha:media_player.living_room" }), _jsx(MediaPlayerBar, { entity: "ha:media_player.off" }), _jsx(Flex, { width: 380, children: _jsx(MediaPlayerBar, { entity: "ha:media_player.living_room", rows: 2 }) })] }) }), _jsx(ComponentDoc, { title: "Media Player Column", components: ['MediaPlayerColumn'], description: "The player as an upright card for a narrow column beside a dashboard: the artwork in a ring that shows how far along playback is (drag around it to seek), title, transport, and a volume bar that is always visible. The browse button opens the library in the drawer.", children: _jsx(Flex, { width: 320, children: _jsx(MediaPlayerColumn, { entity: "ha:media_player.living_room" }) }) }), _jsx(ComponentDoc, { title: "Media Player Full", components: ['MediaPlayerFull', 'MediaQueue'], description: "The big player: the player centered with its queue beside it (`MediaQueue`: tap a track to jump to it, the cross takes it out, Clear empties it), and its library below, where every track can be played, played next or added to the queue, and an open album or playlist can be played, shuffled or added whole. The media cards' drawers show the player and library too. Where the queue has no room beside the player (a narrow column, the side drawer), Library and Queue are tabs above the list; `tabs={false}` leaves only the library, `defaultTab` and `tab` / `onTabChange` choose which is open. A dashboard puts it in a page of its own, around it whatever it likes.", children: _jsx(Flex, { direction: "column", children: _jsx(MediaPlayerFull, { entity: "ha:media_player.living_room", wide: true, queue: _jsx(MediaQueue, { entity: "ha:media_player.living_room" }), browser: _jsx(MediaBrowser, { entity: "ha:media_player.living_room", layout: "theater" }) }) }) }), _jsx(ComponentDoc, { title: "Weather Forecast", components: ['WeatherForecast'], description: "A weather entity's forecast: the weather now, the next 24 hours as a chart (hover it for everything about an hour) and the days ahead. The top bar's weather chip shows it in a drawer; a dashboard can put it in a page of its own. Narrow, then with `expanded` (readings tiles, a card for each hour).", children: _jsxs(Flex, { direction: "column", gap: 6, children: [_jsx(Flex, { direction: "column", width: 380, children: _jsx(WeatherForecast, { entity: "ha:weather.home", expanded: false }) }), _jsx(WeatherForecast, { entity: "ha:weather.home", expanded: true })] }) }), _jsx(ComponentDoc, { title: "Media Speakers", components: ['MediaSpeakers'], description: "Who plays together with a player: its stream's speakers, each with its own volume and a Remove button (a follower can also Leave, or remove the player that leads it to carry on itself, and a leader with several others can Remove all), the speakers playing something else (Join their stream, or Add them to this one), the quiet ones it can add (Add). Playing something else on a speaker, or on this player, to do it asks first. Everything is asked of the player and shown when it answers. `speakers` is an allowlist of the ones that can be added: only those that are in it and that the player can be grouped with are offered, and a speaker that is but is off shows dimmed. The player components show it behind a small pill under the track info (it says \u201CSpeakers\u201D, \u201C3 speakers\u201D in a group, or \u201CPatio playing\u201D when there is a stream to join) that opens it in the side panel, unless `grouping={false}`. The first demo is a stream led by the porch with the bedroom in it; the second is a player on its own with an allowlist. They are live: try them.", children: _jsxs(Flex, { direction: "column", gap: 6, children: [_jsx(Flex, { direction: "column", width: 560, children: _jsx(MediaSpeakers, { entity: "ha:media_player.porch" }) }), _jsx(Flex, { direction: "column", width: 560, children: _jsx(MediaSpeakers, { entity: "ha:media_player.office", speakers: [
405
+ 'ha:media_player.porch',
406
+ 'ha:media_player.patio',
407
+ 'ha:media_player.garage',
408
+ ] }) })] }) }), _jsx(ComponentDoc, { title: "Media Browser", components: ['MediaBrowser'], description: "A player's own library in the app's style: tap a folder to open it, a track or station to play it, or the play button on an album, playlist or artist for all of it. The search box appears when the library can be searched; with `search='icon'` (the second demo) it is an icon at the end of the row of tabs, after a thin line, and pressing it turns that row into the field, so it takes no room of its own. The `theater` layout (third demo) is one row of large cards that scrolls sideways, for a wide space; `list` is the compact default. While a shelf loads, placeholders take the room the cards will (fourth demo, a shelf that never arrives), so nothing moves.", children: _jsxs(Flex, { direction: "column", gap: 6, children: [_jsx(Flex, { direction: "column", maxHeight: 420, overflow: "auto", children: _jsx(MediaBrowser, { entity: "ha:media_player.living_room" }) }), _jsx(Flex, { direction: "column", maxHeight: 420, overflow: "auto", children: _jsx(MediaBrowser, { entity: "ha:media_player.living_room", search: "icon" }) }), _jsx(MediaBrowser, { entity: "ha:media_player.living_room", layout: "theater" }), _jsx(MediaBrowser, { entity: "ha:media_player.loading", layout: "theater" })] }) }), _jsx(ComponentDoc, { title: "Navigation", components: ['NavRail', 'NavDock', 'Page'], description: "Routes within one dashboard. A dashboard includes one itself, usually in its layout route: NavRail is fixed to the left and NavDock floats at the bottom, and both reserve the space they take in the Page around them, which pads so content never sits under them. An item can carry a dot (`attention`: `notice` or `urgent`) when something on its page needs a look, so it is seen without opening the page.", children: _jsxs(Flex, { direction: "column", gap: 5, children: [_jsx(SubPreview, { label: "NavRail, in a Page", height: 240, children: _jsxs(Page, { height: "100%", children: [_jsx(NavRail, { base: "/gallery", items: NAV_ITEMS }), _jsx(Typography, { as: "p", variant: "body", color: "textMuted", children: "Page content, padded clear of the rail." })] }) }), _jsx(SubPreview, { label: "NavDock", height: 140, children: _jsx(NavDock, { base: "/gallery", items: NAV_ITEMS }) }), _jsx(SubPreview, { label: "NavRail, with something to look at on two pages", height: 240, children: _jsxs(Page, { height: "100%", children: [_jsx(NavRail, { base: "/gallery", items: ATTENTION_ITEMS }), _jsx(Typography, { as: "p", variant: "body", color: "textMuted", children: "A warm dot says something is coming up, a red one that it needs doing now." })] }) }), _jsx(SubPreview, { label: "NavDock, with something to look at on two pages", height: 140, children: _jsx(NavDock, { base: "/gallery", items: ATTENTION_ITEMS }) })] }) }), _jsx(ComponentDoc, { title: "Animated Outlet", components: ['AnimatedOutlet'], description: "A dashboard layout's `Outlet` with a transition between its pages: the page that was showing fades out, then the new one fades in, sliding from the side it sits on in the nav (a later page from the right, an earlier one from the left) when you give it the nav's `items`. Without them the pages only fade. It does nothing for a visitor who prefers reduced motion. Try the links.", children: _jsx(AnimatedOutletDemo, {}) }), _jsx(ComponentDoc, { title: "Top Bar", components: ['TopBar'], description: "Page chrome above a dashboard's content: a title (a dropdown to switch dashboards, once there's more than one to switch to), scene shortcuts, weather (tap it for the forecast), presence, and a clock.", children: _jsx(TopBar, { title: "Living Room", dashboards: [
404
409
  { id: 'living-room', title: 'Living Room', icon: 'lu:sofa' },
405
410
  { id: 'bedroom', title: 'Bedroom', icon: 'lu:bed' },
406
411
  { id: 'bathroom', title: 'Bathroom', icon: 'tb:bath' },
@@ -667,6 +667,18 @@ export const COMPONENT_PROPS = {
667
667
  optional: true,
668
668
  type: 'boolean',
669
669
  },
670
+ {
671
+ doc: 'Whether the player can add speakers to what it plays, put it back to just itself and join a stream playing elsewhere (`MediaSpeakers`), from a speakers button with `overlays` and a Speakers tab in the drawer. Only for a player that can be grouped. `false` leaves it out. Default `true`.',
672
+ name: 'grouping',
673
+ optional: true,
674
+ type: 'boolean',
675
+ },
676
+ {
677
+ doc: 'An allowlist of the speakers that can be added: only those that are in it and that the player can be grouped with are offered. Absent, every speaker it can be grouped with is offered.',
678
+ name: 'speakers',
679
+ optional: true,
680
+ type: 'EntityRef[]',
681
+ },
670
682
  {
671
683
  doc: "`1` puts the controls beside the track, in one pill; `2` puts them on a second row under it, for a narrow space (a small wall display). Default `'auto'`: one row, and two once the bar is narrower than 560 px wide.",
672
684
  name: 'rows',
@@ -702,6 +714,18 @@ export const COMPONENT_PROPS = {
702
714
  optional: true,
703
715
  type: 'boolean',
704
716
  },
717
+ {
718
+ doc: "Whether the player can add speakers to what it plays, put it back to just itself and join a stream playing elsewhere (`MediaSpeakers`): a Speakers tab in the drawer's player, and with `overlays` a speakers button beside the title that opens it. Only for a player that can be grouped. `false` leaves it out. Default `true`.",
719
+ name: 'grouping',
720
+ optional: true,
721
+ type: 'boolean',
722
+ },
723
+ {
724
+ doc: 'An allowlist of the speakers that can be added: only those that are in it and that the player can be grouped with are offered. Absent, every speaker it can be grouped with is offered.',
725
+ name: 'speakers',
726
+ optional: true,
727
+ type: 'EntityRef[]',
728
+ },
705
729
  ],
706
730
  },
707
731
  MediaPlayerFull: {
@@ -725,6 +749,24 @@ export const COMPONENT_PROPS = {
725
749
  optional: true,
726
750
  type: 'ReactNode',
727
751
  },
752
+ {
753
+ doc: 'Whether the player can add speakers to what it plays, put it back to just itself and join a stream playing elsewhere (`MediaSpeakers`): a pill under the track info says how its speakers stand and opens them. Only for a player given as a ref that can be grouped. `false` leaves it out. Default `true`.',
754
+ name: 'grouping',
755
+ optional: true,
756
+ type: 'boolean',
757
+ },
758
+ {
759
+ doc: 'An allowlist of the speakers that can be added: only those that are in it and that the player can be grouped with are offered. Absent, every speaker it can be grouped with is offered.',
760
+ name: 'speakers',
761
+ optional: true,
762
+ type: 'EntityRef[]',
763
+ },
764
+ {
765
+ doc: 'How the pill opens the speakers: `overlay` over the player, in the side panel the media overlays use, or `inline`, in the place of the list below the player, for a player that is already in a panel. Default `overlay`.',
766
+ name: 'speakersView',
767
+ optional: true,
768
+ type: "'overlay' | 'inline'",
769
+ },
728
770
  {
729
771
  doc: 'Calls the player this instead of the name it reports.',
730
772
  name: 'name',
@@ -803,6 +845,29 @@ export const COMPONENT_PROPS = {
803
845
  },
804
846
  ],
805
847
  },
848
+ MediaSpeakers: {
849
+ doc: "The speakers of a player's stream: who plays together with it, a volume for each, a way to take one out, to add another, to join a stream that is playing in another room, and to put the stream back to just this player. Everything is asked of the backend and shown when it answers. Playing something else on a speaker, or on this player, to do it asks first.",
850
+ props: [
851
+ {
852
+ doc: 'The player whose stream this is, as a ref like `ma:porch` or a handle (a custom source).',
853
+ name: 'entity',
854
+ optional: false,
855
+ type: "EntityRef | EntityHandle<'mediaPlayer'>",
856
+ },
857
+ {
858
+ doc: "What to call the player. Defaults to the player's own name.",
859
+ name: 'name',
860
+ optional: true,
861
+ type: 'string',
862
+ },
863
+ {
864
+ doc: 'An allowlist of the speakers that can be added to this stream (or joined). Only those that are in it and that the player can be grouped with are offered; a speaker the player cannot be grouped with is left out, and one that is but is unavailable shows dimmed. Absent, every speaker it can be grouped with is offered. Speakers already in the stream are always shown, so they can be taken out.',
865
+ name: 'speakers',
866
+ optional: true,
867
+ type: 'EntityRef[]',
868
+ },
869
+ ],
870
+ },
806
871
  NavRail: {
807
872
  doc: "Fixed left sidebar for switching between a dashboard's pages. Must be rendered inside a router. It is `position: fixed` and reserves its width in the surrounding `Page`, which pads for it so content never sits under it.",
808
873
  props: [
package/dist/hooks.d.ts CHANGED
@@ -2,6 +2,9 @@ import type { ConnectionStatus, Entity, EntityKind, EntityRef, LinkStatus } from
2
2
  import { type CommandSender, type EntityHandle } from './entity-handle.ts';
3
3
  /** Live generic entity. `undefined` while loading (or with no ref), `null` if it does not exist. */
4
4
  export declare function useEntity(ref: EntityRef | undefined): Entity | null | undefined;
5
+ /** Several live entities at once, in the order of `refs`: for a component that has to know about a list of
6
+ * them without a row of its own for each. Each is `undefined` while loading and `null` if it does not exist. */
7
+ export declare function useEntities(refs: readonly EntityRef[]): (Entity | null | undefined)[];
5
8
  /** Returns a function that runs a named command on `ref`: `command('setVolume', { volume: 0.4 })`. */
6
9
  export declare function useCommand(ref: EntityRef | undefined): CommandSender;
7
10
  /**
package/dist/hooks.js CHANGED
@@ -11,6 +11,32 @@ export function useEntity(ref) {
11
11
  const getSnapshot = useCallback(() => (ref ? client.getEntity(ref) : undefined), [client, ref]);
12
12
  return useSyncExternalStore(subscribe, getSnapshot, () => undefined);
13
13
  }
14
+ /** Several live entities at once, in the order of `refs`: for a component that has to know about a list of
15
+ * them without a row of its own for each. Each is `undefined` while loading and `null` if it does not exist. */
16
+ export function useEntities(refs) {
17
+ const client = useClient();
18
+ const key = refs.join('\n');
19
+ const kept = useRef({ key: '', list: [] });
20
+ const subscribe = useCallback((onChange) => {
21
+ const offs = key === ''
22
+ ? []
23
+ : key.split('\n').map((ref) => client.subscribe(ref, onChange));
24
+ return () => offs.forEach((off) => off());
25
+ }, [client, key]);
26
+ // The same array while nothing in it changed, so React does not see a new snapshot on every read.
27
+ const getSnapshot = useCallback(() => {
28
+ const list = key === '' ? [] : key.split('\n').map((ref) => client.getEntity(ref));
29
+ const last = kept.current;
30
+ if (last.key === key &&
31
+ last.list.length === list.length &&
32
+ list.every((entity, i) => entity === last.list[i])) {
33
+ return last.list;
34
+ }
35
+ kept.current = { key, list };
36
+ return list;
37
+ }, [client, key]);
38
+ return useSyncExternalStore(subscribe, getSnapshot, () => kept.current.list);
39
+ }
14
40
  /** Returns a function that runs a named command on `ref`: `command('setVolume', { volume: 0.4 })`. */
15
41
  export function useCommand(ref) {
16
42
  const client = useClient();
package/dist/index.d.ts CHANGED
@@ -33,6 +33,7 @@ export * from './entities/media-player-full.tsx';
33
33
  export * from './entities/weather-forecast.tsx';
34
34
  export * from './entities/media-browser.tsx';
35
35
  export * from './entities/media-queue.tsx';
36
+ export { MediaSpeakers, type MediaSpeakersProps } from './entities/media-speakers.tsx';
36
37
  export { formatDuration } from './entities/media-progress.ts';
37
38
  export * from './entities/nav-rail.tsx';
38
39
  export * from './entities/nav-dock.tsx';
package/dist/index.js CHANGED
@@ -27,6 +27,7 @@ export * from './entities/media-player-full.js';
27
27
  export * from './entities/weather-forecast.js';
28
28
  export * from './entities/media-browser.js';
29
29
  export * from './entities/media-queue.js';
30
+ export { MediaSpeakers } from './entities/media-speakers.js';
30
31
  export { formatDuration } from './entities/media-progress.js';
31
32
  export * from './entities/nav-rail.js';
32
33
  export * from './entities/nav-dock.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hashsome/ui",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Design system components and hooks",
5
5
  "type": "module",
6
6
  "exports": {
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@emotion/react": "11.14.0",
19
- "@hashsome/core": "0.11.0",
19
+ "@hashsome/core": "0.12.0",
20
20
  "e-prim": "2.0.1",
21
21
  "motion": "13.4.5",
22
22
  "react": "19.3.0",