@hashsome/ui 0.5.0 → 0.7.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 (42) 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 +78 -61
  6. package/dist/entities/climate-tile.js +2 -1
  7. package/dist/entities/media-browser.d.ts +3 -1
  8. package/dist/entities/media-browser.js +41 -15
  9. package/dist/entities/media-player-bar.js +1 -1
  10. package/dist/entities/media-player-column.d.ts +5 -2
  11. package/dist/entities/media-player-column.js +33 -5
  12. package/dist/entities/media-player-full.d.ts +12 -2
  13. package/dist/entities/media-player-full.js +45 -3
  14. package/dist/entities/now-playing.d.ts +3 -1
  15. package/dist/entities/now-playing.js +8 -3
  16. package/dist/entities/sensor-readout.js +8 -4
  17. package/dist/entities/top-bar.js +6 -2
  18. package/dist/gallery/gallery.js +13 -1
  19. package/dist/gallery/props-data.js +125 -3
  20. package/dist/index.d.ts +3 -0
  21. package/dist/index.js +3 -0
  22. package/dist/layout/animated-number.d.ts +15 -0
  23. package/dist/layout/animated-number.js +53 -0
  24. package/dist/layout/board.d.ts +26 -0
  25. package/dist/layout/board.js +85 -0
  26. package/dist/layout/debug-menu.d.ts +11 -0
  27. package/dist/layout/debug-menu.js +99 -0
  28. package/dist/layout/drawer-controls.js +18 -6
  29. package/dist/layout/energy-chart.js +2 -3
  30. package/dist/layout/grid-overlay.d.ts +6 -0
  31. package/dist/layout/grid-overlay.js +143 -0
  32. package/dist/layout/page.d.ts +7 -1
  33. package/dist/layout/page.js +43 -6
  34. package/dist/layout/room-header.js +1 -1
  35. package/dist/layout/tile.js +10 -2
  36. package/dist/layout/top-row.d.ts +9 -0
  37. package/dist/layout/top-row.js +10 -0
  38. package/dist/provider.d.ts +7 -3
  39. package/dist/provider.js +39 -21
  40. package/dist/theme/grid.d.ts +30 -0
  41. package/dist/theme/grid.js +38 -0
  42. package/package.json +2 -2
