@hashsome/ui 0.4.0 → 0.6.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 (59) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/debug.d.ts +27 -0
  3. package/dist/debug.js +95 -0
  4. package/dist/entities/artwork-ring.d.ts +4 -2
  5. package/dist/entities/artwork-ring.js +79 -51
  6. package/dist/entities/light-tile.d.ts +3 -1
  7. package/dist/entities/light-tile.js +29 -20
  8. package/dist/entities/media-browser.d.ts +3 -1
  9. package/dist/entities/media-browser.js +41 -15
  10. package/dist/entities/media-player-bar.d.ts +9 -1
  11. package/dist/entities/media-player-bar.js +122 -12
  12. package/dist/entities/media-player-column.d.ts +5 -2
  13. package/dist/entities/media-player-column.js +33 -5
  14. package/dist/entities/media-player-full.d.ts +12 -2
  15. package/dist/entities/media-player-full.js +45 -3
  16. package/dist/entities/media-seek.d.ts +4 -1
  17. package/dist/entities/media-seek.js +22 -3
  18. package/dist/entities/nav-dock.js +3 -3
  19. package/dist/entities/now-playing.d.ts +3 -1
  20. package/dist/entities/now-playing.js +8 -3
  21. package/dist/entities/seek-line.js +12 -0
  22. package/dist/entities/top-bar.d.ts +21 -0
  23. package/dist/entities/top-bar.js +19 -10
  24. package/dist/gallery/gallery.js +18 -3
  25. package/dist/gallery/props-data.js +187 -3
  26. package/dist/index.d.ts +3 -0
  27. package/dist/index.js +3 -0
  28. package/dist/layout/animated-outlet.d.ts +14 -0
  29. package/dist/layout/animated-outlet.js +44 -0
  30. package/dist/layout/board.d.ts +26 -0
  31. package/dist/layout/board.js +85 -0
  32. package/dist/layout/debug-menu.d.ts +11 -0
  33. package/dist/layout/debug-menu.js +99 -0
  34. package/dist/layout/detail-provider.d.ts +4 -2
  35. package/dist/layout/detail-provider.js +2 -2
  36. package/dist/layout/drawer-controls.js +36 -8
  37. package/dist/layout/entity-drawer.js +1 -1
  38. package/dist/layout/grid-overlay.d.ts +6 -0
  39. package/dist/layout/grid-overlay.js +143 -0
  40. package/dist/layout/hold-progress.d.ts +8 -2
  41. package/dist/layout/hold-progress.js +20 -4
  42. package/dist/layout/page.d.ts +7 -1
  43. package/dist/layout/page.js +43 -6
  44. package/dist/layout/room-header.js +1 -1
  45. package/dist/layout/tile.js +14 -10
  46. package/dist/layout/top-row.d.ts +9 -0
  47. package/dist/layout/top-row.js +10 -0
  48. package/dist/layout/use-drawer.d.ts +3 -2
  49. package/dist/layout/use-drawer.js +5 -3
  50. package/dist/provider.d.ts +15 -4
  51. package/dist/provider.js +144 -13
  52. package/dist/theme/global-styles.js +3 -0
  53. package/dist/theme/grid.d.ts +30 -0
  54. package/dist/theme/grid.js +38 -0
  55. package/dist/theme/index.js +2 -1
  56. package/dist/theme/schedule.d.ts +33 -0
  57. package/dist/theme/schedule.js +44 -0
  58. package/dist/theme/tokens.d.ts +2 -0
  59. package/package.json +2 -2
@@ -5,9 +5,11 @@ import { useEffect, useMemo, useRef, useState, } from 'react';
5
5
  import { Icon } from '../icon.js';
6
6
  import { RoundButton } from './round-button.js';
7
7
  import { statusLabels } from '../status.js';
8
+ import { BADGE } from '../theme/grid.js';
8
9
  import { useDrawer } from './use-drawer.js';
9
10
  import { EnergyChart } from './energy-chart.js';
10
11
  import { HistorySection } from './history-section.js';
12
+ import { HoldProgress } from './hold-progress.js';
11
13
  import { LogbookHistory } from './logbook-history.js';
12
14
  const DRAG_THRESHOLD = 8;
13
15
  const KEY_STEP = 0.05;
