@hashsome/ui 0.1.0 → 0.3.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 (127) 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 +40 -14
  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 +76 -20
  9. package/dist/entities/media-player-bar.js +3 -2
  10. package/dist/entities/media-player-column.js +3 -2
  11. package/dist/entities/media-player-full.d.ts +22 -0
  12. package/dist/entities/media-player-full.js +19 -0
  13. package/dist/entities/media-queue.d.ts +14 -0
  14. package/dist/entities/media-queue.js +151 -0
  15. package/dist/entities/now-playing.d.ts +1 -1
  16. package/dist/entities/now-playing.js +1 -1
  17. package/dist/entities/top-bar.d.ts +1 -1
  18. package/dist/entities/top-bar.js +26 -23
  19. package/dist/entities/weather-forecast.d.ts +13 -0
  20. package/dist/entities/weather-forecast.js +148 -0
  21. package/dist/entities/weather-icon.d.ts +7 -0
  22. package/dist/entities/weather-icon.js +11 -0
  23. package/dist/entities/weather-look.d.ts +17 -0
  24. package/dist/entities/weather-look.js +75 -0
  25. package/dist/gallery/fixtures.d.ts +8 -2
  26. package/dist/gallery/fixtures.js +22 -3
  27. package/dist/gallery/gallery.js +7 -5
  28. package/dist/gallery/props-data.js +66 -8
  29. package/dist/hooks.d.ts +6 -0
  30. package/dist/hooks.js +18 -1
  31. package/dist/icon.d.ts +3 -1
  32. package/dist/icon.js +6 -2
  33. package/dist/index.d.ts +3 -1
  34. package/dist/index.js +3 -1
  35. package/dist/layout/drawer-controls.d.ts +2 -0
  36. package/dist/layout/drawer-controls.js +11 -1
  37. package/dist/layout/history-section.d.ts +3 -13
  38. package/dist/layout/history-section.js +15 -5
  39. package/dist/layout/logbook-history.d.ts +6 -0
  40. package/dist/layout/logbook-history.js +9 -0
  41. package/dist/layout/marquee-text.d.ts +11 -0
  42. package/dist/layout/marquee-text.js +67 -0
  43. package/dist/layout/reveal.d.ts +11 -0
  44. package/dist/layout/reveal.js +19 -0
  45. package/dist/layout/series-chart.d.ts +5 -1
  46. package/dist/layout/series-chart.js +9 -4
  47. package/dist/layout/skeleton.d.ts +11 -0
  48. package/dist/layout/skeleton.js +16 -0
  49. package/dist/layout/tile.d.ts +9 -1
  50. package/dist/layout/tile.js +8 -4
  51. package/dist/use-entity-history.js +5 -1
  52. package/dist/use-entity-logbook.d.ts +13 -0
  53. package/dist/use-entity-logbook.js +46 -0
  54. package/dist/use-media-browser.d.ts +4 -0
  55. package/dist/use-media-browser.js +11 -3
  56. package/dist/use-media-queue.d.ts +15 -0
  57. package/dist/use-media-queue.js +44 -0
  58. package/dist/use-weather-forecast.d.ts +12 -0
  59. package/dist/use-weather-forecast.js +45 -0
  60. package/package.json +2 -2
  61. package/dist/entities/action-button.js.map +0 -1
  62. package/dist/entities/artwork-ring.js.map +0 -1
  63. package/dist/entities/climate-tile.js.map +0 -1
  64. package/dist/entities/light-color.js.map +0 -1
  65. package/dist/entities/light-tile.js.map +0 -1
  66. package/dist/entities/media-browser.js.map +0 -1
  67. package/dist/entities/media-controls.js.map +0 -1
  68. package/dist/entities/media-player-bar.js.map +0 -1
  69. package/dist/entities/media-player-body.d.ts +0 -16
  70. package/dist/entities/media-player-body.js +0 -18
  71. package/dist/entities/media-player-body.js.map +0 -1
  72. package/dist/entities/media-player-column.js.map +0 -1
  73. package/dist/entities/media-player-page.d.ts +0 -12
  74. package/dist/entities/media-player-page.js +0 -13
  75. package/dist/entities/media-player-page.js.map +0 -1
  76. package/dist/entities/media-progress.js.map +0 -1
  77. package/dist/entities/media-seek.js.map +0 -1
  78. package/dist/entities/nav-dock.js.map +0 -1
  79. package/dist/entities/nav-rail.js.map +0 -1
  80. package/dist/entities/now-playing.js.map +0 -1
  81. package/dist/entities/scene-button.js.map +0 -1
  82. package/dist/entities/seek-line.js.map +0 -1
  83. package/dist/entities/sensor-readout.js.map +0 -1
  84. package/dist/entities/top-bar.js.map +0 -1
  85. package/dist/entity-handle.js.map +0 -1
  86. package/dist/gallery/fixtures.js.map +0 -1
  87. package/dist/gallery/gallery.js.map +0 -1
  88. package/dist/gallery/index.js.map +0 -1
  89. package/dist/gallery/props-data.js.map +0 -1
  90. package/dist/hooks.js.map +0 -1
  91. package/dist/icon-data.js.map +0 -1
  92. package/dist/icon.js.map +0 -1
  93. package/dist/index.js.map +0 -1
  94. package/dist/layout/cover.js.map +0 -1
  95. package/dist/layout/date-time-field.js.map +0 -1
  96. package/dist/layout/detail-provider.js.map +0 -1
  97. package/dist/layout/drawer-controls.js.map +0 -1
  98. package/dist/layout/energy-chart.js.map +0 -1
  99. package/dist/layout/energy-usage.js.map +0 -1
  100. package/dist/layout/entity-drawer.js.map +0 -1
  101. package/dist/layout/fade-scroll.js.map +0 -1
  102. package/dist/layout/grid.js.map +0 -1
  103. package/dist/layout/history-section.js.map +0 -1
  104. package/dist/layout/hold-progress.js.map +0 -1
  105. package/dist/layout/icon-button-styles.js.map +0 -1
  106. package/dist/layout/page.js.map +0 -1
  107. package/dist/layout/plain-button.js.map +0 -1
  108. package/dist/layout/room-header.js.map +0 -1
  109. package/dist/layout/round-button.js.map +0 -1
  110. package/dist/layout/sensor-history.js.map +0 -1
  111. package/dist/layout/sensor-stats.js.map +0 -1
  112. package/dist/layout/series-chart.js.map +0 -1
  113. package/dist/layout/stepper.js.map +0 -1
  114. package/dist/layout/swatch-row.js.map +0 -1
  115. package/dist/layout/tile.js.map +0 -1
  116. package/dist/layout/use-drawer.js.map +0 -1
  117. package/dist/layout/use-hold.js.map +0 -1
  118. package/dist/provider.js.map +0 -1
  119. package/dist/status.js.map +0 -1
  120. package/dist/theme/density.js.map +0 -1
  121. package/dist/theme/global-styles.js.map +0 -1
  122. package/dist/theme/index.js.map +0 -1
  123. package/dist/theme/overrides.js.map +0 -1
  124. package/dist/theme/tokens.js.map +0 -1
  125. package/dist/theme/use-typography.js.map +0 -1
  126. package/dist/use-entity-history.js.map +0 -1
  127. package/dist/use-media-browser.js.map +0 -1