@@ -0,0 +1,26 @@
1
+ import { type ReactNode } from 'react';
2
+ export interface BoardProps {
3
+ /** How many equal columns the page is divided into. Default 12. */
4
+ columns?: number;
5
+ /** `Cell`s, each saying how big it is; the board places them. */
6
+ children: ReactNode;
7
+ }
8
+ /** A page laid out on a grid, for a dashboard made for one device. The page is `columns` equal columns
9
+ * (a gap between them) and rows as tall as what is in them, with twice that gap between rows. Nothing
10
+ * says where a cell goes: each `Cell` only says how big it is, and the board puts it in the first
11
+ * place it fits, in the order written. Every card is a whole number of grid modules tall and every gap
12
+ * is 3, so whatever the board makes stays on the grid. It takes the height that is left of the page.
13
+ * Opt in: a page of plain flex columns keeps working the same. */
14
+ export declare function Board({ columns, children }: BoardProps): import("@emotion/react/jsx-runtime").JSX.Element;
15
+ export interface CellProps {
16
+ /** How many columns wide. Default: all of them. */
17
+ cols?: number;
18
+ /** How many rows of the board it covers, counting rows as tall as their content (a room is one): `3` ends where the third row does, and the cell is as tall as those rows, whatever is in it (a card in it that is too tall squeezes). `'fill'` starts where the cell is placed and goes down to the bottom of the page (or of the content, if that is longer), whatever rows are beside it; they keep their place. Default 1. */
19
+ rows?: number | 'fill';
20
+ /** What is in it, one under the other with the gap between. In a cell of more than one row, or `'fill'`, the last card stretches to the cell's height. */
21
+ children: ReactNode;
22
+ }
23
+ /** Something on a `Board`, and how big it is: `cols` wide and `rows` tall. Where it goes is the board's
24
+ * business. A room is one cell (its header and its tiles), a player beside three of them is one that
25
+ * covers three rows, or fills. */
26
+ export declare function Cell({ cols, rows, children }: CellProps): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -0,0 +1,85 @@
1
+ import { jsx as _jsx } from "@emotion/react/jsx-runtime";
2
+ /** @jsxImportSource @emotion/react */
3
+ import { Box } from 'e-prim';
4
+ import { useLayoutEffect, useRef, useState } from 'react';
5
+ /** A page laid out on a grid, for a dashboard made for one device. The page is `columns` equal columns
6
+ * (a gap between them) and rows as tall as what is in them, with twice that gap between rows. Nothing
7
+ * says where a cell goes: each `Cell` only says how big it is, and the board puts it in the first
8
+ * place it fits, in the order written. Every card is a whole number of grid modules tall and every gap
9
+ * is 3, so whatever the board makes stays on the grid. It takes the height that is left of the page.
10
+ * Opt in: a page of plain flex columns keeps working the same. */
11
+ export function Board({ columns = 12, children }) {
12
+ return (_jsx(Box, { "data-board": true, position: "relative", css: ({ density }) => ({
13
+ display: 'grid',
14
+ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
15
+ gridAutoRows: 'max-content',
16
+ alignContent: 'start',
17
+ // Twice as far between rows as between columns and inside a cell: a row is a group (a room), and
18
+ // groups read as groups.
19
+ columnGap: density.space,
20
+ rowGap: density.space * 2,
21
+ // The height of its rows, or the page's if that is more: it grows into what is left, and never
22
+ // shrinks below its content (the page scrolls instead).
23
+ flex: '1 0 auto',
24
+ minWidth: 0,
25
+ }), children: children }));
26
+ }
27
+ /** Something on a `Board`, and how big it is: `cols` wide and `rows` tall. Where it goes is the board's
28
+ * business. A room is one cell (its header and its tiles), a player beside three of them is one that
29
+ * covers three rows, or fills. */
30
+ export function Cell({ cols, rows = 1, children }) {
31
+ const ref = useRef(null);
32
+ const fill = rows === 'fill';
33
+ const [height, setHeight] = useState(0);
34
+ // `fill`: as tall as from where the cell starts to the bottom of the board, or to the end of the
35
+ // others' content if that is further. The others' content, not its own: it is what is being set.
36
+ useLayoutEffect(() => {
37
+ const cell = ref.current;
38
+ const board = cell?.parentElement;
39
+ if (!fill || !cell || !board) {
40
+ return;
41
+ }
42
+ const measure = () => {
43
+ const others = [...board.children].filter((child) => child !== cell);
44
+ const bottom = Math.max(board.clientHeight, ...others.map((child) => child.offsetTop + child.offsetHeight));
45
+ const next = Math.max(0, Math.floor(bottom - cell.offsetTop));
46
+ setHeight((current) => (Math.abs(current - next) < 1 ? current : next));
47
+ };
48
+ measure();
49
+ if (typeof ResizeObserver === 'undefined') {
50
+ return;
51
+ }
52
+ const observer = new ResizeObserver(measure);
53
+ observer.observe(board);
54
+ for (const child of board.children) {
55
+ observer.observe(child);
56
+ }
57
+ return () => observer.disconnect();
58
+ });
59
+ const span = typeof rows === 'number' ? rows : 1;
60
+ const stretches = fill || span > 1;
61
+ const column = (gap) => ({
62
+ display: 'flex',
63
+ flexDirection: 'column',
64
+ gap,
65
+ '& > [data-grid-card]:last-child': stretches ? { flexGrow: 1 } : undefined,
66
+ });
67
+ return (_jsx(Box, { ref: ref, "data-cell": true, css: ({ density }) => ({
68
+ gridColumn: cols === undefined ? '1 / -1' : `span ${cols}`,
69
+ gridRow: span > 1 ? `span ${span}` : undefined,
70
+ minWidth: 0,
71
+ // A cell takes the height of what is in it, unless it is meant to reach across rows.
72
+ alignSelf: stretches ? 'stretch' : 'start',
73
+ // One that reaches across rows, or fills, takes the height the page and the others give it; its
74
+ // own content does not size them (a player taller than three rooms squeezes, it does not
75
+ // stretch the third, or push the page past its bottom).
76
+ contain: stretches ? 'size' : undefined,
77
+ ...(fill ? { position: 'relative' } : column(density.space)),
78
+ }), children: fill ? (_jsx(Box, { position: "absolute", css: ({ density }) => ({
79
+ top: 0,
80
+ left: 0,
81
+ right: 0,
82
+ height: height > 0 ? height : '100%',
83
+ ...column(density.space),
84
+ }), children: children })) : (children) }));
85
+ }
@@ -0,0 +1,11 @@
1
+ /** @jsxImportSource @emotion/react */
2
+ import type { EntityRef } from '@hashsome/core';
3
+ import type { ThemeMode } from '../provider.tsx';
4
+ /** A floating button at the bottom left, for working on a dashboard on the device it is for: its
5
+ * popover shows the module grid over the page, makes the page fullscreen, and changes the theme
6
+ * (light, dark, the system's, and the sun's when the project has a sun entity). What it sets is kept on the device. Only there when
7
+ * `HashsomeProvider` has `debug` on (`HASHSOME_DEBUG=1`), and not exported. */
8
+ export declare function DebugMenu({ configured, sun, }: {
9
+ configured?: ThemeMode;
10
+ sun?: EntityRef | undefined;
11
+ }): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -0,0 +1,99 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
2
+ import { Flex, Typography } from 'e-prim';
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { THEME_CHOICES, useDebug } from '../debug.js';
5
+ import { Icon } from '../icon.js';
6
+ import { ChipRow } from './drawer-controls.js';
7
+ import { PlainButton } from './plain-button.js';
8
+ const THEME_LABELS = {
9
+ light: 'Light',
10
+ dark: 'Dark',
11
+ system: 'System',
12
+ sun: 'Sun',
13
+ };
14
+ const ON_OFF = [
15
+ { value: 'off', label: 'Off' },
16
+ { value: 'on', label: 'On' },
17
+ ];
18
+ /** The theme the project configured, as the menu's own choice, for showing which is in effect when
19
+ * the menu has not chosen. A schedule by the clock is none of them. */
20
+ function configuredChoice(configured) {
21
+ if (configured === undefined) {
22
+ return 'dark';
23
+ }
24
+ if (typeof configured === 'string') {
25
+ return configured;
26
+ }
27
+ return 'sun' in configured ? 'sun' : undefined;
28
+ }
29
+ function Row({ label, children }) {
30
+ return (_jsxs(Flex, { direction: "column", gap: 2, children: [_jsx(Typography, { as: "span", variant: "label", color: "textMuted", children: label }), children] }));
31
+ }
32
+ /** A floating button at the bottom left, for working on a dashboard on the device it is for: its
33
+ * popover shows the module grid over the page, makes the page fullscreen, and changes the theme
34
+ * (light, dark, the system's, and the sun's when the project has a sun entity). What it sets is kept on the device. Only there when
35
+ * `HashsomeProvider` has `debug` on (`HASHSOME_DEBUG=1`), and not exported. */
36
+ export function DebugMenu({ configured, sun, }) {
37
+ const debug = useDebug();
38
+ const [open, setOpen] = useState(false);
39
+ const root = useRef(null);
40
+ const [isFull, setIsFull] = useState(() => typeof document !== 'undefined' && document.fullscreenElement !== null);
41
+ const canFullscreen = typeof document !== 'undefined' && document.fullscreenEnabled === true;
42
+ useEffect(() => {
43
+ const onChange = () => setIsFull(document.fullscreenElement !== null);
44
+ document.addEventListener('fullscreenchange', onChange);
45
+ return () => document.removeEventListener('fullscreenchange', onChange);
46
+ }, []);
47
+ // Closes on a touch anywhere else, and on Escape.
48
+ useEffect(() => {
49
+ if (!open) {
50
+ return;
51
+ }
52
+ const away = (event) => {
53
+ if (root.current && !root.current.contains(event.target)) {
54
+ setOpen(false);
55
+ }
56
+ };
57
+ const escape = (event) => {
58
+ if (event.key === 'Escape') {
59
+ setOpen(false);
60
+ }
61
+ };
62
+ document.addEventListener('pointerdown', away);
63
+ document.addEventListener('keydown', escape);
64
+ return () => {
65
+ document.removeEventListener('pointerdown', away);
66
+ document.removeEventListener('keydown', escape);
67
+ };
68
+ }, [open]);
69
+ const toggleFullscreen = (on) => {
70
+ // Asked for in the touch itself: a browser only allows it as the answer to one.
71
+ if (on) {
72
+ void document.documentElement
73
+ .requestFullscreen({ navigationUI: 'hide' })
74
+ .catch(() => undefined);
75
+ }
76
+ else if (document.fullscreenElement) {
77
+ void document.exitFullscreen().catch(() => undefined);
78
+ }
79
+ debug.setFullscreen(on);
80
+ };
81
+ const theme = debug.themeChoice ?? configuredChoice(configured);
82
+ return (_jsxs(Flex, { ref: root, "data-debug-menu": true,
83
+ // As big as the button, whatever the page's own rules say: the document shell makes every `div`
84
+ // that is a child of `body` at least as tall as the window (`body > div { min-height: 100% }`),
85
+ // which this is, and the button would sit at the top of it.
86
+ css: {
87
+ position: 'fixed',
88
+ left: 12,
89
+ bottom: 12,
90
+ zIndex: 30,
91
+ width: 40,
92
+ height: 40,
93
+ minHeight: 0,
94
+ }, children: [open ? (_jsxs(Flex, { role: "dialog", "aria-label": "Debug", direction: "column", gap: 4, background: "surface", radius: "row", shadow: "drawer", p: 4, width: 288, css: { position: 'absolute', left: 0, bottom: 52, boxSizing: 'border-box' }, children: [_jsx(Row, { label: "Show grid", children: _jsx(ChipRow, { options: ON_OFF, value: debug.grid ? 'on' : 'off', onChange: (value) => debug.setGrid(value === 'on') }) }), _jsx(Row, { label: "Fullscreen", children: canFullscreen ? (_jsx(ChipRow, { options: ON_OFF, value: isFull ? 'on' : 'off', onChange: (value) => toggleFullscreen(value === 'on') })) : (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: "Not available in this browser." })) }), _jsx(Row, { label: "Theme", children: _jsx(ChipRow
95
+ // The sun only if the project has one to follow.
96
+ , {
97
+ // The sun only if the project has one to follow.
98
+ options: THEME_CHOICES.filter((choice) => choice !== 'sun' || sun !== undefined).map((choice) => ({ value: choice, label: THEME_LABELS[choice] })), value: theme, onChange: (value) => debug.setThemeChoice(value) }) })] })) : null, _jsx(PlainButton, { "aria-label": "Debug menu", title: "Debug menu", "aria-expanded": open, onClick: () => setOpen((current) => !current), center: true, radius: "full", background: "surface", color: "textMuted", shadow: "dock", width: 40, height: 40, css: { flex: 'none' }, children: _jsx(Icon, { name: "lu:bug", size: 18 }) })] }));
99
+ }
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
3
3
  import { Box, Flex, Typography } from 'e-prim';
