@hashsome/ui 0.1.0 → 0.2.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.
Files changed (108) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/entities/artwork-ring.d.ts +1 -1
  3. package/dist/entities/artwork-ring.js +1 -1
  4. package/dist/entities/climate-tile.d.ts +1 -1
  5. package/dist/entities/climate-tile.js +1 -1
  6. package/dist/entities/light-tile.d.ts +1 -1
  7. package/dist/entities/light-tile.js +1 -1
  8. package/dist/entities/media-browser.js +15 -3
  9. package/dist/entities/media-player-bar.js +2 -2
  10. package/dist/entities/media-player-column.js +2 -2
  11. package/dist/entities/media-player-full.d.ts +20 -0
  12. package/dist/entities/{media-player-body.js → media-player-full.js} +6 -5
  13. package/dist/entities/now-playing.d.ts +1 -1
  14. package/dist/entities/now-playing.js +1 -1
  15. package/dist/entities/top-bar.d.ts +1 -1
  16. package/dist/entities/top-bar.js +26 -23
  17. package/dist/entities/weather-forecast.d.ts +13 -0
  18. package/dist/entities/weather-forecast.js +161 -0
  19. package/dist/entities/weather-icon.d.ts +7 -0
  20. package/dist/entities/weather-icon.js +11 -0
  21. package/dist/entities/weather-look.d.ts +17 -0
  22. package/dist/entities/weather-look.js +75 -0
  23. package/dist/gallery/fixtures.js +7 -1
  24. package/dist/gallery/gallery.js +6 -5
  25. package/dist/gallery/props-data.js +49 -8
  26. package/dist/icon.d.ts +3 -1
  27. package/dist/icon.js +6 -2
  28. package/dist/index.d.ts +2 -1
  29. package/dist/index.js +2 -1
  30. package/dist/layout/history-section.d.ts +3 -13
  31. package/dist/layout/history-section.js +15 -5
  32. package/dist/layout/logbook-history.d.ts +6 -0
  33. package/dist/layout/logbook-history.js +9 -0
  34. package/dist/layout/series-chart.d.ts +5 -1
  35. package/dist/layout/series-chart.js +3 -3
  36. package/dist/layout/tile.d.ts +9 -1
  37. package/dist/layout/tile.js +8 -4
  38. package/dist/use-entity-logbook.d.ts +13 -0
  39. package/dist/use-entity-logbook.js +42 -0
  40. package/dist/use-weather-forecast.d.ts +12 -0
  41. package/dist/use-weather-forecast.js +41 -0
  42. package/package.json +2 -2
  43. package/dist/entities/action-button.js.map +0 -1
  44. package/dist/entities/artwork-ring.js.map +0 -1
  45. package/dist/entities/climate-tile.js.map +0 -1
  46. package/dist/entities/light-color.js.map +0 -1
  47. package/dist/entities/light-tile.js.map +0 -1
  48. package/dist/entities/media-browser.js.map +0 -1
  49. package/dist/entities/media-controls.js.map +0 -1
  50. package/dist/entities/media-player-bar.js.map +0 -1
  51. package/dist/entities/media-player-body.d.ts +0 -16
  52. package/dist/entities/media-player-body.js.map +0 -1
  53. package/dist/entities/media-player-column.js.map +0 -1
  54. package/dist/entities/media-player-page.d.ts +0 -12
  55. package/dist/entities/media-player-page.js +0 -13
  56. package/dist/entities/media-player-page.js.map +0 -1
  57. package/dist/entities/media-progress.js.map +0 -1
  58. package/dist/entities/media-seek.js.map +0 -1
  59. package/dist/entities/nav-dock.js.map +0 -1
  60. package/dist/entities/nav-rail.js.map +0 -1
  61. package/dist/entities/now-playing.js.map +0 -1
  62. package/dist/entities/scene-button.js.map +0 -1
  63. package/dist/entities/seek-line.js.map +0 -1
  64. package/dist/entities/sensor-readout.js.map +0 -1
  65. package/dist/entities/top-bar.js.map +0 -1
  66. package/dist/entity-handle.js.map +0 -1
  67. package/dist/gallery/fixtures.js.map +0 -1
  68. package/dist/gallery/gallery.js.map +0 -1
  69. package/dist/gallery/index.js.map +0 -1
  70. package/dist/gallery/props-data.js.map +0 -1
  71. package/dist/hooks.js.map +0 -1
  72. package/dist/icon-data.js.map +0 -1
  73. package/dist/icon.js.map +0 -1
  74. package/dist/index.js.map +0 -1
  75. package/dist/layout/cover.js.map +0 -1
  76. package/dist/layout/date-time-field.js.map +0 -1
  77. package/dist/layout/detail-provider.js.map +0 -1
  78. package/dist/layout/drawer-controls.js.map +0 -1
  79. package/dist/layout/energy-chart.js.map +0 -1
  80. package/dist/layout/energy-usage.js.map +0 -1
  81. package/dist/layout/entity-drawer.js.map +0 -1
  82. package/dist/layout/fade-scroll.js.map +0 -1
  83. package/dist/layout/grid.js.map +0 -1
  84. package/dist/layout/history-section.js.map +0 -1
  85. package/dist/layout/hold-progress.js.map +0 -1
  86. package/dist/layout/icon-button-styles.js.map +0 -1
  87. package/dist/layout/page.js.map +0 -1
  88. package/dist/layout/plain-button.js.map +0 -1
  89. package/dist/layout/room-header.js.map +0 -1
  90. package/dist/layout/round-button.js.map +0 -1
  91. package/dist/layout/sensor-history.js.map +0 -1
  92. package/dist/layout/sensor-stats.js.map +0 -1
  93. package/dist/layout/series-chart.js.map +0 -1
  94. package/dist/layout/stepper.js.map +0 -1
  95. package/dist/layout/swatch-row.js.map +0 -1
  96. package/dist/layout/tile.js.map +0 -1
  97. package/dist/layout/use-drawer.js.map +0 -1
  98. package/dist/layout/use-hold.js.map +0 -1
  99. package/dist/provider.js.map +0 -1
  100. package/dist/status.js.map +0 -1
  101. package/dist/theme/density.js.map +0 -1
  102. package/dist/theme/global-styles.js.map +0 -1
  103. package/dist/theme/index.js.map +0 -1
  104. package/dist/theme/overrides.js.map +0 -1
  105. package/dist/theme/tokens.js.map +0 -1
  106. package/dist/theme/use-typography.js.map +0 -1
  107. package/dist/use-entity-history.js.map +0 -1
  108. package/dist/use-media-browser.js.map +0 -1