@@ -157,6 +157,12 @@ export const COMPONENT_PROPS = {
157
157
  optional: true,
158
158
  type: 'LogbookEntry[]',
159
159
  },
160
+ {
161
+ doc: "An entity whose recent activity to fetch from the backend when the drawer opens, for the same History section (Home Assistant's logbook). `history`, when given, is used instead. A backend that keeps none shows no section.",
162
+ name: 'logbook',
163
+ optional: true,
164
+ type: 'EntityRef',
165
+ },
160
166
  ],
161
167
  },
162
168
  IconButton: {
@@ -230,6 +236,12 @@ export const COMPONENT_PROPS = {
230
236
  optional: true,
231
237
  type: 'number',
232
238
  },
239
+ {
240
+ doc: 'For an icon drawn in more than one color: part `i` of the icon (its `i`th shape) is drawn in `colors[i]`, and any part without one, in the surrounding text color. Only for outline icons.',
241
+ name: 'colors',
242
+ optional: true,
243
+ type: 'readonly (string | undefined)[]',
244
+ },
233
245
  ],
234
246
  },
235
247
  LightTile: {
@@ -260,7 +272,7 @@ export const COMPONENT_PROPS = {
260
272
  type: 'EnergyChartProps',
261
273
  },
262
274
  {
263
- doc: 'Recent activity, shown in the drawer. No default source yet — pass explicitly.',
275
+ doc: "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.",
264
276
  name: 'history',
265
277
  optional: true,
266
278
  type: 'LogbookEntry[]',
@@ -301,7 +313,7 @@ export const COMPONENT_PROPS = {
301
313
  type: 'EnergyChartProps',
302
314
  },
303
315
  {
304
- doc: 'Recent activity, shown in the drawer. No default source yet — pass explicitly.',
316
+ doc: "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.",
305
317
  name: 'history',
306
318
  optional: true,
307
319
  type: 'LogbookEntry[]',
@@ -459,21 +471,39 @@ export const COMPONENT_PROPS = {
459
471
  },
460
472
  ],
461
473
  },
462
- MediaPlayerPage: {
463
- doc: "The player as a whole page: the player centered, and the player's library below it, so songs are picked right here instead of in a drawer. It is the same layout as the expanded drawer. It fills the height it is given and scrolls inside it.",
474
+ MediaPlayerFull: {
475
+ doc: "The big player: the player centered at the top, and the library (or whatever `browser` is) below it. It is what the media cards' drawers show, at every width, and it is the widget a dashboard puts in a page of its own, around it whatever it likes (a surface that fills the space and scrolls, a heading, other cards beside it). It fills the height it is given, so the page that holds it decides how tall that is.",
464
476
  props: [
465
477
  {
466
- doc: 'A media player, as a ref like `ma:living_room`. A ref, not a handle: the library below the player is read through the runtime.',
478
+ doc: 'The media player, as a ref like `ma:living_room`.',
467
479
  name: 'entity',
468
480
  optional: false,
469
- type: 'EntityRef',
481
+ type: "EntityRef | EntityHandle<'mediaPlayer'>",
470
482
  },
471
483
  {
472
- doc: "What to call the player: the drawer's title and the name shown when nothing is playing. Defaults to the player's own name.",
484
+ doc: 'What goes below the player, usually the player\'s library: `<MediaBrowser entity="ma:living_room" layout="theater" />`. Left out, only the player is drawn.',
485
+ name: 'browser',
486
+ optional: true,
487
+ type: 'ReactNode',
488
+ },
489
+ {
490
+ doc: 'What goes beside the player in a wide space, usually the player\'s queue: `<MediaQueue entity="ma:living_room" />`. Only used when `wide` and the player has a queue; the narrow layout has no room for it.',
491
+ name: 'queue',
492
+ optional: true,
493
+ type: 'ReactNode',
494
+ },
495
+ {
496
+ doc: 'Calls the player this instead of the name it reports.',
473
497
  name: 'name',
474
498
  optional: true,
475
499
  type: 'string',
476
500
  },
501
+ {
502
+ doc: 'Gives the library the whole width, for one laid out to use it (the theater layout). Defaults to whether the drawer is expanded.',
503
+ name: 'wide',
504
+ optional: true,
505
+ type: 'boolean',
506
+ },
477
507
  ],
478
508
  },
479
509
  MediaBrowser: {
@@ -499,6 +529,17 @@ export const COMPONENT_PROPS = {
499
529
  },
500
530
  ],
501
531
  },
532
+ MediaQueue: {
533
+ doc: "A player's queue: the tracks that have just played (dimmed), the one playing, and what comes next, as a list that scrolls. Tap a track to jump to it, the cross takes it out, and Clear takes out everything after the track that is playing, which carries on. What comes next can be put in another order by dragging a track by its handle (or with the arrow keys on the handle). Once the last track has played and the player has stopped, that track is shown as played, not as playing. It is made to sit beside the player in a wide space (see `MediaPlayerFull`'s `queue`).",
534
+ props: [
535
+ {
536
+ doc: 'The media player, as a ref like `ma:living_room`. Shows nothing for a player with no queue.',
537
+ name: 'entity',
538
+ optional: false,
539
+ type: 'EntityRef',
540
+ },
541
+ ],
542
+ },
502
543
  NavRail: {
503
544
  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.",
504
545
  props: [
@@ -567,7 +608,7 @@ export const COMPONENT_PROPS = {
567
608
  type: '(EntityRef | TopBarScene)[]',
568
609
  },
569
610
  {
570
- doc: '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.',
611
+ doc: "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.",
571
612
  name: 'weather',
572
613
  optional: true,
573
614
  type: 'EntityRef',
@@ -662,6 +703,23 @@ export const COMPONENT_PROPS = {
662
703
  },
663
704
  ],
664
705
  },
706
+ WeatherForecast: {
707
+ doc: "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, with `expanded` for the full-size layout. Wide, it adds the current readings and a card for each hour. A source with no forecast shows only the weather now.",
708
+ props: [
709
+ {
710
+ doc: 'A weather entity, as a ref like `ha:weather.forecast_home`. The forecasts it offers (hourly, daily) are read through the runtime.',
711
+ name: 'entity',
712
+ optional: false,
713
+ type: 'EntityRef',
714
+ },
715
+ {
716
+ doc: '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.',
717
+ name: 'expanded',
718
+ optional: true,
719
+ type: 'boolean',
720
+ },
721
+ ],
722
+ },
665
723
  HashsomeProvider: {
666
724
  doc: 'Connects the tree to the runtime proxy. Render only on the client.',
667
725
  props: [
package/dist/hooks.d.ts CHANGED
@@ -11,6 +11,12 @@ export declare function useCommand(ref: EntityRef | undefined): CommandSender;
11
11
  export declare function useEntityHandle<K extends EntityKind>(kind: K, source: EntityRef | EntityHandle<K>): EntityHandle<K>;
12
12
  /** Connection between this browser and the runtime. */
13
13
  export declare function useConnectionStatus(): LinkStatus;
14
+ /**
15
+ * How many times the connection to the runtime has opened. A hook that asked for something while it
16
+ * was down (and got an error) depends on this, so it asks again when the connection is back: a read
17
+ * that failed because the page was ahead of the connection, or because it dropped, is not final.
18
+ */
19
+ export declare function useReconnects(): number;
14
20
  /** Status of the runtime's connection to a backend (`ha`, `ma`, ...). */
15
21
  export declare function useIntegrationStatus(integration: string): ConnectionStatus | undefined;
16
22
  /** Status of every backend integration the runtime reports, keyed by integration id. */
package/dist/hooks.js CHANGED
@@ -1,4 +1,4 @@
1
- import { useCallback, useMemo, useSyncExternalStore } from 'react';
1
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
2
2
  import { toHandle } from './entity-handle.js';
3
3
  import { useClient } from './provider.js';
4
4
  // Every `subscribe` handed to `useSyncExternalStore` below is memoized: React resubscribes whenever
@@ -33,6 +33,23 @@ export function useConnectionStatus() {
33
33
  const subscribe = useCallback((onChange) => client.onLinkChange(onChange), [client]);
34
34
  return useSyncExternalStore(subscribe, () => client.link, () => 'closed');
35
35
  }
36
+ /**
37
+ * How many times the connection to the runtime has opened. A hook that asked for something while it
38
+ * was down (and got an error) depends on this, so it asks again when the connection is back: a read
39
+ * that failed because the page was ahead of the connection, or because it dropped, is not final.
40
+ */
41
+ export function useReconnects() {
42
+ const link = useConnectionStatus();
43
+ const [opened, setOpened] = useState(0);
44
+ const was = useRef(link);
45
+ useEffect(() => {
46
+ if (link === 'open' && was.current !== 'open') {
47
+ setOpened((count) => count + 1);
48
+ }
49
+ was.current = link;
50
+ }, [link]);
51
+ return opened;
52
+ }
36
53
  /** Status of the runtime's connection to a backend (`ha`, `ma`, ...). */
37
54
  export function useIntegrationStatus(integration) {
38
55
  const client = useClient();
package/dist/icon.d.ts CHANGED
@@ -4,6 +4,8 @@ export interface IconProps {
4
4
  name: IconName;
5
5
  /** Overrides the density's icon size, in px. */
6
6
  size?: number;
7
+ /** For an icon drawn in more than one color: part `i` of the icon (its `i`th shape) is drawn in `colors[i]`, and any part without one, in the surrounding text color. Only for outline icons. */
8
+ colors?: readonly (string | undefined)[];
7
9
  }
8
10
  /** An inline SVG icon, from a plain prefixed id (`lu:` Lucide, `tb:` Tabler outline). Takes the surrounding text color. */
9
- export declare function Icon({ name, size }: IconProps): import("@emotion/react/jsx-runtime").JSX.Element;
11
+ export declare function Icon({ name, size, colors }: IconProps): import("@emotion/react/jsx-runtime").JSX.Element;
package/dist/icon.js CHANGED
@@ -7,7 +7,7 @@ const STROKE_WIDTH = 1.5;
7
7
  * here (e.g. a future filled pack) renders solid instead. */
8
8
  const STROKE_PACKS = new Set(['lu', 'tb']);
9
9
  /** An inline SVG icon, from a plain prefixed id (`lu:` Lucide, `tb:` Tabler outline). Takes the surrounding text color. */
10
- export function Icon({ name, size }) {
10
+ export function Icon({ name, size, colors }) {
11
11
  const stroke = STROKE_PACKS.has(name.slice(0, name.indexOf(':')));
12
12
  const nodes = ICON_NODES[name];
13
13
  if (!nodes) {
@@ -24,5 +24,9 @@ export function Icon({ name, size }) {
24
24
  strokeLinecap: 'round',
25
25
  strokeLinejoin: 'round',
26
26
  }
27
- : {}), "aria-hidden": "true", children: (nodes ?? []).map(([tag, attrs], i) => createElement(tag, { key: i, ...attrs })) }));
27
+ : {}), "aria-hidden": "true", children: (nodes ?? []).map(([tag, attrs], i) => createElement(tag, {
28
+ key: i,
29
+ ...attrs,
30
+ ...(stroke && colors?.[i] ? { stroke: colors[i] } : {}),
31
+ })) }));
28
32
  }