4
4
  import { motion } from 'motion/react';
5
5
  import { MarqueeText } from './marquee-text.js';
6
- import { useEffect, useRef } from 'react';
6
+ import { useEffect, useId, useRef, } from 'react';
7
7
  import { Icon } from '../icon.js';
8
8
  import { FadeScroll } from './fade-scroll.js';
9
9
  import { RoundButton } from './round-button.js';
@@ -101,19 +101,31 @@ export const CHIP_HEIGHT = 28;
101
101
  /** A wrapping row of pill choices (modes, presets) with the selected one filled. With `tabs` it is
102
102
  * a tab list (the selected chip is the open tab) that never wraps: too many to fit scroll sideways. */
103
103
  export function ChipRow({ options, value, onChange, tabs = false, }) {
104
+ // The accent behind the selected chip is one pill that glides to the next; its own id, so two rows
105
+ // on a page do not share it.
106
+ const pill = useId();
104
107
  // Tabs scroll sideways when there are more than fit, and fade at the edge that has more.
105
108
  const Wrap = tabs ? FadeScroll : Flex;
106
109
  return (_jsx(Wrap, { gap: 2, ...(tabs ? { role: 'tablist' } : {}), css: tabs
107
- ? { flexShrink: 0, scrollbarWidth: 'none', '&::-webkit-scrollbar': { display: 'none' } }
108
- : { flexWrap: 'wrap' }, children: options.map((option) => {
110
+ ? {
111
+ flexShrink: 0,
112
+ isolation: 'isolate',
113
+ scrollbarWidth: 'none',
114
+ '&::-webkit-scrollbar': { display: 'none' },
115
+ }
116
+ : { flexWrap: 'wrap', isolation: 'isolate' }, children: options.map((option) => {
109
117
  const selected = option.value === value;
110
118
  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",
111
119
  // A fixed height, not the text's: an emoji is drawn from another font with a taller line,
112
120
  // which would make this one chip taller than the rest.
113
- height: CHIP_HEIGHT, px: 3, background: selected ? 'accent' : 'surface', color: selected ? 'accentText' : 'text', css: {
121
+ height: CHIP_HEIGHT, px: 3, background: "surface", color: selected ? 'accentText' : 'text', css: {
114
122
  flex: 'none',
123
+ position: 'relative',
115
124
  whiteSpace: 'nowrap',
116
- transition: 'background-color 160ms ease, color 160ms ease',
117
- }, 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));
125
+ transition: 'color 160ms ease',
126
+ }, children: [selected ? (_jsx(Box, { as: motion.span, layoutId: pill, position: "absolute", radius: "full", background: "accent", transition: { type: 'spring', duration: 0.32, bounce: 0.12 },
127
+ // Above every chip's own background, whichever way it is going: a later chip paints over
128
+ // an earlier one's pill otherwise.
129
+ css: { inset: 0, zIndex: 1 } })) : null, _jsxs(Flex, { as: "span", align: "center", gap: 1.5, position: "relative", minWidth: 0, css: { zIndex: 2 }, 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));
118
130
  }) }));
119
131
  }
@@ -4,6 +4,7 @@ import { useState } from 'react';
4
4
  import { useEntityHandle } from '../hooks.js';
5
5
  import { useEntityHistory } from '../use-entity-history.js';
6
6
  import { usageFromDaily } from './energy-usage.js';
7
+ import { AnimatedNumber, decimalsOf } from './animated-number.js';
7
8
  import { useDetail } from './detail-provider.js';
8
9
  import { RangeSwitcher, SeriesChart } from './series-chart.js';
9
10
  const ENERGY_RANGES = [
@@ -53,9 +54,7 @@ function PowerReading({ entity, fallbackUnit }) {
53
54
  const handle = useEntityHandle('sensor', entity);
54
55
  const sensor = handle.entity;
55
56
  const unit = sensor?.unit ?? fallbackUnit;
56
- return (_jsx(Typography, { as: "span", variant: "stat", "aria-live": "off", children: handle.status === 'ready' && sensor?.numeric !== undefined
57
- ? `${sensor.numeric} ${unit}`
58
- : '—' }));
57
+ return (_jsx(Typography, { as: "span", variant: "stat", "aria-live": "off", children: handle.status === 'ready' && sensor?.numeric !== undefined ? (_jsx(AnimatedNumber, { value: sensor.numeric, format: (n) => `${n.toFixed(decimalsOf(sensor.numeric ?? 0))} ${unit}` })) : ('—') }));
59
58
  }
60
59
  function LifetimeEnergy({ entity }) {
61
60
  const handle = useEntityHandle('sensor', entity);
@@ -0,0 +1,6 @@
1
+ /** Draws the grid over the page it sits in (the `Page`, when the debug menu has it on): a faint
2
+ * line at every module, a stronger one at every tile pitch, and a box round every card (anything
3
+ * marked `data-grid-card`) with its height in modules, green when its top and height are on the grid
4
+ * and red when they are not. For laying out a dashboard for one device: see where things fall, and
5
+ * what to change. Never takes a touch. */
6
+ export declare function GridOverlay(): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -0,0 +1,143 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
2
+ /** @jsxImportSource @emotion/react */
3
+ import { useTheme } from '@emotion/react';
4
+ import { Box } from 'e-prim';
5
+ import { useLayoutEffect, useRef, useState } from 'react';
6
+ import { gridMetrics, offGrid } from '../theme/grid.js';
7
+ const SAME = (a, b) => a !== null && JSON.stringify(a) === JSON.stringify(b);
8
+ /** The viewport the page has now (it is smaller than the screen while the browser shows bars), the
9
+ * screen, both in CSS px, and the pixel ratio: multiply by it for the panel's own pixels. */
10
+ function describeDevice() {
11
+ const ratio = Math.round(window.devicePixelRatio * 100) / 100;
12
+ return `viewport ${window.innerWidth}×${window.innerHeight} · screen ${window.screen.width}×${window.screen.height} @${ratio}`;
13
+ }
14
+ /** Draws the grid over the page it sits in (the `Page`, when the debug menu has it on): a faint
15
+ * line at every module, a stronger one at every tile pitch, and a box round every card (anything
16
+ * marked `data-grid-card`) with its height in modules, green when its top and height are on the grid
17
+ * and red when they are not. For laying out a dashboard for one device: see where things fall, and
18
+ * what to change. Never takes a touch. */
19
+ export function GridOverlay() {
20
+ const theme = useTheme();
21
+ const metrics = gridMetrics(theme.density.space);
22
+ const { module, tilePitch } = metrics;
23
+ const ref = useRef(null);
24
+ const [view, setView] = useState(null);
25
+ useLayoutEffect(() => {
26
+ const host = ref.current?.parentElement;
27
+ if (!host) {
28
+ return;
29
+ }
30
+ let frame = 0;
31
+ const measure = () => {
32
+ const style = getComputedStyle(host);
33
+ const centered = host.dataset['centered'] ?? '';
34
+ const [slack = 0, slackBelow = 0] = centered.split(' ').map(Number);
35
+ // The grid spans the whole page, its padding included: it starts at the page's edge, less the
36
+ // sliver centering left over, so the padding is its first modules and the cards start after it.
37
+ const top = slack;
38
+ const box = host.getBoundingClientRect();
39
+ const next = {
40
+ top,
41
+ width: host.scrollWidth,
42
+ height: host.scrollHeight - slack - slackBelow,
43
+ content: {
44
+ left: parseFloat(style.paddingLeft),
45
+ top: parseFloat(style.paddingTop) - slack,
46
+ right: parseFloat(style.paddingRight),
47
+ bottom: parseFloat(style.paddingBottom) - slackBelow,
48
+ },
49
+ centered,
50
+ device: describeDevice(),
51
+ cards: [...host.querySelectorAll('[data-grid-card]')].map((card) => {
52
+ const rect = card.getBoundingClientRect();
53
+ const x = rect.left - box.left + host.scrollLeft;
54
+ const y = rect.top - box.top + host.scrollTop - top;
55
+ return {
56
+ x: Math.round(x * 10) / 10,
57
+ y: Math.round(y * 10) / 10,
58
+ width: Math.round(rect.width * 10) / 10,
59
+ height: Math.round(rect.height * 10) / 10,
60
+ off: offGrid(y, rect.height, module),
61
+ };
62
+ }),
63
+ };
64
+ setView((current) => (SAME(current, next) ? current : next));
65
+ };
66
+ const schedule = () => {
67
+ cancelAnimationFrame(frame);
68
+ frame = requestAnimationFrame(measure);
69
+ };
70
+ schedule();
71
+ window.addEventListener('resize', schedule);
72
+ const resize = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(schedule);
73
+ resize?.observe(host);
74
+ // What the page is made of changing (a card appearing, a size changing): not this overlay's own.
75
+ const mutations = new MutationObserver((records) => {
76
+ if (records.some((record) => !ref.current?.contains(record.target))) {
77
+ schedule();
78
+ }
79
+ });
80
+ mutations.observe(host, { subtree: true, childList: true, attributes: true });
81
+ return () => {
82
+ cancelAnimationFrame(frame);
83
+ window.removeEventListener('resize', schedule);
84
+ resize?.disconnect();
85
+ mutations.disconnect();
86
+ };
87
+ }, [module]);
88
+ // A line every module, unless that is too fine to read, then every gap (3 modules).
89
+ const minor = module >= 3.5 ? module : module * 3;
90
+ const off = view?.cards.filter((card) => card.off).length ?? 0;
91
+ const [top = 0, bottom = 0] = (view?.centered ?? '').split(' ').map(Number);
92
+ return (_jsx(Box, { ref: ref, "data-grid-overlay": true, "aria-hidden": "true", css: { display: 'contents' }, children: view ? (_jsxs(_Fragment, { children: [_jsxs(Box, { position: "absolute", css: {
93
+ left: 0,
94
+ top: view.top,
95
+ width: view.width,
96
+ height: view.height,
97
+ pointerEvents: 'none',
98
+ zIndex: 4,
99
+ backgroundImage: [
100
+ `linear-gradient(to bottom, rgba(0, 150, 255, 0.5) 1px, transparent 1px)`,
101
+ `linear-gradient(to right, rgba(0, 150, 255, 0.5) 1px, transparent 1px)`,
102
+ `linear-gradient(to bottom, rgba(0, 150, 255, 0.12) 1px, transparent 1px)`,
103
+ `linear-gradient(to right, rgba(0, 150, 255, 0.12) 1px, transparent 1px)`,
104
+ ].join(', '),
105
+ backgroundSize: [
106
+ `100% ${tilePitch}px`,
107
+ `${tilePitch}px 100%`,
108
+ `100% ${minor}px`,
109
+ `${minor}px 100%`,
110
+ ].join(', '),
111
+ }, children: [_jsx(Box, { position: "absolute", css: {
112
+ left: view.content.left,
113
+ top: view.content.top,
114
+ right: view.content.right,
115
+ bottom: view.content.bottom,
116
+ outline: '1px dashed rgba(0, 150, 255, 0.9)',
117
+ } }), view.cards.map((card) => (_jsx(Box, { position: "absolute", css: {
118
+ left: card.x,
119
+ top: card.y,
120
+ width: card.width,
121
+ height: card.height,
122
+ boxSizing: 'border-box',
123
+ outline: `1px solid ${card.off ? '#ef4444' : '#22c55e'}`,
124
+ outlineOffset: -1,
125
+ color: card.off ? '#ef4444' : '#22c55e',
126
+ font: '600 9px/1 system-ui, sans-serif',
127
+ padding: 2,
128
+ overflow: 'hidden',
129
+ }, children: card.height >= 14 ? `${Math.round((card.height / module) * 10) / 10}u` : null }, `${card.x}:${card.y}:${card.width}:${card.height}`)))] }), _jsx(Box, { position: "fixed", css: {
130
+ // Clear of the debug menu's button, which is how the grid is turned on.
131
+ left: 60,
132
+ bottom: 8,
133
+ zIndex: 100,
134
+ pointerEvents: 'none',
135
+ // On a narrow screen it wraps instead of running off the edge.
136
+ maxWidth: 'calc(100vw - 68px)',
137
+ background: 'rgba(0, 0, 0, 0.78)',
138
+ color: '#fff',
139
+ font: '600 11px/1.3 system-ui, sans-serif',
140
+ padding: '4px 8px',
141
+ borderRadius: 6,
142
+ }, children: `grid · 1u = ${Math.round(module * 100) / 100}px · gap 3u · tile ${Math.round(metrics.tile / module)}u · ${off === 0 ? 'all on grid' : `${off} off grid`}${top + bottom > 0 ? ` · centered +${top}/${bottom}px` : ''}${view.device ? ` · ${view.device}` : ''}` })] })) : null }));
143
+ }
@@ -12,6 +12,12 @@ export interface PageProps {
12
12
  /** The page every dashboard renders into: padded by the density's spacing, a column with gaps,
13
13
  * exactly viewport height, and scrolling inside itself (the document never scrolls — the entity
14
14
  * drawer is `position: fixed` and relies on that). It pads further for a `NavRail` or `NavDock`
15
- * the dashboard includes. Render it once, in the app's root layout. */
15
+ * the dashboard includes. Render it once, in the app's root layout.
16
+ *
17
+ * The debug menu (`HASHSOME_DEBUG=1`) can draw the module grid over the page, spanning all of it (its
18
+ * padding is the grid's first three modules), with every card boxed green or red by whether it sits on
19
+ * the grid: for laying a dashboard out for one device. The height is rarely a whole number of grid
20
+ * modules; what is left over (under one module) is shared between the top and the bottom padding, so the
21
+ * content sits centered and the grid stays whole. */
16
22
  export declare function Page({ children, height }: PageProps): import("@emotion/react/jsx-runtime").JSX.Element;
17
23
  export {};
@@ -1,7 +1,11 @@
1
- import { jsx as _jsx } from "@emotion/react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
2
2
  /** @jsxImportSource @emotion/react */
3
+ import { useTheme } from '@emotion/react';
3
4
  import { Flex } from 'e-prim';
4
- import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useState, } from 'react';
5
+ import { useDebug } from '../debug.js';
6
+ import { centeringOffsets, gridMetrics } from '../theme/grid.js';
7
+ import { GridOverlay } from './grid-overlay.js';
8
+ import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState, } from 'react';
5
9
  const InsetContext = createContext(null);
6
10
  /** Reserves `size` px of the surrounding `Page`'s edge for something fixed over it (`NavRail`,
7
11
  * `NavDock`), so content never sits under it. A no-op outside a `Page`. */
@@ -12,7 +16,13 @@ export function usePageInset(side, size) {
12
16
  /** The page every dashboard renders into: padded by the density's spacing, a column with gaps,
13
17
  * exactly viewport height, and scrolling inside itself (the document never scrolls — the entity
14
18
  * drawer is `position: fixed` and relies on that). It pads further for a `NavRail` or `NavDock`
15
- * the dashboard includes. Render it once, in the app's root layout. */
19
+ * the dashboard includes. Render it once, in the app's root layout.
20
+ *
21
+ * The debug menu (`HASHSOME_DEBUG=1`) can draw the module grid over the page, spanning all of it (its
22
+ * padding is the grid's first three modules), with every card boxed green or red by whether it sits on
23
+ * the grid: for laying a dashboard out for one device. The height is rarely a whole number of grid
24
+ * modules; what is left over (under one module) is shared between the top and the bottom padding, so the
25
+ * content sits centered and the grid stays whole. */
16
26
  export function Page({ children, height = '100dvh' }) {
17
27
  const [insets, setInsets] = useState({ left: 0, bottom: 0 });
18
28
  const reserve = useCallback((side, size) => {
@@ -20,10 +30,37 @@ export function Page({ children, height = '100dvh' }) {
20
30
  return () => setInsets((current) => ({ ...current, [side]: 0 }));
21
31
  }, []);
22
32
  const value = useMemo(() => reserve, [reserve]);
23
- return (_jsx(InsetContext.Provider, { value: value, children: _jsx(Flex, { as: "main", direction: "column", height: height, overflow: "auto", gap: 3, p: 3, css: ({ spacing }) => ({
33
+ // The debug menu's "Show grid" draws the module grid over the page, for laying a dashboard out.
34
+ const showGrid = useDebug().grid;
35
+ // What is left of the height after whole modules goes half to the top padding and half to the
36
+ // bottom one (in whole pixels, so edges stay crisp).
37
+ const theme = useTheme();
38
+ const space = theme.density?.space;
39
+ const main = useRef(null);
40
+ const [centered, setCentered] = useState({ top: 0, bottom: 0 });
41
+ useLayoutEffect(() => {
42
+ const element = main.current;
43
+ if (!element || space === undefined) {
44
+ return;
45
+ }
46
+ const measure = () => {
47
+ const next = centeringOffsets(element.clientHeight - 2 * space - insets.bottom, gridMetrics(space).module);
48
+ setCentered((current) => current.top === next.top && current.bottom === next.bottom ? current : next);
49
+ };
50
+ measure();
51
+ window.addEventListener('resize', measure);
52
+ const resize = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure);
53
+ resize?.observe(element);
54
+ return () => {
55
+ window.removeEventListener('resize', measure);
56
+ resize?.disconnect();
57
+ };
58
+ }, [space, insets.bottom]);
59
+ return (_jsx(InsetContext.Provider, { value: value, children: _jsxs(Flex, { as: "main", ref: main, "data-centered": `${centered.top} ${centered.bottom}`, direction: "column", height: height, overflow: "auto", position: "relative", gap: 3, p: 3, css: ({ spacing }) => ({
24
60
  // The padding of one space, plus room for a nav that is fixed over the page.
25
61
  paddingLeft: `calc(${spacing(3)} + ${insets.left}px)`,
26
- paddingBottom: `calc(${spacing(3)} + ${insets.bottom}px)`,
62
+ paddingTop: `calc(${spacing(3)} + ${centered.top}px)`,
63
+ paddingBottom: `calc(${spacing(3)} + ${insets.bottom + centered.bottom}px)`,
27
64
  boxSizing: 'border-box',
28
- }), children: children }) }));
65
+ }), children: [children, showGrid ? _jsx(GridOverlay, {}) : null] }) }));
29
66
  }
@@ -4,7 +4,7 @@ import { Box, Flex, Typography } from 'e-prim';
4
4
  import { Icon } from '../icon.js';
5
5
  /** The header that starts a room: muted icon and title, a hairline, and readouts on the right. Put it above a `Grid` (or any tiles); it adds its own space above, so consecutive rooms read as groups. */
6
6
  export function RoomHeader({ title, icon, readouts }) {
7
- return (_jsxs(Flex, { as: "header", align: "center", color: "textMuted", minHeight: 32, mt: 3, gap: 3,
7
+ return (_jsxs(Flex, { as: "header", "data-grid-card": true, align: "center", color: "textMuted", minHeight: 32, mt: 3, gap: 3,
8
8
  // A header starts a new group: the extra space sits on top of the screen's own gap, so a
9
9
  // room reads as header + its tiles rather than as evenly spaced rows.
10
10
  css: { '&:first-child': { marginTop: 0 } }, children: [icon ? _jsx(Icon, { name: icon, size: 16 }) : null, _jsx(Typography, { as: "h2", variant: "roomTitle", color: "textMuted", css: { margin: 0 }, children: title }), _jsx(Box, { as: "span", background: "border", css: { flex: 1, height: 1 } }), readouts ? (_jsx(Flex, { align: "center", color: "textMuted", css: ({ density }) => ({ gap: density.space * 1.5 }), children: readouts })) : null] }));
@@ -5,6 +5,7 @@ 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';
@@ -183,9 +184,16 @@ export function Tile({ label, icon, secondary, status = 'ready', active = false,
183
184
  const dimColorKey = ready ? 'line' : 'textMuted';
184
185
  const cardOpacity = !ready || feedback === 'pending' ? 0.7 : 1;
185
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;
186
- 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,
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,
187
188
  // A card that mounts (a page opened) is drawn as it is, not animated from nothing.
188
- initial: false, 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",
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",
189
197
  // Spans the whole card, including behind `trailing` (e.g. a color-capable light's
190
198
  // picker button) — it's a sibling of the button and `trailing`, not nested inside the
191
199
  // button, specifically so its width isn't capped at the button's own narrower bounds.
@@ -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
+ }
@@ -1,4 +1,4 @@
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';
@@ -21,10 +21,14 @@ export interface HashsomeProviderProps {
21
21
  font?: string;
22
22
  /** `'compact'` for small square displays: tighter spacing, shorter tiles, smaller icon circles. Default `'comfortable'`. */
23
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: the choice is kept on the device, so reloads and links inside the app keep it, and `?motion=auto` forgets it. That wins over this prop. */
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
25
  motion?: MotionPreference;
26
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. */
27
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;
28
32
  /** The app. */
29
33
  children: ReactNode;
30
34
  }
@@ -40,5 +44,5 @@ export interface ThemeModeState {
40
44
  * Must be rendered inside `<HashsomeProvider>`. */
41
45
  export declare function useThemeToggle(): ThemeModeState;
42
46
  /** Connects the tree to the runtime proxy. Render only on the client. */
43
- export declare function HashsomeProvider({ url, client, clientOptions, theme, motion, 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;
44
48
  export declare function useClient(): Client;