@@ -2,7 +2,7 @@ import type { SeekHold } from './media-seek.ts';
2
2
  /** The player's artwork in a circle, like the one in the bar but larger: an inner outline around
3
3
  * it, and an outer ring that shows how far along playback is. With `onSeek` the ring is a slider:
4
4
  * drag around it, or use the arrow keys. Not exported from the package: `MediaPlayerColumn` and
5
- * `MediaPlayerPage` draw it. */
5
+ * `MediaPlayerFull` draw it. */
6
6
  export declare function ArtworkRing({ artworkUrl, seek, duration, seekable, onOpen, size: SIZE, }: {
7
7
  artworkUrl?: string | undefined;
8
8
  /** The position to draw and the way to change it, shared with whatever shows the time. */
@@ -9,7 +9,7 @@ const DEFAULT_SIZE = 168;
9
9
  /** The player's artwork in a circle, like the one in the bar but larger: an inner outline around
10
10
  * it, and an outer ring that shows how far along playback is. With `onSeek` the ring is a slider:
11
11
  * drag around it, or use the arrow keys. Not exported from the package: `MediaPlayerColumn` and
12
- * `MediaPlayerPage` draw it. */
12
+ * `MediaPlayerFull` draw it. */
13
13
  export function ArtworkRing({ artworkUrl, seek, duration, seekable, onOpen, size: SIZE = DEFAULT_SIZE, }) {
14
14
  const ring = useRef(null);
15
15
  const dragging = useRef(false);
@@ -12,7 +12,7 @@ export interface ClimateTileProps {
12
12
  icon?: IconName;
13
13
  /** Power-draw chart for the drawer, above `history`. No default source yet — pass explicitly. */
14
14
  energy?: EnergyChartProps;
15
- /** Recent activity, shown in the drawer. No default source yet — pass explicitly. */
15
+ /** Recent activity, shown in the drawer: who or what changed it, and when. Left out, it comes from the backend's own record when it keeps one (Home Assistant's logbook), fetched when the drawer opens. */
16
16
  history?: LogbookEntry[];
17
17
  }
18
18
  /** Heater / thermostat: a mode button that opens mode swatches in the card, a target temperature
@@ -44,7 +44,7 @@ export function ClimateTile({ entity, name, icon, energy, history }) {
44
44
  temperature: Math.min(max, Math.max(min, Math.round(value * 100) / 100)),
45
45
  });
46
46
  const setMode = (next) => void handle.command('setMode', { mode: next });
47
- return (_jsx(Tile, { label: name ?? climate?.name ?? fallbackName(entity), icon: icon ?? 'lu:thermometer', kind: "Climate", status: status, secondary: current !== undefined ? `${current} ${unit}` : undefined, detail: _jsx(ClimateDetailBody, { climate: climate, mode: mode, modes: modes, target: target, current: current, step: step, min: min, max: max, unit: unit, onMode: setMode, onTarget: setTarget, onPreset: (preset) => void handle.command('setPreset', { preset }) }), ...(energy ? { energy } : {}), ...(history ? { history } : {}), ...(ready && picking
47
+ return (_jsx(Tile, { label: name ?? climate?.name ?? fallbackName(entity), icon: icon ?? 'lu:thermometer', kind: "Climate", status: status, secondary: current !== undefined ? `${current} ${unit}` : undefined, detail: _jsx(ClimateDetailBody, { climate: climate, mode: mode, modes: modes, target: target, current: current, step: step, min: min, max: max, unit: unit, onMode: setMode, onTarget: setTarget, onPreset: (preset) => void handle.command('setPreset', { preset }) }), ...(energy ? { energy } : {}), ...(history ? { history } : {}), ...(typeof entity === 'string' ? { logbook: entity } : {}), ...(ready && picking
48
48
  ? {
49
49
  // A render prop `Tile` calls, not a component of its own.
50
50
  // oxlint-disable-next-line react/no-unstable-nested-components
@@ -15,7 +15,7 @@ export interface LightTileProps {
15
15
  /** Power-draw chart (e.g. a Shelly's energy monitoring), shown in the drawer above `history`.
16
16
  * No default source yet — pass explicitly. */
17
17
  energy?: EnergyChartProps;
18
- /** Recent activity, shown in the drawer. No default source yet — pass explicitly. */
18
+ /** Recent activity, shown in the drawer: who or what changed it, and when. Left out, it comes from the backend's own record when it keeps one (Home Assistant's logbook), fetched when the drawer opens. */
19
19
  history?: LogbookEntry[];
20
20
  /** Swatches shown when the palette button is opened. Default: four white temperatures. Each takes `kelvin` (color-temp lights) and/or `hs` (color lights) and is skipped if the light can't apply it. */
21
21
  colors?: LightColorPreset[];
@@ -49,7 +49,7 @@ export function LightTile({ entity, name, icon, energy, history, colors = DEFAUL
49
49
  const [hue, saturation] = rgbToHs(picked);
50
50
  void handle.command('setColor', { hue, saturation });
51
51
  }
52
- } })) : null }), ...(energy ? { energy } : {}), ...(history ? { history } : {}), ...(dimmable
52
+ } })) : null }), ...(energy ? { energy } : {}), ...(history ? { history } : {}), ...(typeof entity === 'string' ? { logbook: entity } : {}), ...(dimmable
53
53
  ? {
54
54
  fill: on ? (brightness ?? 1) : 0,
55
55
  onFillChange: (fill) => void handle.command('setBrightness', { brightness: fill }),
@@ -48,7 +48,13 @@ export function MediaBrowser({ entity, onPlay, layout = 'list' }) {
48
48
  icon: 'lu:arrow-left',
49
49
  ariaLabel: 'Back',
50
50
  }
51
- : { value: item.id, label: item.title }), value: browser.activeTab, onChange: (id) => id === browser.activeTab && inside ? browser.back() : browser.selectTab(id) })) : null, browser.error ? (_jsx(Typography, { as: "p", variant: "body", color: "danger", role: "alert", children: browser.error })) : browser.loading ? (_jsx(Typography, { as: "p", variant: "body", color: "textMuted", children: "Loading\u2026" })) : browser.items.length === 0 ? (_jsx(Typography, { as: "p", variant: "body", color: "textMuted", children: browser.searching ? 'Nothing found.' : 'Nothing here.' })) : theater ? (_jsx(FadeScroll, { as: "ul", gap: 4, m: 0, p: 0, pb: 2, css: { listStyle: 'none', scrollSnapType: 'x proximity' }, children: browser.items.map((item) => (_jsx("li", { css: { flex: 'none', scrollSnapAlign: 'start' }, children: _jsx(BrowseCard, { item: item, onOpen: () => browser.open(item), onPlay: () => play(item) }) }, item.id))) })) : (_jsx(Flex, { direction: "column", gap: 1, as: "ul", m: 0, p: 0, minHeight: 0, css: { listStyle: 'none', overflowY: 'auto' }, children: browser.items.map((item) => (_jsx("li", { css: { flex: 'none' }, children: _jsx(BrowseRow, { item: item, onOpen: () => browser.open(item), onPlay: () => play(item) }) }, item.id))) }))] }));
51
+ : { value: item.id, label: item.title }), value: browser.activeTab, onChange: (id) => id === browser.activeTab && inside ? browser.back() : browser.selectTab(id) })) : null, browser.error ? (_jsx(Typography, { as: "p", variant: "body", color: "danger", role: "alert", children: browser.error })) : browser.loading ? (_jsx(Typography, { as: "p", variant: "body", color: "textMuted", children: "Loading\u2026" })) : browser.items.length === 0 ? (_jsx(Typography, { as: "p", variant: "body", color: "textMuted", children: browser.searching ? 'Nothing found.' : 'Nothing here.' })) : theater ? (_jsx(FadeScroll, { as: "ul", gap: 4, m: 0, p: 0, pb: 2, css: {
52
+ listStyle: 'none',
53
+ scrollSnapType: 'x proximity',
54
+ containerType: 'size',
55
+ flex: `0 1 ${CARD + LABEL + 16}px`,
56
+ minHeight: MIN_CARD + LABEL + 16,
57
+ }, children: browser.items.map((item) => (_jsx("li", { css: { flex: 'none', scrollSnapAlign: 'start' }, children: _jsx(BrowseCard, { item: item, onOpen: () => browser.open(item), onPlay: () => play(item) }) }, item.id))) })) : (_jsx(Flex, { direction: "column", gap: 1, as: "ul", m: 0, p: 0, minHeight: 0, css: { listStyle: 'none', overflowY: 'auto' }, children: browser.items.map((item) => (_jsx("li", { css: { flex: 'none' }, children: _jsx(BrowseRow, { item: item, onOpen: () => browser.open(item), onPlay: () => play(item) }) }, item.id))) }))] }));
52
58
  }