package/dist/index.d.ts CHANGED
@@ -22,8 +22,10 @@ export * from './entities/action-button.tsx';
22
22
  export * from './entities/scene-button.tsx';
23
23
  export * from './entities/media-player-bar.tsx';
24
24
  export * from './entities/media-player-column.tsx';
25
- export * from './entities/media-player-page.tsx';
25
+ export * from './entities/media-player-full.tsx';
26
+ export * from './entities/weather-forecast.tsx';
26
27
  export * from './entities/media-browser.tsx';
28
+ export * from './entities/media-queue.tsx';
27
29
  export { formatDuration } from './entities/media-progress.ts';
28
30
  export * from './entities/nav-rail.tsx';
29
31
  export * from './entities/nav-dock.tsx';
package/dist/index.js CHANGED
@@ -16,8 +16,10 @@ export * from './entities/action-button.js';
16
16
  export * from './entities/scene-button.js';
17
17
  export * from './entities/media-player-bar.js';
18
18
  export * from './entities/media-player-column.js';
19
- export * from './entities/media-player-page.js';
19
+ export * from './entities/media-player-full.js';
20
+ export * from './entities/weather-forecast.js';
20
21
  export * from './entities/media-browser.js';
22
+ export * from './entities/media-queue.js';
21
23
  export { formatDuration } from './entities/media-progress.js';