@@ -181,19 +183,21 @@ export function Tile({ label, icon, secondary, status = 'ready', active = false,
181
183
  // the unavailable/unknown/missing/loading statuses below).
182
184
  const dimColorKey = ready ? 'line' : 'textMuted';
183
185
  const cardOpacity = !ready || feedback === 'pending' ? 0.7 : 1;
184
- const iconBadge = icon ? (_jsx(Flex, { as: motion.span, align: "center", justify: "center", position: "relative", radius: "full", width: 32, height: 32, animate: { backgroundColor: iconBg, color: iconColor }, transition: COLOR_TRANSITION, css: { flex: 'none' }, children: _jsx(Icon, { name: icon, size: 16 }) })) : null;
185
- return (_jsxs(Flex, { as: motion.div, radius: "card", position: "relative", align: "center", overflow: "hidden", minWidth: 0, "data-status": status, "data-active": accented, "data-solid-accent": solidAccent, "data-fill": fill !== undefined, "data-fill-visible": showFill, "data-overlay": overlay !== undefined, "data-pending": feedback === 'pending', "data-feedback": feedback === 'pending' ? undefined : feedback, animate: { backgroundColor: cardBg, color: cardColor, opacity: cardOpacity }, transition: COLOR_TRANSITION, css: ({ palette }) => feedback === 'error' ? { outline: `2px solid ${palette.danger}`, outlineOffset: -2 } : null, children: [hasFill ? (_jsx(Box, { as: motion.span, position: "absolute", background: "accent",
186
+ const iconBadge = icon ? (_jsx(Flex, { as: motion.span, align: "center", justify: "center", position: "relative", radius: "full", width: 32, height: 32, initial: false, animate: { backgroundColor: iconBg, color: iconColor }, transition: COLOR_TRANSITION, css: { flex: 'none' }, children: _jsx(Icon, { name: icon, size: 16 }) })) : null;
187
+ return (_jsxs(Flex, { as: motion.div, radius: "card", position: "relative", align: "center", overflow: "hidden", minWidth: 0, "data-grid-card": true, "data-status": status, "data-active": accented, "data-solid-accent": solidAccent, "data-fill": fill !== undefined, "data-fill-visible": showFill, "data-overlay": overlay !== undefined, "data-pending": feedback === 'pending', "data-feedback": feedback === 'pending' ? undefined : feedback,
188
+ // A card that mounts (a page opened) is drawn as it is, not animated from nothing.
189
+ initial: false, animate: { backgroundColor: cardBg, color: cardColor, opacity: cardOpacity }, transition: COLOR_TRANSITION, css: ({ palette, spacing }) => ({
190
+ // The badge between its padding, written down rather than left to the content, so a tile is a
191
+ // whole number of grid modules (12, in the comfortable density) whatever is in it.
192
+ minHeight: `calc(${spacing(4)} + ${BADGE}px)`,
193
+ ...(feedback === 'error'
194
+ ? { outline: `2px solid ${palette.danger}`, outlineOffset: -2 }
195
+ : {}),
196
+ }), children: [hasFill ? (_jsx(Box, { as: motion.span, position: "absolute", background: "accent",
186
197
  // Spans the whole card, including behind `trailing` (e.g. a color-capable light's
187
198
  // picker button) — it's a sibling of the button and `trailing`, not nested inside the
188
199
  // button, specifically so its width isn't capped at the button's own narrower bounds.
189
- animate: { width: `${(shownFill ?? 0) * 100}%`, opacity: overlaid ? 0 : shownFill }, transition: drag !== null ? { duration: 0 } : COLOR_TRANSITION, css: { inset: '0 auto 0 0', pointerEvents: 'none' } })) : null, holdable && holding ? (_jsx(Box, { as: motion.span, position: "absolute", initial: { width: '0%' }, animate: { width: '100%' }, transition: { duration: HOLD_MS / 1000, ease: 'linear' }, height: 2, css: {
190
- inset: 'auto auto 0 0',
191
- pointerEvents: 'none',
192
- // Visible against either background: a light wash normally, a dark wash on an
193
- // accent-colored (non-dimmable-active) card, where a light or accent bar would
194
- // disappear or clash.
195
- backgroundColor: solidAccent ? 'rgba(0, 0, 0, 0.35)' : 'rgba(255, 255, 255, 0.55)',
196
- } }, "hold-progress")) : null, overlay ? (_jsxs(Flex, { align: "center", gap: 2.5, minWidth: 0, grow: 1, position: "relative", pt: 2, pr: 2, pb: 2, pl: 2, css: { alignSelf: 'stretch' }, children: [iconBadge, _jsx(Flex, { grow: 1, minWidth: 0, justify: "center", align: "center", children: overlay({ openDetail: openDrawer }) })] })) : (_jsxs(Flex, { as: "button", type: "button", disabled: !ready || (!onPress && !adjustable && !holdable), onClick: onClick, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onPointerCancel: onPointerCancel, onKeyDown: onKeyDown, title: title, "aria-label": label, align: "center", gap: 2.5, minWidth: 0, grow: 1, position: "relative", cursor: ready ? 'pointer' : 'default', pt: 2, pr: 3.5, pb: 2, pl: 2, css: {
200
+ initial: false, animate: { width: `${(shownFill ?? 0) * 100}%`, opacity: overlaid ? 0 : shownFill }, transition: drag !== null ? { duration: 0 } : COLOR_TRANSITION, css: { inset: '0 auto 0 0', pointerEvents: 'none' } })) : null, holdable && holding ? (_jsx(HoldProgress, { edge: "bottom", color: solidAccent ? 'rgba(0, 0, 0, 0.35)' : 'rgba(255, 255, 255, 0.55)' })) : null, overlay ? (_jsxs(Flex, { align: "center", gap: 2.5, minWidth: 0, grow: 1, position: "relative", pt: 2, pr: 2, pb: 2, pl: 2, css: { alignSelf: 'stretch' }, children: [iconBadge, _jsx(Flex, { grow: 1, minWidth: 0, justify: "center", align: "center", children: overlay({ openDetail: openDrawer }) })] })) : (_jsxs(Flex, { as: "button", type: "button", disabled: !ready || (!onPress && !adjustable && !holdable), onClick: onClick, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onPointerCancel: onPointerCancel, onKeyDown: onKeyDown, title: title, "aria-label": label, align: "center", gap: 2.5, minWidth: 0, grow: 1, position: "relative", cursor: ready ? 'pointer' : 'default', pt: 2, pr: 3.5, pb: 2, pl: 2, css: {
197
201
  alignSelf: 'stretch',
198
202
  background: 'none',
199
203
  textAlign: 'left',
@@ -0,0 +1,9 @@
1
+ import type { ReactNode } from 'react';
2
+ export interface TopRowProps {
3
+ /** What goes in the row: the date, the weather, the clock. */
4
+ children: ReactNode;
5
+ }
6
+ /** The row along the top of a page you build yourself instead of with `TopBar`: its pieces (`DateChip`,
7
+ * `WeatherChip`, `Clock`, `SystemStatus`) in a line at the right, in the height the grid gives the top
8
+ * row, with no padding of its own. */
9
+ export declare function TopRow({ children }: TopRowProps): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx } from "@emotion/react/jsx-runtime";
2
+ /** @jsxImportSource @emotion/react */
3
+ import { Flex } from 'e-prim';
4
+ import { TOP_ROW } from '../theme/grid.js';
5
+ /** The row along the top of a page you build yourself instead of with `TopBar`: its pieces (`DateChip`,
6
+ * `WeatherChip`, `Clock`, `SystemStatus`) in a line at the right, in the height the grid gives the top
7
+ * row, with no padding of its own. */
8
+ export function TopRow({ children }) {
9
+ return (_jsx(Flex, { align: "center", justify: "flex-end", gap: 3, css: { minHeight: TOP_ROW }, children: children }));
10
+ }
@@ -17,6 +17,7 @@ export declare function useDrawer({ icon, label, kind, body, }: {
17
17
  }): {
18
18
  open: () => void;
19
19
  openExpanded: () => void;
20
+ openFull: () => void;
20
21
  isOpen: boolean;
21
22
  };
22
23
  /** `useDrawer` for an owner that must not itself subscribe to the drawer's context. Re-pushing the
@@ -28,6 +29,6 @@ export declare function DrawerTrigger({ icon, label, kind, body, children, }: {
28
29
  label: string;
29
30
  kind?: string | undefined;
30
31
  body: ReactNode;
31
- /** Gets `open` (the side panel) and `openExpanded` (full size). */
32
- children: (open: () => void, openExpanded: () => void) => ReactNode;
32
+ /** Gets `open` (the side panel), `openExpanded` (full size, which can be collapsed) and `openFull` (full size, with no collapse button). */
33
+ children: (open: () => void, openExpanded: () => void, openFull: () => void) => ReactNode;
33
34
  }): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -22,13 +22,15 @@ export function useDrawer({ icon, label, kind, body, }) {
22
22
  }, [isOpen, id, icon, label, kind, body, updateDetail]);
23
23
  const open = () => openDetail(id, _jsx(DrawerHeader, { icon: icon, label: label, kind: kind }), body);
24
24
  const openExpanded = () => openDetail(id, _jsx(DrawerHeader, { icon: icon, label: label, kind: kind }), body, true);
25
- return { open, openExpanded, isOpen };
25
+ // Full size and kept there: for content that is an overlay of its own, with nothing to collapse to.
26
+ const openFull = () => openDetail(id, _jsx(DrawerHeader, { icon: icon, label: label, kind: kind }), body, true, true);
27
+ return { open, openExpanded, openFull, isOpen };
26
28
  }
27
29
  /** `useDrawer` for an owner that must not itself subscribe to the drawer's context. Re-pushing the
28
30
  * body on every context change re-renders whoever called the hook; if that is the same component
29
31
  * that built `body`, it makes a fresh element each time and loops. Hosting the hook in this child
30
32
  * keeps `body`'s identity tied to the parent's own renders (entity updates), not the drawer's. */
31
33
  export function DrawerTrigger({ icon, label, kind, body, children, }) {
32
- const { open, openExpanded } = useDrawer({ icon, label, kind, body });
33
- return _jsx(_Fragment, { children: children(open, openExpanded) });
34
+ const { open, openExpanded, openFull } = useDrawer({ icon, label, kind, body });
35
+ return _jsx(_Fragment, { children: children(open, openExpanded, openFull) });
34
36
  }
@@ -1,8 +1,13 @@
1
- import { type Client, type RemoteClientOptions } from '@hashsome/core';
1
+ import { type Client, type EntityRef, type RemoteClientOptions } from '@hashsome/core';
2
2
  import { type ReactNode } from 'react';
3
3
  import { type Density } from './theme/density.ts';
4
4
  import { type ThemeOverrides } from './theme/overrides.ts';
5
- export type ThemeMode = 'light' | 'dark' | 'system';
5
+ import { type ThemeSchedule } from './theme/schedule.ts';
6
+ export type { ThemeSchedule };
7
+ /** `reduced` turns every animation off, for a slow display; `full` keeps them; `auto` (the default) leaves things as they are. */
8
+ export type MotionPreference = 'auto' | 'full' | 'reduced';
9
+ /** `'system'` follows the display's own light/dark preference; a `ThemeSchedule` changes with the time of day. */
10
+ export type ThemeMode = 'light' | 'dark' | 'system' | ThemeSchedule;
6
11
  export interface HashsomeProviderProps {
7
12
  /** Runtime WebSocket URL. Defaults to `/ws` on the current origin. */
8
13
  url?: string;
@@ -10,14 +15,20 @@ export interface HashsomeProviderProps {
10
15
  client?: Client;
11
16
  /** Options for the default `RemoteClient` (reconnect timing, a custom socket factory). Ignored when `client` is given. */
12
17
  clientOptions?: Omit<RemoteClientOptions, 'url'>;
13
- /** `'system'` follows the browser/OS preference and updates live. Default `'dark'`. A `useThemeToggle()` caller (e.g. `NavRail`'s dev toggle) can still override this at runtime. */
18
+ /** `'light'`, `'dark'`, `'system'` (follows the browser/OS preference and updates live), or a schedule: `{ dark: { from: '19:00', to: '07:00' } }` is dark between those times on the display's clock, `{ sun: 'ha:sun.sun' }` is dark while that entity, a `daylight` sensor (`on` while the sun is up; the Home Assistant integration makes one of `sun.sun`), is `off`. Default `'dark'`. A schedule and the document shell's first paint use `'system'` until the time or the entity is known. A `useThemeToggle()` caller (e.g. `NavRail`'s dev toggle) can still override this at runtime. Pass a constant defined outside the component. */
14
19
  theme?: ThemeMode;
15
20
  /** Any Google Fonts family name (e.g. `'Inter'`, `'Roboto'`, `'Poppins'`), loaded dynamically. Default `'Inter'`. */
16
21
  font?: string;
17
22
  /** `'compact'` for small square displays: tighter spacing, shorter tiles, smaller icon circles. Default `'comfortable'`. */
18
23
  density?: Density;
24
+ /** `'reduced'` turns animations and transitions off, for a slow display, `'full'` keeps them and `'auto'` (the default) leaves things as they are. A device can choose for itself with `?motion=reduced` (or `full`) on the address it opens, which wins over this prop. Nothing is kept: it holds while the app is open, moving between its pages, and a reload without the parameter goes back to this prop. */
25
+ motion?: MotionPreference;
19
26
  /** Partial changes to the built-in theme — colors per light/dark, radii, typography, spacing, density sizes. Pass a constant defined outside the component: a new object each render rebuilds the theme each render. */
20
27
  overrides?: ThemeOverrides;
28
+ /** Shows the debug menu: a floating button at the bottom left whose popover shows the module grid, makes the page fullscreen and changes the theme, on the device, kept there. Default: on when `HASHSOME_DEBUG=1` (or `true`) was in the environment of `hashsome dev` or `build`. */
29
+ debug?: boolean;
30
+ /** The daylight sensor (`on` while the sun is up) the debug menu's Sun theme follows, when `theme` is not a sun schedule already (`theme={{ sun: … }}` names one). Without either, the menu has no Sun choice: Hashsome does not know which entity is the sun. */
31
+ sun?: EntityRef;
21
32
  /** The app. */
22
33
  children: ReactNode;
23
34
  }
@@ -33,5 +44,5 @@ export interface ThemeModeState {
33
44
  * Must be rendered inside `<HashsomeProvider>`. */
34
45
  export declare function useThemeToggle(): ThemeModeState;
35
46
  /** Connects the tree to the runtime proxy. Render only on the client. */
36
- export declare function HashsomeProvider({ url, client, clientOptions, theme, font, density, overrides, children, }: HashsomeProviderProps): import("react").JSX.Element;
47
+ export declare function HashsomeProvider({ url, client, clientOptions, theme, motion, font, density, overrides, debug, sun, children, }: HashsomeProviderProps): import("react").JSX.Element;
37
48
  export declare function useClient(): Client;
package/dist/provider.js CHANGED
@@ -1,13 +1,17 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { RemoteClient } from '@hashsome/core';
2
+ import { RemoteClient, } from '@hashsome/core';
3
3
  import { Global, ThemeProvider as EmotionThemeProvider } from '@emotion/react';
4
4
  import { ThemeProvider } from 'e-prim';
5
- import { createContext, useContext, useEffect, useMemo, useState } from 'react';
5
+ import { MotionGlobalConfig } from 'motion/react';
6
+ import { createContext, useCallback, useContext, useEffect, useMemo, useState, useSyncExternalStore, } from 'react';
7
+ import { DebugContext, debugFromEnv, useDebugState, useFullscreenKept, } from './debug.js';
8
+ import { DebugMenu } from './layout/debug-menu.js';
6
9
  import { DetailProvider } from './layout/detail-provider.js';
7
10
  import { EntityDrawer } from './layout/entity-drawer.js';
8
11
  import { UNITS_PER_SPACE } from './theme/density.js';
9
12
  import { globalStyles } from './theme/global-styles.js';
10
13
  import { applyThemeOverrides, densityTokens } from './theme/overrides.js';
14
+ import { isDarkAt, msUntilSwitch, sunIsDown } from './theme/schedule.js';
11
15
  import { DEFAULT_FONT, darkTheme, googleFontHref, lightTheme, withFontFamily, } from './theme/index.js';
12
16
  const HashsomeContext = createContext(null);
13
17
  /** Loads a Google Fonts family at runtime via a single `<link>` this hook owns and reuses (keyed
@@ -49,29 +53,156 @@ export function useThemeToggle() {
49
53
  }
50
54
  return value;
51
55
  }
52
- /** Resolves `'system'` against the live OS/browser preference, updating if it changes while open —
53
- * a kiosk tablet left running overnight should follow a scheduled OS-level dark mode, for example.
54
- * `override` (set via `useThemeToggle().toggle()`) wins over both until the page reloads. */
55
- function useThemeMode(mode) {
56
- const [systemDark, setSystemDark] = useState(() => mode === 'system' && systemPrefersDark());
56
+ /** What the address asks for motion: `?motion=reduced` or `?motion=full`. Nothing is kept, and the
57
+ * app reads it once, when it starts, so moving between pages inside the app keeps it and a reload
58
+ * without it does not. `?motion=auto` (or anything else) leaves it to the prop. */
59
+ function motionFromAddress() {
60
+ try {
61
+ const asked = new URLSearchParams(window.location.search).get('motion');
62
+ return asked === 'reduced' || asked === 'full' ? asked : undefined;
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ }
68
+ /** Settles motion once, when the app starts, before anything renders: in `reduced` every animation
69
+ * motion would run is skipped, and a flag on the page lets the global styles switch off CSS
70
+ * transitions and animations too. */
71
+ function useMotionMode(preference) {
72
+ const [mode] = useState(() => {
73
+ const chosen = motionFromAddress() ?? preference;
74
+ MotionGlobalConfig.skipAnimations = chosen === 'reduced';
75
+ if (typeof document !== 'undefined') {
76
+ if (chosen === 'reduced') {
77
+ document.documentElement.dataset.motion = 'reduced';
78
+ }
79
+ else {
80
+ delete document.documentElement.dataset.motion;
81
+ }
82
+ }
83
+ return chosen;
84
+ });
85
+ return mode;
86
+ }
87
+ const SUN_KEY = 'hashsome:sun-down';
88
+ /** The last answer a sun entity gave, kept across page loads so a reload at night does not start light. */
89
+ function rememberedSun() {
90
+ try {
91
+ const stored = localStorage.getItem(SUN_KEY);
92
+ return stored === null ? undefined : stored === '1';
93
+ }
94
+ catch {
95
+ return undefined;
96
+ }
97
+ }
98
+ /** The sun entity a theme follows, if it is a sun schedule. */
99
+ function sunOf(mode) {
100
+ return typeof mode === 'object' && 'sun' in mode ? mode.sun : undefined;
101
+ }
102
+ /** The theme in effect: what the debug menu chose, if it chose, else what the project configured.
103
+ * The sun is the project's: the one its theme follows, or the `sun` it gave. Without one there is no
104
+ * sun to follow, so that choice (kept from a visit that had one) leaves the configured theme as it was. */
105
+ function chosenTheme(configured, choice, sun) {
106
+ if (choice === null) {
107
+ return configured;
108
+ }
109
+ if (choice === 'sun') {
110
+ return sun === undefined ? configured : { sun };
111
+ }
112
+ return choice;
113
+ }
114
+ /** Resolves the configured mode: `'system'` against the live OS/browser preference (updating if it
115
+ * changes while open — a kiosk tablet left running overnight should follow a scheduled OS-level dark
116
+ * mode, for example), a time range against the clock (re-checked at each boundary and whenever the
117
+ * page is shown again, since a sleeping tablet misses timers) and a sun entity against what it says.
118
+ * `override` (set via `useThemeToggle().toggle()`) wins over all of them until the page reloads. */
119
+ function useThemeMode(mode, client) {
120
+ const range = typeof mode === 'object' && 'dark' in mode ? mode.dark : undefined;
121
+ const from = range?.from;
122
+ const to = range?.to;
123
+ const sunRef = typeof mode === 'object' && 'sun' in mode ? mode.sun : undefined;
124
+ const followsSystem = mode === 'system' || sunRef !== undefined;
125
+ const [systemDark, setSystemDark] = useState(() => followsSystem && systemPrefersDark());
57
126
  const [override, setOverride] = useState(null);
127
+ const [now, setNow] = useState(() => new Date());
58
128
  useEffect(() => {
59
- if (mode !== 'system' || typeof window.matchMedia !== 'function') {
129
+ if (!followsSystem || typeof window.matchMedia !== 'function') {
60
130
  return;
61
131
  }
62
132
  const query = window.matchMedia('(prefers-color-scheme: dark)');
63
133
  const onChange = () => setSystemDark(query.matches);
64
134
  query.addEventListener('change', onChange);
65
135
  return () => query.removeEventListener('change', onChange);
66
- }, [mode]);
67
- const configured = mode === 'light' ? 'light' : mode === 'dark' ? 'dark' : systemDark ? 'dark' : 'light';
136
+ }, [followsSystem]);
137
+ useEffect(() => {
138
+ if (from === undefined || to === undefined) {
139
+ return;
140
+ }
141
+ const bounds = { from, to };
142
+ let timer;
143
+ const arm = () => {
144
+ clearTimeout(timer);
145
+ const wait = msUntilSwitch(bounds, new Date());
146
+ if (Number.isFinite(wait)) {
147
+ // A moment past the boundary, so the clock has certainly crossed it.
148
+ timer = setTimeout(refresh, wait + 500);
149
+ }
150
+ };
151
+ const refresh = () => {
152
+ setNow(new Date());
153
+ arm();
154
+ };
155
+ const onVisible = () => document.visibilityState === 'visible' && refresh();
156
+ refresh();
157
+ document.addEventListener('visibilitychange', onVisible);
158
+ return () => {
159
+ clearTimeout(timer);
160
+ document.removeEventListener('visibilitychange', onVisible);
161
+ };
162
+ }, [from, to]);
163
+ // Memoized, like every other `subscribe` given to `useSyncExternalStore` (see hooks.ts): a new
164
+ // function each render makes React resubscribe, which against a remote runtime is an unsubscribe and
165
+ // a subscribe whose reply is a new entity object, which renders again, without end.
166
+ const subscribeSun = useCallback((onChange) => (sunRef ? client.subscribe(sunRef, onChange) : () => undefined), [client, sunRef]);
167
+ const readSun = useCallback(() => (sunRef ? client.getEntity(sunRef) : undefined), [client, sunRef]);
168
+ const sun = useSyncExternalStore(subscribeSun, readSun, () => undefined);
169
+ const sunDown = sunIsDown(sun);
170
+ useEffect(() => {
171
+ if (sunDown === undefined) {
172
+ return;
173
+ }
174
+ try {
175
+ localStorage.setItem(SUN_KEY, sunDown ? '1' : '0');
176
+ }
177
+ catch {
178
+ // Without storage the next load just starts from the system preference again.
179
+ }
180
+ }, [sunDown]);
181
+ let configured;
182
+ if (mode === 'light' || mode === 'dark') {
183
+ configured = mode;
184
+ }
185
+ else if (range) {
186
+ configured = isDarkAt(range, now) ? 'dark' : 'light';
187
+ }
188
+ else if (sunRef) {
189
+ configured = (sunDown ?? rememberedSun() ?? systemDark) ? 'dark' : 'light';
190
+ }
191
+ else {
192
+ configured = systemDark ? 'dark' : 'light';
193
+ }
68
194
  const resolved = override ?? configured;
69
195
  return { resolved, toggle: () => setOverride(resolved === 'dark' ? 'light' : 'dark') };
70
196
  }
71
197
  /** Connects the tree to the runtime proxy. Render only on the client. */
72
- export function HashsomeProvider({ url, client, clientOptions, theme = 'dark', font = DEFAULT_FONT, density = 'comfortable', overrides, children, }) {
198
+ export function HashsomeProvider({ url, client, clientOptions, theme = 'dark', motion = 'auto', font = DEFAULT_FONT, density = 'comfortable', overrides, debug = debugFromEnv(), sun, children, }) {
73
199
  const instance = useMemo(() => client ?? new RemoteClient({ url: url ?? defaultUrl(), ...clientOptions }), [client, url, clientOptions]);
74
- const themeMode = useThemeMode(theme);
200
+ useMotionMode(motion);
201
+ const debugState = useDebugState();
202
+ useFullscreenKept(debugState.fullscreen, debugState.setFullscreen);
203
+ // The debug menu can choose a theme over the configured one; the sun is the project's own entity.
204
+ const sunEntity = sunOf(theme) ?? sun;
205
+ const themeMode = useThemeMode(chosenTheme(theme, debug ? debugState.themeChoice : null, sunEntity), instance);
75
206
  // Memoized: Emotion recomputes the merged theme (and every `css` prop) when the function changes.
76
207
  const withDensity = useMemo(() => (outer) => ({ ...outer, density: densityTokens(density, overrides) }), [density, overrides]);
77
208
  useGoogleFont(font);
@@ -84,7 +215,7 @@ export function HashsomeProvider({ url, client, clientOptions, theme = 'dark', f
84
215
  instance.connect();
85
216
  return () => instance.close();
86
217
  }, [instance]);
87
- return (_jsx(HashsomeContext.Provider, { value: instance, children: _jsx(ThemeModeContext.Provider, { value: themeMode, children: _jsx(ThemeProvider, { theme: resolvedTheme, children: _jsxs(EmotionThemeProvider, { theme: withDensity, children: [_jsx(Global, { styles: globalStyles }), _jsxs(DetailProvider, { children: [children, _jsx(EntityDrawer, {})] })] }) }) }) }));
218
+ return (_jsx(HashsomeContext.Provider, { value: instance, children: _jsx(ThemeModeContext.Provider, { value: themeMode, children: _jsx(ThemeProvider, { theme: resolvedTheme, children: _jsxs(EmotionThemeProvider, { theme: withDensity, children: [_jsx(Global, { styles: globalStyles }), _jsx(DebugContext.Provider, { value: debugState, children: _jsxs(DetailProvider, { children: [children, _jsx(EntityDrawer, {}), debug ? _jsx(DebugMenu, { configured: theme, sun: sunEntity }) : null] }) })] }) }) }) }));
88
219
  }
89
220
  export function useClient() {
90
221
  const client = useContext(HashsomeContext);
@@ -20,6 +20,9 @@ export function globalStyles({ palette, typography }) {
20
20
  color: palette.text,
21
21
  fontFamily: typography.default.fontFamily,
22
22
  },
23
+ // `?motion=reduced` (see `HashsomeProvider`): no CSS transitions or animations, except the ones
24
+ // that are a cue the user waits for (a hold's progress line, marked `data-keep-motion`).
25
+ 'html[data-motion="reduced"] *:not([data-keep-motion]), html[data-motion="reduced"] *:not([data-keep-motion])::before, html[data-motion="reduced"] *:not([data-keep-motion])::after': { transition: 'none !important', animation: 'none !important' },
23
26
  button: { font: 'inherit', color: 'inherit' },
24
27
  // Buttons and inputs start borderless; one that wants a border asks for it with the `border` prop.
25
28
  'button, input': { border: 0 },
@@ -0,0 +1,30 @@
1
+ /** The icon badge a tile starts with, in px: the one size in a tile that does not scale with density. */
2
+ export declare const BADGE = 32;
3
+ /** The height of a room header, in px. */
4
+ export declare const HEADER = 32;
5
+ /** The height of the top row, in px: the clock's line and the tallest things beside it (the switcher, the
6
+ * scene buttons), with no padding of its own, so its content sits as far from the page's edge as
7
+ * everything else does. Sizes that do not scale with density are multiples of 8, so they are whole
8
+ * modules in both (4px and 2.67px). */
9
+ export declare const TOP_ROW = 40;
10
+ /** The vertical grid every card sits on. The module is the theme's spacing unit (a third of a
11
+ * space: 4px in comfortable density, 2.67px in compact), so the gap between cards is always 3
12
+ * modules, and a tile and a header are whole numbers of them in either density. Sizes are in px;
13
+ * `pitch` is a card's height plus the gap that follows it, and a card spanning n pitches is n heights
14
+ * and n-1 gaps tall. */
15
+ export interface GridMetrics {
16
+ module: number;
17
+ gap: number;
18
+ tile: number;
19
+ header: number;
20
+ tilePitch: number;
21
+ headerPitch: number;
22
+ }
23
+ export declare function gridMetrics(space: number): GridMetrics;
24
+ /** Whether a box's top edge, measured from the grid's origin, or its height is off the module grid. Browsers round to the pixel, so a little under a pixel is still on it. */
25
+ export declare function offGrid(top: number, height: number, module: number): boolean;
26
+ /** How a height's leftover (what is under one module after as many whole ones as fit) is shared between the top and the bottom padding: half each, in whole pixels, the odd one to the bottom. `available` is the height inside the page's padding. */
27
+ export declare function centeringOffsets(available: number, module: number): {
28
+ top: number;
29
+ bottom: number;
30
+ };
@@ -0,0 +1,38 @@
1
+ import { UNITS_PER_SPACE } from './density.js';
2
+ /** The icon badge a tile starts with, in px: the one size in a tile that does not scale with density. */
3
+ export const BADGE = 32;
4
+ /** The height of a room header, in px. */
5
+ export const HEADER = 32;
6
+ /** The height of the top row, in px: the clock's line and the tallest things beside it (the switcher, the
7
+ * scene buttons), with no padding of its own, so its content sits as far from the page's edge as
8
+ * everything else does. Sizes that do not scale with density are multiples of 8, so they are whole
9
+ * modules in both (4px and 2.67px). */
10
+ export const TOP_ROW = 40;
11
+ export function gridMetrics(space) {
12
+ const module = space / UNITS_PER_SPACE;
13
+ // A tile is its badge between two spacing-unit-2 paddings.
14
+ const tile = BADGE + 4 * module;
15
+ return {
16
+ module,
17
+ gap: space,
18
+ tile,
19
+ header: HEADER,
20
+ tilePitch: tile + space,
21
+ headerPitch: HEADER + space,
22
+ };
23
+ }
24
+ /** Whether a box's top edge, measured from the grid's origin, or its height is off the module grid. Browsers round to the pixel, so a little under a pixel is still on it. */
25
+ export function offGrid(top, height, module) {
26
+ const tolerance = Math.max(0.5, module * 0.15);
27
+ const off = (value) => {
28
+ const rest = Math.abs(value) % module;
29
+ return Math.min(rest, module - rest) > tolerance;
30
+ };
31
+ return off(top) || off(height);
32
+ }
33
+ /** How a height's leftover (what is under one module after as many whole ones as fit) is shared between the top and the bottom padding: half each, in whole pixels, the odd one to the bottom. `available` is the height inside the page's padding. */
34
+ export function centeringOffsets(available, module) {
35
+ const leftover = available > 0 ? Math.floor(available % module) : 0;
36
+ const top = Math.floor(leftover / 2);
37
+ return { top, bottom: leftover - top };
38
+ }
@@ -38,6 +38,7 @@ const shared = {
38
38
  },
39
39
  shadow: {
40
40
  drawer: '0 20px 48px rgba(0, 0, 0, 0.5)',
41
+ dock: '0 6px 20px rgba(0, 0, 0, 0.16)',
41
42
  },
42
43
  radius: {
43
44
  full: '999px',
@@ -75,7 +76,7 @@ export const darkTheme = {
75
76
  surfaceRaised: '#211E26',
76
77
  text: '#F2EFEA',
77
78
  textMuted: '#96908C',
78
- accent: '#FF7A45',
79
+ accent: '#B85C38',
79
80
  accentText: '#FFFFFF',
80
81
  onAccent: '#1B1B1F',
81
82
  warm: '#E3B341',
@@ -0,0 +1,33 @@
1
+ import type { EntityRef } from '@hashsome/core';
2
+ /**
3
+ * When the dark theme is on, for a display that should change with the time of day:
4
+ * - `{ dark: { from: '19:00', to: '07:00' } }`: dark between two times of day on the display's own
5
+ * clock. `to` may be earlier than `from` (it is the next morning); the same time twice means never dark.
6
+ * - `{ sun: 'ha:sun.sun' }`: dark while a `daylight` sensor (the sun entity: `on` while the sun is up) is off.
7
+ */
8
+ export type ThemeSchedule = {
9
+ dark: {
10
+ from: string;
11
+ to: string;
12
+ };
13
+ } | {
14
+ sun: EntityRef;
15
+ };
16
+ /** Minutes after midnight for `HH:MM`. Throws on anything else, naming what was wrong. */
17
+ export declare function minutesOf(time: string, name: string): number;
18
+ /** Whether `now` is inside the dark hours. */
19
+ export declare function isDarkAt(range: {
20
+ from: string;
21
+ to: string;
22
+ }, now: Date): boolean;
23
+ /** How long until the theme next changes, in ms; `Infinity` when it never does. */
24
+ export declare function msUntilSwitch(range: {
25
+ from: string;
26
+ to: string;
27
+ }, now: Date): number;
28
+ /** What a `daylight` sensor says (`on` while the sun is up): dark once it is `off`. `undefined` for anything else (not loaded yet, unavailable, not that kind of sensor). */
29
+ export declare function sunIsDown(entity: {
30
+ kind: string;
31
+ value?: string;
32
+ measurement?: string;
33
+ } | null | undefined): boolean | undefined;
@@ -0,0 +1,44 @@
1
+ const TIME = /^([01]\d|2[0-3]):([0-5]\d)$/;
2
+ /** Minutes after midnight for `HH:MM`. Throws on anything else, naming what was wrong. */
3
+ export function minutesOf(time, name) {
4
+ const match = TIME.exec(time);
5
+ if (!match) {
6
+ throw new Error(`theme.dark.${name} must be a time like "19:00", not "${time}"`);
7
+ }
8
+ return Number(match[1]) * 60 + Number(match[2]);
9
+ }
10
+ const minutesNow = (now) => now.getHours() * 60 + now.getMinutes();
11
+ /** Whether `now` is inside the dark hours. */
12
+ export function isDarkAt(range, now) {
13
+ const from = minutesOf(range.from, 'from');
14
+ const to = minutesOf(range.to, 'to');
15
+ const at = minutesNow(now);
16
+ if (from === to) {
17
+ return false;
18
+ }
19
+ // Dark hours that run past midnight are everything except the daytime between `to` and `from`.
20
+ return from < to ? at >= from && at < to : at >= from || at < to;
21
+ }
22
+ const DAY_MS = 24 * 60 * 60 * 1000;
23
+ /** How long until the theme next changes, in ms; `Infinity` when it never does. */
24
+ export function msUntilSwitch(range, now) {
25
+ const from = minutesOf(range.from, 'from');
26
+ const to = minutesOf(range.to, 'to');
27
+ if (from === to) {
28
+ return Infinity;
29
+ }
30
+ const wait = (minutes) => {
31
+ const at = new Date(now);
32
+ at.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
33
+ const ms = at.getTime() - now.getTime();
34
+ return ms > 0 ? ms : ms + DAY_MS;
35
+ };
36
+ return Math.min(wait(from), wait(to));
37
+ }
38
+ /** What a `daylight` sensor says (`on` while the sun is up): dark once it is `off`. `undefined` for anything else (not loaded yet, unavailable, not that kind of sensor). */
39
+ export function sunIsDown(entity) {
40
+ if (entity?.kind !== 'sensor' || entity.measurement !== 'daylight') {
41
+ return undefined;
42
+ }
43
+ return entity.value === 'off' ? true : entity.value === 'on' ? false : undefined;
44
+ }
@@ -42,6 +42,8 @@ declare module 'e-prim' {
42
42
  interface TShadow {
43
43
  /** `EntityDrawer`'s own drop shadow. */
44
44
  drawer: string;
45
+ /** `NavDock`'s: a softer one, for a small pill that floats over the page. */
46
+ dock: string;
45
47
  }
46
48
  interface TZIndex {
47
49
  scrim: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hashsome/ui",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Design system components and hooks",
5
5
  "type": "module",
6
6
  "exports": {
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@emotion/react": "11.14.0",
19
- "@hashsome/core": "0.4.0",
19
+ "@hashsome/core": "0.6.0",
20
20
  "e-prim": "2.0.1",
21
21
  "motion": "13.4.5",
22
22
  "react": "19.3.0",