53
59
  function BrowseRow({ item, onOpen, onPlay, }) {
54
60
  // A row opens what can be opened and otherwise plays; an openable thing that can also be played
@@ -56,12 +62,18 @@ function BrowseRow({ item, onOpen, onPlay, }) {
56
62
  const main = item.expandable ? onOpen : item.playable ? onPlay : undefined;
57
63
  return (_jsxs(Flex, { align: "center", gap: 2, children: [_jsxs(PlainButton, { align: "center", gap: 3, grow: 1, minWidth: 0, radius: "row", py: 1.5, px: 2, cursor: main ? 'pointer' : 'default', disabled: !main, "aria-label": item.expandable ? `Open ${item.title}` : `Play ${item.title}`, onClick: main, css: ({ palette }) => ({ '&:hover:not(:disabled)': { background: palette.surfaceRaised } }), children: [_jsx(Flex, { align: "center", justify: "center", background: "surfaceRaised", color: "textMuted", radius: "small", width: 44, height: 44, overflow: "hidden", css: { flex: 'none' }, children: item.artworkUrl ? (_jsx(Cover, { src: item.artworkUrl })) : (_jsx(Icon, { name: KIND_ICONS[item.kind], size: 20 })) }), _jsxs(Flex, { direction: "column", minWidth: 0, children: [_jsx(Typography, { as: "span", variant: "bodyStrong", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", children: item.title }), item.subtitle ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", children: item.subtitle })) : null] })] }), item.expandable && item.playable ? (_jsx(IconButton, { icon: "lu:play", label: `Play ${item.title}`, glyph: 16, onClick: onPlay })) : null] }));
58
64
  }
59
- /** The width and height of a theater card's artwork. */
65
+ /** The width and height of a theater card's artwork when there is room for it. */
60
66
  const CARD = 200;
67
+ /** The smallest a theater card's artwork gets: when there is less room than that, the page scrolls instead. */
68
+ const MIN_CARD = 120;
69
+ /** What a theater card needs under its artwork for the title and subtitle. */
70
+ const LABEL = 56;
71
+ /** The artwork's side: `CARD`, or less when the row it sits in is shorter than the card and its labels (`cqh` is a percent of that row's height). */
72
+ const CARD_SIZE = `min(${CARD}px, max(${MIN_CARD}px, calc(100cqh - ${LABEL}px)))`;
61
73
  /** An item as a large card for the theater layout: big artwork (round for an artist), then the
62
74
  * title and subtitle. It opens or plays like a row does, and an album or playlist gets its play
63
75
  * button over the artwork's corner. */
64
76
  function BrowseCard({ item, onOpen, onPlay, }) {
65
77
  const main = item.expandable ? onOpen : item.playable ? onPlay : undefined;
66
- return (_jsxs(Box, { position: "relative", width: CARD, children: [_jsxs(PlainButton, { direction: "column", gap: 2, width: "100%", cursor: main ? 'pointer' : 'default', disabled: !main, "aria-label": item.expandable ? `Open ${item.title}` : `Play ${item.title}`, onClick: main, children: [_jsx(Flex, { align: "center", justify: "center", background: "surfaceRaised", color: "textMuted", radius: item.kind === 'artist' ? 'full' : 'card', overflow: "hidden", width: CARD, height: CARD, children: item.artworkUrl ? (_jsx(Cover, { src: item.artworkUrl })) : (_jsx(Icon, { name: KIND_ICONS[item.kind], size: 56 })) }), _jsxs(Flex, { direction: "column", minWidth: 0, px: 1, children: [_jsx(Typography, { as: "span", variant: "bodyStrong", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", children: item.title }), item.subtitle ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", children: item.subtitle })) : null] })] }), item.expandable && item.playable ? (_jsx(Box, { position: "absolute", css: { right: 10, top: CARD - 54 }, children: _jsx(IconButton, { icon: "lu:play", label: `Play ${item.title}`, glyph: 16, onClick: onPlay }) })) : null] }));
78
+ return (_jsxs(Box, { position: "relative", css: { width: CARD_SIZE }, children: [_jsxs(PlainButton, { direction: "column", gap: 2, width: "100%", cursor: main ? 'pointer' : 'default', disabled: !main, "aria-label": item.expandable ? `Open ${item.title}` : `Play ${item.title}`, onClick: main, children: [_jsx(Flex, { align: "center", justify: "center", background: "surfaceRaised", color: "textMuted", radius: item.kind === 'artist' ? 'full' : 'card', overflow: "hidden", css: { width: CARD_SIZE, height: CARD_SIZE }, children: item.artworkUrl ? (_jsx(Cover, { src: item.artworkUrl })) : (_jsx(Icon, { name: KIND_ICONS[item.kind], size: 56 })) }), _jsxs(Flex, { direction: "column", minWidth: 0, px: 1, children: [_jsx(Typography, { as: "span", variant: "bodyStrong", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", children: item.title }), item.subtitle ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", children: item.subtitle })) : null] })] }), item.expandable && item.playable ? (_jsx(Box, { position: "absolute", css: { right: 10, top: `calc(${CARD_SIZE} - 54px)` }, children: _jsx(IconButton, { icon: "lu:play", label: `Play ${item.title}`, glyph: 16, onClick: onPlay }) })) : null] }));
67
79
  }
@@ -15,7 +15,7 @@ import { usePressFeedback, useVolumeControl } from './media-controls.js';
15
15
  import { Cover } from '../layout/cover.js';
16
16
  import { PlainButton } from '../layout/plain-button.js';
17
17
  import { MediaBrowser } from './media-browser.js';
18
- import { MediaPlayerBody } from './media-player-body.js';
18
+ import { MediaPlayerFull } from './media-player-full.js';
19
19
  import { formatDuration, useMediaPosition } from './media-progress.js';
20
20
  import { useSeekHold } from './media-seek.js';
21
21
  import { SeekLine } from './seek-line.js';
@@ -74,5 +74,5 @@ export function MediaPlayerBar({ entity, name, browse }) {
74
74
  : statusLabels[status] }), ready && caps?.shuffle ? (_jsx(PlainButton, { "aria-label": "Shuffle", "aria-pressed": player?.shuffle === true, title: "Shuffle", onClick: () => void handle.command('setShuffle', { shuffle: player?.shuffle !== true }), center: true, color: player?.shuffle === true ? 'accent' : 'textMuted', css: { flex: 'none' }, children: _jsx(Icon, { name: "lu:shuffle", size: 14 }) })) : null] }), ready && player?.media?.artist ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", noWrap: true, textOverflow: "ellipsis", children: player.media.artist })) : null] }), timed ? (_jsxs(Typography, { as: "span", variant: "secondary", color: "textMuted", noWrap: true, css: { flex: 'none', fontVariantNumeric: 'tabular-nums' }, children: [formatDuration(seek.shown ?? 0), " / ", formatDuration(duration ?? 0)] })) : null] })), _jsx(IconButton, { icon: volumeOpen ? 'lu:x' : muted ? 'lu:volume-x' : 'lu:volume-2', label: volumeOpen ? 'Close volume' : 'Volume', glyph: MEDIA_GLYPH, disabled: !ready || (caps?.volume === false && caps.mute === false), onClick: () => setVolumeOpen((current) => !current) }), open && browser !== undefined ? (_jsx(IconButton, { icon: "lu:library", label: "Browse media", glyph: MEDIA_GLYPH, onClick: openExpanded ?? open })) : null, _jsx(IconButton, { icon: "lu:skip-back", label: "Previous", glyph: MEDIA_GLYPH, disabled: !ready || caps?.previous === false, feedback: feedbackFor('previous'), onClick: () => press('previous', () => handle.command('previous')) }), _jsx(IconButton, { icon: playing ? 'lu:pause' : 'lu:play', label: playing ? 'Pause' : 'Play', glyph: MEDIA_GLYPH, disabled: !ready, primary: true, onClick: () => void handle.command('togglePlay') }), _jsx(IconButton, { icon: "lu:skip-forward", label: "Next", glyph: MEDIA_GLYPH, disabled: !ready || caps?.next === false, feedback: feedbackFor('next'), onClick: () => press('next', () => handle.command('next')) }), timed ? (_jsx(SeekLine, { seek: seek, duration: duration ?? 0, seekable: caps?.seek === true })) : null] }));
