@hashsome/ui 0.5.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 (37) 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/media-browser.d.ts +3 -1
  7. package/dist/entities/media-browser.js +41 -15
  8. package/dist/entities/media-player-bar.js +1 -1
  9. package/dist/entities/media-player-column.d.ts +5 -2
  10. package/dist/entities/media-player-column.js +33 -5
  11. package/dist/entities/media-player-full.d.ts +12 -2
  12. package/dist/entities/media-player-full.js +45 -3
  13. package/dist/entities/now-playing.d.ts +3 -1
  14. package/dist/entities/now-playing.js +8 -3
  15. package/dist/entities/top-bar.js +6 -2
  16. package/dist/gallery/gallery.js +3 -1
  17. package/dist/gallery/props-data.js +102 -3
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.js +2 -0
  20. package/dist/layout/board.d.ts +26 -0
  21. package/dist/layout/board.js +85 -0
  22. package/dist/layout/debug-menu.d.ts +11 -0
  23. package/dist/layout/debug-menu.js +99 -0
  24. package/dist/layout/drawer-controls.js +18 -6
  25. package/dist/layout/grid-overlay.d.ts +6 -0
  26. package/dist/layout/grid-overlay.js +143 -0
  27. package/dist/layout/page.d.ts +7 -1
  28. package/dist/layout/page.js +43 -6
  29. package/dist/layout/room-header.js +1 -1
  30. package/dist/layout/tile.js +10 -2
  31. package/dist/layout/top-row.d.ts +9 -0
  32. package/dist/layout/top-row.js +10 -0
  33. package/dist/provider.d.ts +7 -3
  34. package/dist/provider.js +39 -21
  35. package/dist/theme/grid.d.ts +30 -0
  36. package/dist/theme/grid.js +38 -0
  37. package/package.json +2 -2
@@ -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
  }
@@ -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;
package/dist/provider.js CHANGED
@@ -1,9 +1,11 @@
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
5
  import { MotionGlobalConfig } from 'motion/react';
6
- import { createContext, useContext, useEffect, useMemo, useState, useSyncExternalStore, } from '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';
7
9
  import { DetailProvider } from './layout/detail-provider.js';
8
10
  import { EntityDrawer } from './layout/entity-drawer.js';
9
11
  import { UNITS_PER_SPACE } from './theme/density.js';
@@ -51,22 +53,13 @@ export function useThemeToggle() {
51
53
  }
52
54
  return value;
53
55
  }
54
- const MOTION_KEY = 'hashsome:motion';
55
- /** What this device chose for motion: `?motion=reduced` or `?motion=full` on the address (which is
56
- * also remembered), or what an earlier visit remembered. `?motion=auto` forgets it. */
57
- function motionFromDevice() {
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() {
58
60
  try {
59
61
  const asked = new URLSearchParams(window.location.search).get('motion');
60
- if (asked === 'reduced' || asked === 'full') {
61
- localStorage.setItem(MOTION_KEY, asked);
62
- return asked;
63
- }
64
- if (asked === 'auto') {
65
- localStorage.removeItem(MOTION_KEY);
66
- return undefined;
67
- }
68
- const kept = localStorage.getItem(MOTION_KEY);
69
- return kept === 'reduced' || kept === 'full' ? kept : undefined;
62
+ return asked === 'reduced' || asked === 'full' ? asked : undefined;
70
63
  }
71
64
  catch {
72
65
  return undefined;
@@ -77,7 +70,7 @@ function motionFromDevice() {
77
70
  * transitions and animations too. */
78
71
  function useMotionMode(preference) {
79
72
  const [mode] = useState(() => {
80
- const chosen = motionFromDevice() ?? preference;
73
+ const chosen = motionFromAddress() ?? preference;
81
74
  MotionGlobalConfig.skipAnimations = chosen === 'reduced';
82
75
  if (typeof document !== 'undefined') {
83
76
  if (chosen === 'reduced') {
@@ -102,6 +95,22 @@ function rememberedSun() {
102
95
  return undefined;
103
96
  }
104
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
+ }
105
114
  /** Resolves the configured mode: `'system'` against the live OS/browser preference (updating if it
106
115
  * changes while open — a kiosk tablet left running overnight should follow a scheduled OS-level dark
107
116
  * mode, for example), a time range against the clock (re-checked at each boundary and whenever the
@@ -151,7 +160,12 @@ function useThemeMode(mode, client) {
151
160
  document.removeEventListener('visibilitychange', onVisible);
152
161
  };
153
162
  }, [from, to]);
154
- const sun = useSyncExternalStore((onChange) => (sunRef ? client.subscribe(sunRef, onChange) : () => undefined), () => (sunRef ? client.getEntity(sunRef) : undefined), () => undefined);
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);
155
169
  const sunDown = sunIsDown(sun);
156
170
  useEffect(() => {
157
171
  if (sunDown === undefined) {
@@ -181,10 +195,14 @@ function useThemeMode(mode, client) {
181
195
  return { resolved, toggle: () => setOverride(resolved === 'dark' ? 'light' : 'dark') };
182
196
  }
183
197
  /** Connects the tree to the runtime proxy. Render only on the client. */
184
- export function HashsomeProvider({ url, client, clientOptions, theme = 'dark', motion = 'auto', 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, }) {
185
199
  const instance = useMemo(() => client ?? new RemoteClient({ url: url ?? defaultUrl(), ...clientOptions }), [client, url, clientOptions]);
186
200
  useMotionMode(motion);
187
- const themeMode = useThemeMode(theme, instance);
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);
188
206
  // Memoized: Emotion recomputes the merged theme (and every `css` prop) when the function changes.
189
207
  const withDensity = useMemo(() => (outer) => ({ ...outer, density: densityTokens(density, overrides) }), [density, overrides]);
190
208
  useGoogleFont(font);
@@ -197,7 +215,7 @@ export function HashsomeProvider({ url, client, clientOptions, theme = 'dark', m
197
215
  instance.connect();
198
216
  return () => instance.close();
199
217
  }, [instance]);
200
- 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] }) })] }) }) }) }));
201
219
  }
202
220
  export function useClient() {
203
221
  const client = useContext(HashsomeContext);
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hashsome/ui",
3
- "version": "0.5.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.5.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",