22
24
  export * from './entities/nav-rail.js';
23
25
  export * from './entities/nav-dock.js';
@@ -31,6 +31,8 @@ export declare function PowerButton({ on, onToggle }: {
31
31
  on: boolean;
32
32
  onToggle: () => void;
33
33
  }): import("@emotion/react/jsx-runtime").JSX.Element;
34
+ /** The height of every chip, whatever its label holds. */
35
+ export declare const CHIP_HEIGHT = 28;
34
36
  /** A wrapping row of pill choices (modes, presets) with the selected one filled. With `tabs` it is
35
37
  * a tab list (the selected chip is the open tab) that never wraps: too many to fit scroll sideways. */
36
38
  export declare function ChipRow({ options, value, onChange, tabs, }: {
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
2
2
  /** @jsxImportSource @emotion/react */
3
3
  import { Box, Flex, Typography } from 'e-prim';
4
4
  import { motion } from 'motion/react';
5
+ import { MarqueeText } from './marquee-text.js';
5
6
  import { useEffect, useRef } from 'react';
6
7
  import { Icon } from '../icon.js';
7
8
  import { FadeScroll } from './fade-scroll.js';
@@ -79,6 +80,8 @@ export function ValueBar({ label, value, min, max, keyStep, round = 1, gradient,
79
80
  export function PowerButton({ on, onToggle }) {
80
81
  return (_jsx(Flex, { as: "button", type: "button", "aria-pressed": on, "aria-label": on ? 'Turn off' : 'Turn on', title: on ? 'Turn off' : 'Turn on', onClick: onToggle, align: "center", justify: "center", radius: "full", cursor: "pointer", width: 36, height: 36, p: 0, background: on ? 'accent' : 'surface', color: on ? 'accentText' : 'line', css: { flex: 'none', transition: 'background-color 0.2s ease, color 0.2s ease' }, children: _jsx(Icon, { name: "lu:power", size: 16 }) }));
81
82
  }
83
+ /** The height of every chip, whatever its label holds. */
84
+ export const CHIP_HEIGHT = 28;
82
85
  /** A wrapping row of pill choices (modes, presets) with the selected one filled. With `tabs` it is
83
86
  * a tab list (the selected chip is the open tab) that never wraps: too many to fit scroll sideways. */
84
87
  export function ChipRow({ options, value, onChange, tabs = false, }) {
@@ -88,6 +91,13 @@ export function ChipRow({ options, value, onChange, tabs = false, }) {
88
91
  ? { flexShrink: 0, scrollbarWidth: 'none', '&::-webkit-scrollbar': { display: 'none' } }
89
92
  : { flexWrap: 'wrap' }, children: options.map((option) => {
90
93
  const selected = option.value === value;
91
- return (_jsxs(Flex, { as: "button", type: "button", ...(tabs ? { role: 'tab', 'aria-selected': selected } : { 'aria-pressed': selected }), ...(option.ariaLabel ? { 'aria-label': option.ariaLabel } : {}), onClick: () => onChange(option.value), align: "center", gap: 1.5, radius: "full", cursor: "pointer", py: 1.5, px: 3, background: selected ? 'accent' : 'surface', color: selected ? 'accentText' : 'text', css: { flex: 'none', whiteSpace: 'nowrap' }, children: [option.icon ? _jsx(Icon, { name: option.icon, size: 14 }) : null, _jsx(Typography, { as: "span", variant: "body", noWrap: true, textOverflow: "ellipsis", css: { maxWidth: 220 }, children: option.label })] }, option.value));
94
+ return (_jsxs(Flex, { as: "button", type: "button", ...(tabs ? { role: 'tab', 'aria-selected': selected } : { 'aria-pressed': selected }), ...(option.ariaLabel ? { 'aria-label': option.ariaLabel } : {}), onClick: () => onChange(option.value), align: "center", gap: 1.5, radius: "full", cursor: "pointer",
95
+ // A fixed height, not the text's: an emoji is drawn from another font with a taller line,
96
+ // which would make this one chip taller than the rest.
97
+ height: CHIP_HEIGHT, px: 3, background: selected ? 'accent' : 'surface', color: selected ? 'accentText' : 'text', css: {
98
+ flex: 'none',
99
+ whiteSpace: 'nowrap',
100
+ transition: 'background-color 160ms ease, color 160ms ease',
101
+ }, children: [option.icon ? _jsx(Icon, { name: option.icon, size: 14 }) : null, _jsx(Typography, { as: "span", variant: "body", css: { minWidth: 0, maxWidth: 220 }, children: _jsx(MarqueeText, { children: option.label }) })] }, option.value));
92
102
  }) }));
93
103
  }
@@ -1,16 +1,6 @@
1
- export interface LogbookEntry {
2
- id: string;
3
- /** Lowercase, no actor prefix, e.g. "turned on", "brightness set to 60%" — capitalized when
4
- * there's no `actor` to lead with ("Turned on"), left lowercase after one ("Dan turned on"). */
5
- message: string;
6
- /** ISO 8601. */
7
- timestamp: string;
8
- /** Who or what triggered it, e.g. "Dan" or "automation.bedtime". Omit for the system. */
9
- actor?: string;
10
- /** Only matters when `actor` is set: a person gets an initials avatar, an automation a robot
11
- * icon. Default `'person'`. */
12
- actorKind?: 'person' | 'automation';
13
- }
1
+ /** @jsxImportSource @emotion/react */
2
+ import type { LogbookEntry } from '@hashsome/core';
3
+ export type { LogbookEntry };
14
4
  export interface HistorySectionProps {
15
5
  /** Recent activity, newest first. An empty list renders nothing. */
16
6
  entries: LogbookEntry[];
@@ -1,5 +1,4 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
2
- /** @jsxImportSource @emotion/react */
3
2
  import { Flex, Typography } from 'e-prim';
4
3
  import { Icon } from '../icon.js';
5
4
  import { capitalize } from '../status.js';
@@ -26,14 +25,25 @@ function initials(name) {
26
25
  .map((word) => word.charAt(0).toUpperCase())
27
26
  .join('');
28
27
  }
29
- function Avatar({ actor, kind }) {
30
- const automation = kind === 'automation';
31
- return (_jsx(Flex, { align: "center", justify: "center", background: automation ? 'surfaceRaised' : 'accent', color: automation ? 'text' : 'accentText', radius: "full", width: 32, height: 32, css: { flex: 'none' }, children: automation ? (_jsx(Icon, { name: "lu:bot", size: 16 })) : (_jsx(Typography, { as: "span", variant: "eyebrow", children: initials(actor) })) }));
28
+ /** The chip for a row nobody caused: a switch thrown the way it went for on and off, and a chip for
29
+ * the device coming or going or another mode. */
30
+ const QUIET_ICON = {
31
+ on: 'lu:toggle-right',
32
+ off: 'lu:toggle-left',
33
+ state: 'lu:cpu',
34
+ availability: 'lu:cpu',
35
+ };
36
+ /** The round picture at the start of a row: initials for a person, a workflow icon for an automation, and
37
+ * for nobody a quiet chip, so every row's text lines up: a switch for on and off (often someone at
38
+ * the device's own switch), a chip for the device coming or going. */
39
+ function Avatar({ actor, kind, change, }) {
40
+ const person = actor !== undefined && kind === 'person';
41
+ return (_jsx(Flex, { role: "img", "aria-label": actor ?? 'System', align: "center", justify: "center", background: person ? 'accent' : 'surfaceRaised', color: person ? 'accentText' : actor === undefined ? 'textMuted' : 'text', radius: "full", width: 32, height: 32, css: { flex: 'none' }, children: person ? (_jsx(Typography, { as: "span", variant: "eyebrow", children: initials(actor) })) : (_jsx(Icon, { name: actor !== undefined ? 'lu:workflow' : QUIET_ICON[change ?? 'availability'], size: 16 })) }));
32
42
  }
33
43
  /** A short activity list for an entity's detail drawer. Generic — any entity type can supply it. */
34
44
  export function HistorySection({ entries }) {
35
45
  if (entries.length === 0) {
36
46
  return null;
37
47
  }
38
- return (_jsxs(Flex, { direction: "column", gap: 4, pt: 4, mt: "auto", children: [_jsx(Typography, { as: "span", variant: "eyebrow", uppercase: true, color: "textMuted", children: "History" }), _jsx(Flex, { direction: "column", children: entries.map((entry) => (_jsxs(Flex, { align: "center", gap: 3, py: 2, children: [entry.actor ? _jsx(Avatar, { actor: entry.actor, kind: entry.actorKind ?? 'person' }) : null, _jsxs(Flex, { direction: "column", minWidth: 0, children: [_jsxs(Typography, { as: "span", variant: "body", noWrap: true, textOverflow: "ellipsis", minWidth: 0, children: [entry.actor ? (_jsxs(Typography, { as: "span", variant: "bodyStrong", children: [entry.actor, ' '] })) : null, entry.actor ? entry.message : capitalize(entry.message)] }), _jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: relativeTime(entry.timestamp) })] })] }, entry.id))) })] }));
48
+ return (_jsxs(Flex, { direction: "column", gap: 4, pt: 4, mt: "auto", children: [_jsx(Typography, { as: "span", variant: "eyebrow", uppercase: true, color: "textMuted", children: "History" }), _jsx(Flex, { direction: "column", children: entries.map((entry) => (_jsxs(Flex, { align: "center", gap: 3, py: 2, children: [_jsx(Avatar, { actor: entry.actor, kind: entry.actorKind ?? 'person', change: entry.change }), _jsxs(Flex, { direction: "column", minWidth: 0, children: [_jsxs(Typography, { as: "span", variant: "body", noWrap: true, textOverflow: "ellipsis", minWidth: 0, children: [entry.actor ? (_jsxs(Typography, { as: "span", variant: "bodyStrong", children: [entry.actor, ' '] })) : null, entry.actor ? entry.message : capitalize(entry.message)] }), _jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: relativeTime(entry.timestamp) })] })] }, entry.id))) })] }));
39
49
  }