75
75
  // Holding the card, the artwork and the browse button open the same drawer: the player laid out
76
76
  // like the vertical one, with the library below it.
77
- return ready ? (_jsx(DrawerTrigger, { icon: "lu:music", label: name ?? player?.name ?? fallbackName(entity), kind: "Media", body: _jsx(MediaPlayerBody, { entity: entity, ...(name !== undefined ? { name } : {}), ...(browser !== undefined ? { browser } : {}) }), children: (open, openExpanded) => bar(open, openExpanded) })) : (bar());
77
+ return ready ? (_jsx(DrawerTrigger, { icon: "lu:music", label: name ?? player?.name ?? fallbackName(entity), kind: "Media", body: _jsx(MediaPlayerFull, { entity: entity, ...(name !== undefined ? { name } : {}), ...(browser !== undefined ? { browser } : {}) }), children: (open, openExpanded) => bar(open, openExpanded) })) : (bar());
78
78
  }
@@ -7,7 +7,7 @@ import { IconButton } from '../layout/tile.js';
7
7
  import { DrawerTrigger } from '../layout/use-drawer.js';
8
8
  import { useHold } from '../layout/use-hold.js';
9
9
  import { MediaBrowser } from './media-browser.js';
10
- import { MediaPlayerBody } from './media-player-body.js';
10
+ import { MediaPlayerFull } from './media-player-full.js';
11
11
  import { NowPlaying } from './now-playing.js';
12
12
  /** The player as an upright card for a narrow column beside a dashboard (a quarter to a third of
13
13
  * the width): the artwork in a ring that shows (and seeks) the position, title, transport and a
@@ -23,7 +23,7 @@ export function MediaPlayerColumn({ entity, name, browse }) {
23
23
  : (browse ??
24
24
  (ref && player?.capabilities.browse ? (_jsx(MediaBrowser, { entity: ref, layout: "auto" })) : undefined));
25
25
  const fallback = fallbackName(entity);
26
- return browser !== undefined && handle.status === 'ready' ? (_jsx(DrawerTrigger, { icon: "lu:music", label: name ?? player?.name ?? fallback, kind: "Media", body: _jsx(MediaPlayerBody, { entity: entity, browser: browser, ...(name !== undefined ? { name } : {}) }), children: (open, openExpanded) => (_jsx(Card, { open: open, children: _jsx(NowPlaying, { handle: handle, fallback: fallback, ...(name !== undefined ? { name } : {}), onOpenArtwork: open, extra: _jsx(IconButton, { icon: "lu:library", label: "Browse media", glyph: 18, onClick: openExpanded }) }) })) })) : (_jsx(Card, { children: _jsx(NowPlaying, { handle: handle, fallback: fallback, ...(name !== undefined ? { name } : {}) }) }));
26
+ return browser !== undefined && handle.status === 'ready' ? (_jsx(DrawerTrigger, { icon: "lu:music", label: name ?? player?.name ?? fallback, kind: "Media", body: _jsx(MediaPlayerFull, { entity: entity, browser: browser, ...(name !== undefined ? { name } : {}) }), children: (open, openExpanded) => (_jsx(Card, { open: open, children: _jsx(NowPlaying, { handle: handle, fallback: fallback, ...(name !== undefined ? { name } : {}), onOpenArtwork: open, extra: _jsx(IconButton, { icon: "lu:library", label: "Browse media", glyph: 18, onClick: openExpanded }) }) })) })) : (_jsx(Card, { children: _jsx(NowPlaying, { handle: handle, fallback: fallback, ...(name !== undefined ? { name } : {}) }) }));
27
27
  }
28
28
  /** The card itself; with `open`, holding it (anywhere but on a control) calls it. */
29
29
  function Card({ open, children }) {
@@ -0,0 +1,20 @@
1
+ /** @jsxImportSource @emotion/react */
2
+ import type { EntityRef } from '@hashsome/core';
3
+ import type { ReactNode } from 'react';
4
+ import { type EntityHandle } from '../entity-handle.ts';
5
+ export interface MediaPlayerFullProps {
6
+ /** The media player, as a ref like `ma:living_room`. */
7
+ entity: EntityRef | EntityHandle<'mediaPlayer'>;
8
+ /** What goes below the player, usually the player's library: `<MediaBrowser entity="ma:living_room" layout="theater" />`. Left out, only the player is drawn. */
9
+ browser?: ReactNode;
10
+ /** Calls the player this instead of the name it reports. */
11
+ name?: string;
12
+ /** Gives the library the whole width, for one laid out to use it (the theater layout). Defaults to whether the drawer is expanded. */
13
+ wide?: boolean;
14
+ }
15
+ /** The big player: the player centered at the top, and the library (or whatever `browser` is) below
16
+ * it. It is what the media cards' drawers show, at every width, and it is the widget a dashboard puts
17
+ * in a page of its own, around it whatever it likes (a surface that fills the space and scrolls, a
18
+ * heading, other cards beside it). It fills the height it is given, so the page that holds it
19
+ * decides how tall that is. */
20
+ export declare function MediaPlayerFull({ entity, browser, wide, name }: MediaPlayerFullProps): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -6,11 +6,12 @@ import { useDetail } from '../layout/detail-provider.js';
6
6
  import { NowPlaying } from './now-playing.js';
7
7
  /** The artwork ring's diameter in a full-size player, against the usual 168px. */
8
8
  const FULL_SIZE = 260;
9
- /** The big player: the player centered at the top, and the library (or whatever `browser` is)
10
- * below it. This one layout is the drawer, at every width, and the full page, so the two are the
11
- * same thing and cannot drift apart. Not exported from the package; `MediaPlayerPage` and the
12
- * media cards' drawers draw it. */
13
- export function MediaPlayerBody({ entity, browser, wide, name, }) {
9
+ /** The big player: the player centered at the top, and the library (or whatever `browser` is) below
10
+ * it. It is what the media cards' drawers show, at every width, and it is the widget a dashboard puts
11
+ * in a page of its own, around it whatever it likes (a surface that fills the space and scrolls, a
12
+ * heading, other cards beside it). It fills the height it is given, so the page that holds it
13
+ * decides how tall that is. */
14
+ export function MediaPlayerFull({ entity, browser, wide, name }) {
14
15
  const handle = useEntityHandle('mediaPlayer', entity);
15
16
  const { detail } = useDetail();
16
17
  const full = wide ?? detail?.expanded === true;
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react';
2
2
  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
- * the package; `MediaPlayerColumn` and `MediaPlayerPage` are its public forms. */
5
+ * the package; `MediaPlayerColumn` and `MediaPlayerFull` are its public forms. */
6
6
  export declare function NowPlaying({ handle, fallback, name, extra, onOpenArtwork, size, shuffle: shufflePlacement, }: {
7
7
  handle: EntityHandle<'mediaPlayer'>;
8
8
  /** What to call the player before it reports a name. */
@@ -12,7 +12,7 @@ import { useSeekHold } from './media-seek.js';
12
12
  import { ArtworkRing } from './artwork-ring.js';
13
13
  /** The upright "now playing" panel the media column and page share: the artwork on a record, what is playing, how
14
14
  * far along it is, the transport buttons and a volume bar that is always there. Not exported from
15
- * the package; `MediaPlayerColumn` and `MediaPlayerPage` are its public forms. */
15
+ * the package; `MediaPlayerColumn` and `MediaPlayerFull` are its public forms. */
16
16
  export function NowPlaying({ handle, fallback, name, extra, onOpenArtwork, size, shuffle: shufflePlacement = 'transport', }) {
17
17
  const player = handle.entity;
18
18
  const { status } = handle;
@@ -22,7 +22,7 @@ export interface TopBarProps {
22
22
  dashboards?: DashboardOption[];
23
23
  /** Compact circular buttons that fire `scene.turn_on` directly (not the `Tile`-based `SceneButton`); a bare entity ref uses the default icon and an auto-assigned color, or pass `{ entity, icon, color }` to pick either. */
24
24
  scenes?: (EntityRef | TopBarScene)[];
25
- /** Current weather as a pill with an icon for the sky and a rounded whole-degree reading. A weather entity, like `ha:weather.forecast_home`, shows its real condition; a plain sensor, e.g. an outdoor temperature, gets a fixed sun. */
25
+ /** Current weather as a pill with an icon for the sky and a rounded whole-degree reading. A weather entity, like `ha:weather.forecast_home`, shows its real condition, today's high and low once its forecast has loaded, and opens the forecast (the next 24 hours and the days ahead) when tapped, if its source has one; a plain sensor, e.g. an outdoor temperature, gets a fixed sun and no forecast. */
26
26
  weather?: EntityRef;
27
27
  /** Your own component(s) for the right-hand side, after the weather: a security mode picker, a custom status. Whatever it is, it sits in the bar's row and is yours to style. */
28
28
  extra?: ReactNode;
@@ -7,7 +7,12 @@ import { fallbackName } from '../entity-handle.js';
7
7
  import { useConnectionStatus, useEntity, useEntityHandle, useIntegrationStatuses, } from '../hooks.js';
8
8
  import { Icon } from '../icon.js';
9
9
  import { statusLabels } from '../status.js';
10
+ import { DrawerTrigger } from '../layout/use-drawer.js';
11
+ import { useWeatherForecast } from '../use-weather-forecast.js';
10
12
  import { SensorReadout } from './sensor-readout.js';
13
+ import { WeatherForecast } from './weather-forecast.js';
14
+ import { WeatherIcon } from './weather-icon.js';
15
+ import { CONDITION_LOOK } from './weather-look.js';
11
16
  /** The colors presence avatars without a picture fall back to when `presenceColors` isn't given. */
12
17
  const DEFAULT_PRESENCE_COLORS = ['#8B5CF6', '#34D399', '#60A5FA', '#F472B6', '#FBBF24', '#FB923C'];
13
18
  /** Picks one of `colors` for `id`, the same one every time (a presence avatar's color). */
@@ -54,6 +59,8 @@ export function TopBar({ title, dashboards, scenes, weather, extra, presence, pe
54
59
  return (_jsx(SceneDot, { entity: entity, ...(color ? { color } : {}), ...(icon ? { icon } : {}) }, entity));
55
60
  }) })) : null] }), _jsxs(Flex, { align: "center", gap: 3, children: [showDate ? _jsx(DateChip, {}) : null, weather ? _jsx(WeatherChip, { entity: weather }) : null, extra, presence ? (_jsx(StatusChip, { children: _jsx(SensorReadout, { entity: presence, drawer: false }) })) : null, people?.length ? (_jsx(PresenceStack, { entities: people, ...(presenceColors ? { colors: presenceColors } : {}) })) : null, showClock ? _jsx(Clock, { format: clockFormat }) : null, showStatus ? _jsx(SystemStatus, {}) : null] })] }));