@@ -0,0 +1,6 @@
1
+ import type { EntityRef } from '@hashsome/core';
2
+ /** A drawer's History section filled from the backend: it asks for the entity's recent activity when
3
+ * it is drawn (so only when the drawer opens), and shows nothing until there is some. */
4
+ export declare function LogbookHistory({ entity }: {
5
+ entity: EntityRef;
6
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEntityLogbook } from '../use-entity-logbook.js';
3
+ import { HistorySection } from './history-section.js';
4
+ /** A drawer's History section filled from the backend: it asks for the entity's recent activity when
5
+ * it is drawn (so only when the drawer opens), and shows nothing until there is some. */
6
+ export function LogbookHistory({ entity }) {
7
+ const { entries } = useEntityLogbook(entity);
8
+ return entries ? _jsx(HistorySection, { entries: entries }) : null;
9
+ }
@@ -0,0 +1,11 @@
1
+ import { type ReactNode } from 'react';
2
+ /**
3
+ * One line of text that fits the width it is given: when it is too long it rolls sideways, resting at
4
+ * each end, so all of it can be read; when it fits it just sits there. Whoever it is in decides the
5
+ * width (a `max-width` on it or on a parent), so a long title can never push its tile out of shape,
6
+ * whatever it holds (an emoji at the end included). Where the person asks their device for less
7
+ * motion it stays still and ends in an ellipsis.
8
+ */
9
+ export declare function MarqueeText({ children }: {
10
+ children: ReactNode;
11
+ }): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -0,0 +1,67 @@
1
+ import { jsx as _jsx } from "@emotion/react/jsx-runtime";
2
+ /** @jsxImportSource @emotion/react */
3
+ import { motion, useReducedMotion } from 'motion/react';
4
+ import { useEffect, useRef, useState } from 'react';
5
+ /** How fast the text rolls, in px a second. */
6
+ const SPEED = 36;
7
+ /** How long it waits at each end, in seconds. */
8
+ const PAUSE = 1.4;
9
+ /**
10
+ * One line of text that fits the width it is given: when it is too long it rolls sideways, resting at
11
+ * each end, so all of it can be read; when it fits it just sits there. Whoever it is in decides the
12
+ * width (a `max-width` on it or on a parent), so a long title can never push its tile out of shape,
13
+ * whatever it holds (an emoji at the end included). Where the person asks their device for less
14
+ * motion it stays still and ends in an ellipsis.
15
+ */
16
+ export function MarqueeText({ children }) {
17
+ const outer = useRef(null);
18
+ const inner = useRef(null);
19
+ const [overflow, setOverflow] = useState(0);
20
+ const still = useReducedMotion();
21
+ useEffect(() => {
22
+ const frame = outer.current;
23
+ const text = inner.current;
24
+ if (!frame || !text) {
25
+ return;
26
+ }
27
+ const measure = () => setOverflow(Math.max(0, Math.ceil(text.scrollWidth - frame.clientWidth)));
28
+ measure();
29
+ if (typeof ResizeObserver === 'undefined') {
30
+ return;
31
+ }
32
+ // The frame changes with the room it has; the text with what it says.
33
+ const watch = new ResizeObserver(measure);
34
+ watch.observe(frame);
35
+ watch.observe(text);
36
+ return () => watch.disconnect();
37
+ }, []);
38
+ const rolling = overflow > 0 && !still;
39
+ const seconds = overflow / SPEED;
40
+ return (_jsx("span", { ref: outer, css: {
41
+ display: 'block',
42
+ // A line height of its own, not the font's: an emoji comes from another font with a taller
43
+ // line, and would make a row with one in its title taller than the rest.
44
+ lineHeight: 1.3,
45
+ minWidth: 0,
46
+ maxWidth: '100%',
47
+ overflow: 'hidden',
48
+ whiteSpace: 'nowrap',
49
+ textOverflow: rolling ? 'clip' : 'ellipsis',
50
+ }, children: _jsx(motion.span, { ref: inner, css: { display: 'inline-block' }, ...(rolling
51
+ ? {
52
+ animate: { x: [0, 0, -overflow, -overflow, 0] },
53
+ transition: {
54
+ duration: 2 * seconds + 2 * PAUSE,
55
+ times: [
56
+ 0,
57
+ PAUSE / (2 * seconds + 2 * PAUSE),
58
+ (PAUSE + seconds) / (2 * seconds + 2 * PAUSE),
59
+ (2 * PAUSE + seconds) / (2 * seconds + 2 * PAUSE),
60
+ 1,
61
+ ],
62
+ ease: 'linear',
63
+ repeat: Infinity,
64
+ },
65
+ }
66
+ : {}), children: children }) }));
67
+ }
@@ -0,0 +1,11 @@
1
+ import type { ReactNode } from 'react';
2
+ /**
3
+ * Fades its content in as it appears, rising a few pixels, a little later for each one after the
4
+ * first (the first handful only, so a long list does not take long to arrive). It only moves what is
5
+ * drawn inside it, never the room it takes, so nothing around it shifts. Where the person asks their
6
+ * device for less motion it just appears.
7
+ */
8
+ export declare function Reveal({ index, children }: {
9
+ index?: number;
10
+ children: ReactNode;
11
+ }): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -0,0 +1,19 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "@emotion/react/jsx-runtime";
2
+ /** @jsxImportSource @emotion/react */
3
+ import { motion, useReducedMotion } from 'motion/react';
4
+ /** How much later each following item starts, in seconds, and how many are staggered at all. */
5
+ const STAGGER = 0.03;
6
+ const STAGGERED = 8;
7
+ /**
8
+ * Fades its content in as it appears, rising a few pixels, a little later for each one after the
9
+ * first (the first handful only, so a long list does not take long to arrive). It only moves what is
10
+ * drawn inside it, never the room it takes, so nothing around it shifts. Where the person asks their
11
+ * device for less motion it just appears.
12
+ */
13
+ export function Reveal({ index = 0, children }) {
14
+ const still = useReducedMotion();
15
+ if (still) {
16
+ return _jsx(_Fragment, { children: children });
17
+ }
18
+ return (_jsx(motion.div, { initial: { opacity: 0, y: 8 }, animate: { opacity: 1, y: 0 }, transition: { duration: 0.22, ease: 'easeOut', delay: Math.min(index, STAGGERED) * STAGGER }, children: children }));
19
+ }
@@ -2,18 +2,22 @@ export interface SeriesSample {
2
2
  /** ISO 8601. */
3
3
  timestamp: string;
4
4
  value: number;
5
+ /** Extra lines the tooltip shows under the value, e.g. the sky and the wind at that hour. */
6
+ notes?: string[];
5
7
  }
6
8
  /** The window a chart covers; `custom` is a from/to pair the caller has already applied to the samples. */
7
9
  export type SeriesRange = '1h' | '1d' | '1w' | '1m' | 'custom';
8
10
  export declare function formatTick(range: SeriesRange, iso: string): string;
9
11
  /** A bare sparkline, or — when `expanded` — the full chart with axes, grid and a tooltip. Shared by the power and sensor drawers. */
10
- export declare function SeriesChart({ samples, range, expanded, unit, fitDomain, }: {
12
+ export declare function SeriesChart({ samples, range, expanded, unit, fitDomain, height, }: {
11
13
  samples: SeriesSample[];
12
14
  range: SeriesRange;
13
15
  expanded: boolean;
14
16
  unit: string;
15
17
  /** Fit the y axis to the data instead of starting at 0 — right for temperature/humidity, wrong for power. */
16
18
  fitDomain?: boolean;
19
+ /** The chart's height in px. Default 48 for the sparkline, 220 when expanded. */
20
+ height?: number;
17
21
  }): import("@emotion/react/jsx-runtime").JSX.Element;
18
22
  export declare function RangeSwitcher<T extends string>({ range, options, onChange, }: {
19
23
  range: T;
@@ -1,5 +1,4 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
2
- /** @jsxImportSource @emotion/react */
3
2
  import { Box, Flex, Typography, useColorByKey } from 'e-prim';
4
3
  import { useTypographySize } from '../theme/use-typography.js';
5
4
  import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis, } from 'recharts';
@@ -13,13 +12,19 @@ export function formatTick(range, iso) {
13
12
  }
14
13
  return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
15
14
  }
15
+ /** The grid's dashed lines, but not one along the chart's bottom edge: that is the solid axis line. */
16
+ const dashedExceptBottom = (stroke) => ({ x1, x2, y1, offset, }) => y1 >= offset.top + offset.height - 1 ? (_jsx("g", {})) : (_jsx("line", { x1: x1, x2: x2, y1: y1, y2: y1, stroke: stroke, strokeOpacity: 0.3, strokeDasharray: "3 3" }));
16
17
  /** A bare sparkline, or — when `expanded` — the full chart with axes, grid and a tooltip. Shared by the power and sensor drawers. */
17
- export function SeriesChart({ samples, range, expanded, unit, fitDomain = false, }) {
18
+ export function SeriesChart({ samples, range, expanded, unit, fitDomain = false, height, }) {
18
19
  const accent = useColorByKey('accent') ?? '';
19
20
  const textMuted = useColorByKey('textMuted') ?? '';
20
21
  const line = useColorByKey('line') ?? '';
21
22
  const tickSize = useTypographySize('secondary');
22
- return (_jsx(Box, { height: expanded ? 220 : 48, mt: 3, children: _jsx(ResponsiveContainer, { width: "100%", height: "100%", children: _jsxs(AreaChart, { data: samples, margin: { top: 4, right: 0, bottom: 0, left: 0 }, children: [expanded ? _jsx(CartesianGrid, { stroke: line, strokeDasharray: "3 3", vertical: false }) : null, expanded ? (_jsx(XAxis, { dataKey: "timestamp", tickFormatter: (value) => formatTick(range, value), tick: { fontSize: tickSize, fill: textMuted }, axisLine: false, tickLine: false, minTickGap: 24 })) : null, expanded ? (_jsx(YAxis, { width: 36, ...(fitDomain ? { domain: ['auto', 'auto'] } : {}), tick: { fontSize: tickSize, fill: textMuted }, axisLine: false, tickLine: false })) : null, _jsx(Tooltip, { content: _jsx(ChartTooltip, { unit: unit }), ...(expanded
23
+ return (_jsx(Box, { height: height ?? (expanded ? 220 : 48), mt: 3, children: _jsx(ResponsiveContainer, { width: "100%", height: "100%", children: _jsxs(AreaChart, { data: samples, margin: { top: 4, right: 0, bottom: 0, left: 0 }, children: [expanded ? (_jsx(CartesianGrid, { stroke: line, strokeOpacity: 0.3, strokeDasharray: "3 3", vertical: false,
24
+ // The chart's bottom edge is the solid axis line, so no dashed line goes under it.
25
+ horizontal: dashedExceptBottom(line) })) : null, expanded ? (_jsx(XAxis, { dataKey: "timestamp", tickFormatter: (value) => formatTick(range, value), tick: { fontSize: tickSize, fill: textMuted },
26
+ // The bottom of the chart is a solid line; the grid above it is dashed and lighter.
27
+ axisLine: { stroke: line, strokeOpacity: 0.5, strokeWidth: 0.5 }, tickLine: false, minTickGap: 24 })) : null, expanded ? (_jsx(YAxis, { width: 36, ...(fitDomain ? { domain: ['auto', 'auto'] } : {}), tick: { fontSize: tickSize, fill: textMuted }, axisLine: false, tickLine: false })) : null, _jsx(Tooltip, { content: _jsx(ChartTooltip, { unit: unit }), ...(expanded
23
28
  ? {}
24
29
  : { allowEscapeViewBox: { x: false, y: true }, position: { y: -52 } }) }), _jsx(Area, { type: "monotone", dataKey: "value", stroke: accent, fill: accent, fillOpacity: 0.2, strokeWidth: 2, dot: false, isAnimationActive: false })] }) }) }));
25
30
  }
@@ -42,5 +47,5 @@ function ChartTooltip({ active, payload, unit, }) {
42
47
  day: 'numeric',
43
48
  hour: '2-digit',
44
49
  minute: '2-digit',
45
- }) }), _jsxs(Typography, { as: "span", variant: "bodyStrong", children: [Math.round(sample.value * 10) / 10, " ", unit] })] }) }));
50
+ }) }), _jsxs(Typography, { as: "span", variant: "bodyStrong", children: [Math.round(sample.value * 10) / 10, " ", unit] }), sample.notes?.map((note) => (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: note }, note)))] }) }));
46
51
  }
@@ -0,0 +1,11 @@
1
+ /** @jsxImportSource @emotion/react */
2
+ import { type BoxProps } from 'e-prim';
3
+ /** A placeholder block for something that is on its way, in the size it will have, so what is around
4
+ * it does not move when the real thing arrives. It pulses gently, and holds still where the person
5
+ * asks their device for less motion. Decorative: say what is loading around it, with `aria-busy`. */
6
+ export declare function Skeleton({ width, height, radius, }: {
7
+ /** px, or any CSS length. */
8
+ width: number | string;
9
+ height: number | string;
10
+ radius?: BoxProps['radius'];
11
+ }): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx } from "@emotion/react/jsx-runtime";
2
+ /** @jsxImportSource @emotion/react */
3
+ import { Box } from 'e-prim';
4
+ import { motion, useReducedMotion } from 'motion/react';
5
+ /** A placeholder block for something that is on its way, in the size it will have, so what is around
6
+ * it does not move when the real thing arrives. It pulses gently, and holds still where the person
7
+ * asks their device for less motion. Decorative: say what is loading around it, with `aria-busy`. */
8
+ export function Skeleton({ width, height, radius = 'small', }) {
9
+ const still = useReducedMotion();
10
+ return (_jsx(Box, { "aria-hidden": "true", background: "surfaceRaised", radius: radius, css: { width, height, flex: 'none', overflow: 'hidden' }, children: _jsx(motion.span, { css: { display: 'block', width: '100%', height: '100%', background: 'currentColor' }, initial: { opacity: 0.1 }, ...(still
11
+ ? {}
12
+ : {
13
+ animate: { opacity: [0.04, 0.14, 0.04] },
14
+ transition: { duration: 1.4, repeat: Infinity, ease: 'easeInOut' },
15
+ }) }) }));
16
+ }
@@ -1,3 +1,5 @@
1
+ /** @jsxImportSource @emotion/react */
2
+ import type { EntityRef } from '@hashsome/core';
1
3
  import { type ReactNode } from 'react';