56
61
  }
62
+ /** The height of the switcher's button, and of each entry in its list. */
63
+ const SWITCHER_HEIGHT = 40;
57
64
  function DashboardSwitcher({ title, dashboards, }) {
58
65
  const [open, setOpen] = useState(false);
59
66
  const navigate = useNavigate();
@@ -63,14 +70,16 @@ function DashboardSwitcher({ title, dashboards, }) {
63
70
  // The dashboard being shown: the one whose route the address is under, else the one the title names.
64
71
  const current = dashboards.find((d) => pathname === `/${d.id}` || pathname.startsWith(`/${d.id}/`)) ??
65
72
  dashboards.find((d) => d.title === title);
66
- return (_jsxs(Flex, { ref: ref, position: "relative", children: [_jsxs(Flex, { as: "button", type: "button", onClick: () => setOpen((o) => !o), "aria-haspopup": "listbox", "aria-expanded": open, align: "center", justify: "center", gap: 2, cursor: "pointer", color: "text", background: "surface", border: true, height: 40, px: 4, radius: "chrome", children: [current?.icon ? _jsx(Icon, { name: current.icon, size: 20 }) : null, _jsx(Typography, { as: "h1", variant: "title", children: title }), _jsx(Icon, { name: "lu:chevron-down", size: 20 })] }), open ? (_jsx(Flex, { as: "ul", role: "listbox", direction: "column", background: "surfaceRaised", radius: "row", shadow: "drawer", p: 1.5, gap: 0.5, position: "absolute", zIndex: "dropdown", mt: 2, minWidth: "100%", css: { top: '100%', left: 0, listStyle: 'none', whiteSpace: 'nowrap' }, children: dashboards.map((d) => {
73
+ return (_jsxs(Flex, { ref: ref, position: "relative", children: [_jsxs(Flex, { as: "button", type: "button", onClick: () => setOpen((o) => !o), "aria-haspopup": "listbox", "aria-expanded": open, align: "center", justify: "center", gap: 2, cursor: "pointer", color: "text", background: "surface", border: true, height: SWITCHER_HEIGHT, px: 4, radius: "chrome", children: [current?.icon ? _jsx(Icon, { name: current.icon, size: 20 }) : null, _jsx(Typography, { as: "h1", variant: "title", children: title }), _jsx(Icon, { name: "lu:chevron-down", size: 20 })] }), open ? (_jsx(Flex, { as: "ul", role: "listbox", direction: "column", background: "surfaceRaised", radius: "row", shadow: "drawer", p: 1.5, gap: 0.5, position: "absolute", zIndex: "dropdown", mt: 2, minWidth: "100%", css: { top: '100%', left: 0, listStyle: 'none', whiteSpace: 'nowrap' }, children: dashboards.map((d) => {
67
74
  const active = d.id === current?.id;
68
75
  return (_jsx("li", { role: "none", children: _jsxs(Flex, { as: "button", type: "button", role: "option", "aria-selected": active,
69
76
  // Already here: marked, and nothing to switch to.
70
77
  disabled: active, onClick: () => {
71
78
  setOpen(false);
72
79
  navigate(`/${d.id}`);
73
- }, align: "center", gap: 2, width: "100%", radius: "small", px: 2, py: 1.5, color: "text", background: active ? 'surface' : 'transparent', cursor: active ? 'default' : 'pointer', css: { textAlign: 'left' }, children: [d.icon ? _jsx(Icon, { name: d.icon, size: 16 }) : null, _jsx(Typography, { as: "span", variant: "body", grow: 1, children: d.title }), active ? _jsx(Icon, { name: "lu:check", size: 16 }) : null] }) }, d.id));
80
+ }, align: "center", gap: 2, width: "100%",
81
+ // As tall as the button that opened the list, so an entry is as easy to hit on a touch screen.
82
+ height: SWITCHER_HEIGHT, radius: "small", px: 3, color: "text", background: active ? 'surface' : 'transparent', cursor: active ? 'default' : 'pointer', css: { textAlign: 'left' }, children: [d.icon ? _jsx(Icon, { name: d.icon, size: 16 }) : null, _jsx(Typography, { as: "span", variant: "body", grow: 1, children: d.title }), active ? _jsx(Icon, { name: "lu:check", size: 16 }) : null] }) }, d.id));
74
83
  }) })) : null] }));
75
84
  }
76
85
  const HEALTH_COLORS = {
@@ -128,24 +137,6 @@ export function SystemStatus({ link: linkOverride, statuses: statusesOverride, d
128
137
  function StatusChip({ children }) {
129
138
  return (_jsx(Flex, { align: "center", gap: 2, background: "surface", border: true, radius: "full", px: 3.5, py: 2.5, children: children }));
130
139
  }
131
- /** What a condition looks like in the pill: an icon, and a color where the weather has one. */
132
- const CONDITION_LOOK = {
133
- sunny: { icon: 'lu:sun', color: '#FBBF24' },
134
- 'clear-night': { icon: 'lu:moon', color: '#A5B4FC' },
135
- partlycloudy: { icon: 'lu:cloud-sun', color: '#FBBF24' },
136
- cloudy: { icon: 'lu:cloud' },
137
- fog: { icon: 'lu:cloud-fog' },
138
- rainy: { icon: 'lu:cloud-rain', color: '#60A5FA' },
139
- pouring: { icon: 'lu:cloud-rain-wind', color: '#60A5FA' },
140
- snowy: { icon: 'lu:cloud-snow', color: '#BAE6FD' },
141
- 'snowy-rainy': { icon: 'lu:cloud-snow', color: '#93C5FD' },
142
- hail: { icon: 'lu:cloud-hail', color: '#93C5FD' },
143
- lightning: { icon: 'lu:cloud-lightning', color: '#FBBF24' },
144
- 'lightning-rainy': { icon: 'lu:cloud-lightning', color: '#FBBF24' },
145
- windy: { icon: 'lu:wind' },
146
- exceptional: { icon: 'lu:triangle-alert', color: '#F2554A' },
147
- unknown: { icon: 'lu:cloud' },
148
- };
149
140
  /** Current weather, as a pill: an icon for the sky and a rounded whole-degree reading. A `weather`
150
141
  * entity gives the real condition; a plain `sensor.*` (an outdoor temperature) gets a fixed sun,
151
142
  * since a sensor says nothing about the sky. */
@@ -156,11 +147,23 @@ function WeatherPill({ entity }) {
156
147
  const handle = useEntityHandle('weather', entity);
157
148
  const { status } = handle;
158
149
  const weather = handle.entity;
159
- const look = CONDITION_LOOK[weather?.condition ?? 'unknown'];
150
+ const condition = weather?.condition ?? 'unknown';
151
+ const look = CONDITION_LOOK[condition];
160
152
  const ready = status === 'ready' && weather?.temperature !== undefined;
161
- return (_jsxs(StatusChip, { children: [_jsx(Flex, { as: "span", css: look.color ? { color: look.color } : undefined, children: _jsx(Icon, { name: look.icon, size: 14 }) }), _jsx(Typography, { as: "span", variant: "label", children: ready
153
+ const forecastable = ready && (weather?.forecasts?.length ?? 0) > 0;
154
+ // Today's high and low sit under the reading, once the forecast has said what they are.
155
+ const daily = useWeatherForecast(weather?.forecasts?.includes('daily') ? entity : undefined, 'daily');
156
+ const today = daily.result?.points.find((point) => new Date(point.timestamp).toDateString() === new Date().toDateString()) ?? daily.result?.points[0];
157
+ const range = today?.temperature !== undefined && today.low !== undefined
158
+ ? `${Math.round(today.temperature)}° / ${Math.round(today.low)}°`
159
+ : undefined;
160
+ const chip = (_jsxs(StatusChip, { children: [_jsx(Flex, { as: "span", children: _jsx(WeatherIcon, { condition: condition, size: 14 }) }), _jsx(Typography, { as: "span", variant: "label", children: ready
162
161
  ? `${Math.round(weather.temperature ?? 0)}°`
163
- : statusLabels[status] })] }));
162
+ : statusLabels[status] }), ready && range ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: range })) : null] }));
163
+ if (!forecastable) {
164
+ return chip;
165
+ }
166
+ return (_jsx(DrawerTrigger, { icon: look.icon, label: weather?.name ?? 'Weather', kind: "Weather", body: _jsx(WeatherForecast, { entity: entity }), children: (open) => (_jsx(PlainButton, { "aria-label": `${weather?.name ?? 'Weather'}: forecast`, onClick: open, children: chip })) }));
164
167
  }
165
168
  function SensorWeather({ entity }) {
166
169
  const handle = useEntityHandle('sensor', entity);
@@ -0,0 +1,13 @@
1
+ /** @jsxImportSource @emotion/react */
2
+ import type { EntityRef } from '@hashsome/core';
3
+ export interface WeatherForecastProps {
4
+ /** A weather entity, as a ref like `ha:weather.forecast_home`. The forecasts it offers (hourly, daily) are read through the runtime. */
5
+ entity: EntityRef;
6
+ /** Lay it out for a wide space (readings tiles, a card for each hour, more columns for the days) or for a narrow one. Left out, it follows the drawer it is in. */
7
+ expanded?: boolean;
8
+ }
9
+ /** A weather entity's forecast: the weather now, the next 24 hours as a chart (hover it for everything
10
+ * about an hour) and the days ahead. The top bar's weather chip shows it in a drawer; a dashboard can
11
+ * put it in a page of its own, with `expanded` for the full-size layout. Wide, it adds the current
12
+ * readings and a card for each hour. A source with no forecast shows only the weather now. */
13
+ export declare function WeatherForecast({ entity, expanded: layout }: WeatherForecastProps): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -0,0 +1,161 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
2
+ import { Box, Flex, Typography } from 'e-prim';
3
+ import { useEntityHandle } from '../hooks.js';
4
+ import { Icon } from '../icon.js';
5
+ import { useDetail } from '../layout/detail-provider.js';
6
+ import { SeriesChart } from '../layout/series-chart.js';
7
+ import { useWeatherForecast } from '../use-weather-forecast.js';
8
+ import { WeatherIcon } from './weather-icon.js';
9
+ import { compass, CONDITION_LABEL, uvLevel } from './weather-look.js';
10
+ /** How many hours the chart and the hourly cards cover. */
11
+ const HOURS = 24;
12
+ /** Hours between the labelled points under the chart in the narrow drawer. */
13
+ const EVERY = 4;
14
+ const degrees = (value) => value === undefined ? '–' : `${Math.round(value)}°`;
15
+ const tenths = (value) => Math.round(value * 10) / 10;
16
+ const sameDay = (a, b) => a.toDateString() === b.toDateString();
17
+ function dayName(iso, now) {
18
+ const date = new Date(iso);
19
+ if (sameDay(date, now)) {
20
+ return 'Today';
21
+ }
22
+ const tomorrow = new Date(now);
23
+ tomorrow.setDate(now.getDate() + 1);
24
+ return sameDay(date, tomorrow) ? 'Tomorrow' : date.toLocaleDateString([], { weekday: 'long' });
25
+ }
26
+ const hourName = (iso) => new Date(iso).toLocaleTimeString([], { hour: 'numeric' });
27
+ /** `15 km/h from WNW`, with what there is of it. */
28
+ function windText(speed, bearing, unit) {
29
+ return `${Math.round(speed)}${unit ? ` ${unit}` : ''}${bearing !== undefined ? ` from ${compass(bearing)}` : ''}`;
30
+ }
31
+ /** `60% · 0.4 mm`, for the rain a step has: a chance, an amount, or both. Nothing for a dry step. */
32
+ function rainText(point, unit) {
33
+ const parts = [
34
+ ...(point.precipitationProbability ? [`${Math.round(point.precipitationProbability)}%`] : []),
35
+ ...(point.precipitation ? [`${tenths(point.precipitation)} ${unit ?? 'mm'}`] : []),
36
+ ];
37
+ return parts.length > 0 ? parts.join(' · ') : undefined;
38
+ }
39
+ /** Everything a step knows, a line each, for the chart's hover: the sky, the wind, the rain, the
40
+ * air, the sun and the pressure. What the source did not give is left out. */
41
+ function describeStep(point, result) {
42
+ const rain = rainText(point, result?.precipitationUnit);
43
+ const air = [
44
+ ...(point.humidity !== undefined ? [`Humidity ${Math.round(point.humidity)}%`] : []),
45
+ ...(point.cloudCoverage !== undefined
46
+ ? [`Cloud cover ${Math.round(point.cloudCoverage)}%`]
47
+ : []),
48
+ ];
49
+ const sun = [
50
+ ...(point.uvIndex !== undefined
51
+ ? [`UV ${tenths(point.uvIndex)} (${uvLevel(point.uvIndex)})`]
52
+ : []),
53
+ ...(point.apparentTemperature !== undefined
54
+ ? [`Feels like ${degrees(point.apparentTemperature)}`]
55
+ : []),
56
+ ];
57
+ return [
58
+ CONDITION_LABEL[point.condition],
59
+ ...(point.windSpeed !== undefined
60
+ ? [
61
+ `Wind ${windText(point.windSpeed, point.windBearing, result?.windUnit)}${point.windGustSpeed !== undefined ? `, gusts ${Math.round(point.windGustSpeed)}` : ''}`,
62
+ ]
63
+ : []),
64
+ ...(rain ? [`Rain ${rain}`] : []),
65
+ ...(air.length > 0 ? [air.join(' · ')] : []),
66
+ ...(sun.length > 0 ? [sun.join(' · ')] : []),
67
+ ...(point.pressure !== undefined
68
+ ? [
69
+ `Pressure ${Math.round(point.pressure)}${result?.pressureUnit ? ` ${result.pressureUnit}` : ''}`,
70
+ ]
71
+ : []),
72
+ ];
73
+ }
74
+ function ConditionIcon({ condition, size, }) {
75
+ return (_jsx(Flex, { as: "span", children: _jsx(WeatherIcon, { condition: condition, size: size }) }));
76
+ }
77
+ /** An arrow that points the way the wind blows (it comes from `bearing`, so it points the other way). */
78
+ function WindArrow({ bearing, size }) {
79
+ return (_jsx(Flex, { as: "span", css: { transform: `rotate(${bearing + 180}deg)` }, children: _jsx(Icon, { name: "lu:arrow-up", size: size }) }));
80
+ }
81
+ /** One small reading with an icon in front of it. */
82
+ function Metric({ icon, children }) {
83
+ return (_jsxs(Flex, { align: "center", gap: 1, color: "textMuted", children: [typeof icon === 'string' ? _jsx(Icon, { name: icon, size: 12 }) : icon, _jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", noWrap: true, children: children })] }));
84
+ }
85
+ /** The wind as a metric: an arrow the way it blows, and its speed. */
86
+ function WindMetric({ point, unit }) {
87
+ return point.windSpeed === undefined ? null : (_jsxs(Metric, { icon: point.windBearing !== undefined ? (_jsx(WindArrow, { bearing: point.windBearing, size: 12 })) : ('lu:wind'), children: [Math.round(point.windSpeed), unit ? ` ${unit}` : ''] }));
88
+ }
89
+ /** A tile with a label and a value, for the current readings of the expanded drawer. */
90
+ function Stat({ label, children }) {
91
+ return (_jsxs(Flex, { direction: "column", gap: 0.5, background: "surfaceRaised", radius: "card", px: 3, py: 2.5, children: [_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: label }), _jsx(Typography, { as: "span", variant: "bodyStrong", children: children })] }));
92
+ }
93
+ /** The readings the weather has now, from the entity and, for what it lacks, the hour that is
94
+ * under way (a source may give UV or cloud cover only in its forecast). */
95
+ function currentReadings(weather, soon) {
96
+ return {
97
+ uv: weather?.uvIndex ?? soon?.uvIndex,
98
+ cloud: weather?.cloudCoverage ?? soon?.cloudCoverage,
99
+ feels: weather?.apparentTemperature ?? soon?.apparentTemperature,
100
+ pressure: weather?.pressure ?? soon?.pressure,
101
+ humidity: weather?.humidity ?? soon?.humidity,
102
+ };
103
+ }
104
+ /** A weather entity's forecast: the weather now, the next 24 hours as a chart (hover it for everything
105
+ * about an hour) and the days ahead. The top bar's weather chip shows it in a drawer; a dashboard can
106
+ * put it in a page of its own, with `expanded` for the full-size layout. Wide, it adds the current
107
+ * readings and a card for each hour. A source with no forecast shows only the weather now. */
108
+ export function WeatherForecast({ entity, expanded: layout }) {
109
+ const handle = useEntityHandle('weather', entity);
110
+ const weather = handle.entity;
111
+ const hourly = useWeatherForecast(weather?.forecasts?.includes('hourly') ? entity : undefined, 'hourly');
112
+ const daily = useWeatherForecast(weather?.forecasts?.includes('daily') ? entity : undefined, 'daily');
113
+ const { detail } = useDetail();
114
+ const expanded = layout ?? detail?.expanded ?? false;
115
+ const now = new Date();
116
+ const hours = (hourly.result?.points ?? []).slice(0, HOURS);
117
+ const along = hours.filter((_, index) => index % EVERY === 0);
118
+ const reading = currentReadings(weather, hours[0]);
119
+ const windUnit = hourly.result?.windUnit ?? weather?.windUnit;
120
+ const days = daily.result?.points ?? [];
121
+ const lows = days.flatMap((day) => (day.low !== undefined ? [day.low] : []));
122
+ const highs = days.flatMap((day) => (day.temperature !== undefined ? [day.temperature] : []));
123
+ const coldest = Math.min(...lows);
124
+ const warmest = Math.max(...highs);
125
+ const span = warmest - coldest;
126
+ return (_jsxs(Flex, { direction: "column", gap: 5, children: [_jsxs(Flex, { align: "center", gap: 3, children: [_jsx(ConditionIcon, { condition: weather?.condition ?? 'unknown', size: expanded ? 56 : 40 }), _jsxs(Flex, { direction: "column", children: [_jsx(Typography, { as: "span", variant: "heading", children: degrees(weather?.temperature) }), _jsxs(Typography, { as: "span", variant: "secondary", color: "textMuted", children: [CONDITION_LABEL[weather?.condition ?? 'unknown'], reading.humidity !== undefined ? ` · ${Math.round(reading.humidity)}% humidity` : '', reading.uv !== undefined ? ` · UV ${tenths(reading.uv)} (${uvLevel(reading.uv)})` : ''] }), weather?.windSpeed !== undefined ? (_jsxs(Flex, { align: "center", gap: 1, color: "textMuted", children: [weather.windBearing !== undefined ? (_jsx(WindArrow, { bearing: weather.windBearing, size: 12 })) : null, _jsxs(Typography, { as: "span", variant: "secondary", color: "textMuted", children: ["Wind ", windText(weather.windSpeed, weather.windBearing, weather.windUnit)] })] })) : null] })] }), expanded ? (_jsxs(Box, { css: {
127
+ display: 'grid',
128
+ gap: 12,
129
+ gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
130
+ }, children: [reading.feels !== undefined ? (_jsx(Stat, { label: "Feels like", children: degrees(reading.feels) })) : null, reading.humidity !== undefined ? (_jsxs(Stat, { label: "Humidity", children: [Math.round(reading.humidity), "%"] })) : null, weather?.windSpeed !== undefined ? (_jsx(Stat, { label: "Wind", children: windText(weather.windSpeed, weather.windBearing, weather.windUnit) })) : null, reading.uv !== undefined ? (_jsxs(Stat, { label: "UV index", children: [tenths(reading.uv), " \u00B7 ", uvLevel(reading.uv)] })) : null, reading.cloud !== undefined ? (_jsxs(Stat, { label: "Cloud cover", children: [Math.round(reading.cloud), "%"] })) : null, reading.pressure !== undefined ? (_jsxs(Stat, { label: "Pressure", children: [Math.round(reading.pressure), weather?.pressureUnit ? ` ${weather.pressureUnit}` : ''] })) : null] })) : null, hours.length > 0 ? (_jsxs(Flex, { direction: "column", gap: 1, children: [_jsx(Typography, { as: "h3", variant: "label", color: "textMuted", m: 0, children: "Next 24 hours" }), _jsx(SeriesChart, { samples: hours.flatMap((hour) => hour.temperature !== undefined
131
+ ? [
132
+ {
133
+ timestamp: hour.timestamp,
134
+ value: hour.temperature,
135
+ notes: describeStep(hour, hourly.result),
136
+ },
137
+ ]
138
+ : []), range: "1d", expanded: expanded, unit: hourly.result?.unit ?? '°', fitDomain: true, height: expanded ? 180 : 88 }), expanded ? (_jsx(Box, { mt: 2, css: { containerType: 'inline-size' }, children: _jsx(Box, { css: {
139
+ display: 'grid',
140
+ gap: 10,
141
+ gridTemplateColumns: 'repeat(12, minmax(0, 1fr))',
142
+ '@container (max-width: 1000px)': {
143
+ gridTemplateColumns: 'repeat(8, minmax(0, 1fr))',
144
+ },
145
+ '@container (max-width: 640px)': {
146
+ gridTemplateColumns: 'repeat(6, minmax(0, 1fr))',
147
+ },
148
+ '@container (max-width: 420px)': {
149
+ gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
150
+ },
151
+ }, children: hours.map((hour) => {
152
+ const rain = rainText(hour, hourly.result?.precipitationUnit);
153
+ return (_jsxs(Flex, { direction: "column", align: "center", gap: 1.5, background: "surfaceRaised", radius: "card", px: 2, py: 3, children: [_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: hourName(hour.timestamp) }), _jsx(ConditionIcon, { condition: hour.condition, size: 26 }), _jsx(Typography, { as: "span", variant: "bodyStrong", children: degrees(hour.temperature) }), _jsxs(Flex, { direction: "column", align: "center", gap: 0.5, children: [rain ? _jsx(Metric, { icon: "lu:umbrella", children: rain }) : null, _jsx(WindMetric, { point: hour, unit: windUnit }), hour.humidity !== undefined ? (_jsxs(Metric, { icon: "lu:droplets", children: [Math.round(hour.humidity), "%"] })) : null, hour.uvIndex !== undefined && hour.uvIndex > 0 ? (_jsxs(Metric, { icon: "lu:sun", children: ["UV ", tenths(hour.uvIndex)] })) : null] })] }, hour.timestamp));
154
+ }) }) })) : (_jsx(Flex, { justify: "space-between", mt: 1, children: along.map((hour) => (_jsxs(Flex, { direction: "column", align: "center", gap: 1, grow: 1, css: { flexBasis: 0 }, children: [_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: hourName(hour.timestamp) }), _jsx(ConditionIcon, { condition: hour.condition, size: 18 }), _jsx(Typography, { as: "span", variant: "label", children: degrees(hour.temperature) })] }, hour.timestamp))) }))] })) : null, days.length > 0 ? (_jsxs(Flex, { direction: "column", gap: 2, children: [_jsxs(Typography, { as: "h3", variant: "label", color: "textMuted", m: 0, children: [days.length, " days"] }), _jsx(Flex, { as: "ul", direction: "column", gap: 1, m: 0, p: 0, css: { listStyle: 'none' }, children: days.map((day) => {
155
+ const rain = rainText(day, daily.result?.precipitationUnit);
156
+ return (_jsxs(Flex, { as: "li", align: "center", gap: 3, py: 1.5, children: [_jsx(Typography, { as: "span", variant: "body", width: 104, noWrap: true, children: dayName(day.timestamp, now) }), _jsx(ConditionIcon, { condition: day.condition, size: 20 }), _jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", width: expanded ? 90 : 40, noWrap: true, children: rain ?? '' }), _jsx(Typography, { as: "span", variant: "label", color: "textMuted", width: 36, css: { textAlign: 'right' }, children: degrees(day.low) }), _jsx(Box, { grow: 1, height: 6, radius: "full", background: "surfaceRaised", position: "relative", children: span > 0 && day.low !== undefined && day.temperature !== undefined ? (_jsx(Box, { position: "absolute", height: 6, radius: "full", background: "accent", css: {
157
+ left: `${((day.low - coldest) / span) * 100}%`,
158
+ width: `${((day.temperature - day.low) / span) * 100}%`,
159
+ } })) : null }), _jsx(Typography, { as: "span", variant: "label", width: 36, children: degrees(day.temperature) }), expanded ? (_jsxs(Flex, { gap: 4, css: { flex: 'none' }, children: [_jsx(Box, { width: 84, children: _jsx(WindMetric, { point: day, unit: daily.result?.windUnit }) }), _jsx(Box, { width: 64, children: day.humidity !== undefined ? (_jsxs(Metric, { icon: "lu:droplets", children: [Math.round(day.humidity), "%"] })) : null }), _jsx(Box, { width: 72, children: day.uvIndex !== undefined ? (_jsxs(Metric, { icon: "lu:sun", children: ["UV ", tenths(day.uvIndex)] })) : null })] })) : null] }, day.timestamp));
160
+ }) })] })) : null] }));
161
+ }
@@ -0,0 +1,7 @@
1
+ /** @jsxImportSource @emotion/react */
2
+ import type { WeatherCondition } from '@hashsome/core';
3
+ /** The icon for a sky condition, in its own colors: the sun yellow, a cloud blue, the rain a deeper blue. */
4
+ export declare function WeatherIcon({ condition, size }: {
5
+ condition: WeatherCondition;
6
+ size?: number;
7
+ }): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx } from "@emotion/react/jsx-runtime";
2
+ import { ICON_NODES } from '../icon-data.js';
3
+ import { Icon } from '../icon.js';
4
+ import { CONDITION_LOOK } from './weather-look.js';
5
+ /** The icon for a sky condition, in its own colors: the sun yellow, a cloud blue, the rain a deeper blue. */
6
+ export function WeatherIcon({ condition, size }) {
7
+ const look = CONDITION_LOOK[condition];
8
+ const parts = ICON_NODES[look.icon]?.length ?? 0;
9
+ const colors = Array.from({ length: parts }, (_, i) => look.parts?.[i] ?? look.color);
10
+ return _jsx(Icon, { name: look.icon, colors: colors, ...(size !== undefined ? { size } : {}) });
11
+ }
@@ -0,0 +1,17 @@
1
+ import type { WeatherCondition } from '@hashsome/core';
2
+ import type { IconName } from '../icon-data.ts';
3
+ /** What a condition looks like: an icon, and the colors it is drawn in. */
4
+ export interface ConditionLook {
5
+ icon: IconName;
6
+ /** The color of every part of the icon that `parts` does not name; the surrounding text color when left out. */
7
+ color?: string;
8
+ /** Colors by part of the icon (its `i`th shape), for one drawn in two colors: the cloud blue and the sun yellow. */
9
+ parts?: Record<number, string>;
10
+ }
11
+ export declare const CONDITION_LOOK: Record<WeatherCondition, ConditionLook>;
12
+ /** A condition in words. */
13
+ export declare const CONDITION_LABEL: Record<WeatherCondition, string>;
14
+ /** The compass point for a bearing in degrees: `225` is `SW`. */
15
+ export declare function compass(degrees: number): string;
16
+ /** How strong a UV index is, in words. */
17
+ export declare function uvLevel(index: number): string;