2
4
  import type { IconName } from '../icon-data.ts';
3
5
  import { type EntityStatus } from '../status.ts';
@@ -44,9 +46,15 @@ export interface TileProps {
44
46
  * type — whichever component has a way to fetch it just passes it through.
45
47
  */
46
48
  history?: LogbookEntry[];
49
+ /**
50
+ * An entity whose recent activity to fetch from the backend when the drawer opens, for the same
51
+ * History section (Home Assistant's logbook). `history`, when given, is used instead. A backend
52
+ * that keeps none shows no section.
53
+ */
54
+ logbook?: EntityRef;
47
55
  }
48
56
  /** Base pill used by all entity tiles. Tap to press; hold (with `detail` set) to open the drawer. */
49
- export declare function Tile({ label, icon, secondary, status, active, feedback, onPress, fill, onFillChange, trailing, overlay, title, detail, kind, energy, history, }: TileProps): import("@emotion/react/jsx-runtime").JSX.Element;
57
+ export declare function Tile({ label, icon, secondary, status, active, feedback, onPress, fill, onFillChange, trailing, overlay, title, detail, kind, energy, history, logbook, }: TileProps): import("@emotion/react/jsx-runtime").JSX.Element;
50
58
  export declare function IconButton({ icon, label, onClick, disabled, active, pressed, primary, size, glyph, color, feedback, }: {
51
59
  icon: IconName;
52
60
  label: string;