@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/debug.d.ts +27 -0
- package/dist/debug.js +95 -0
- package/dist/entities/artwork-ring.d.ts +4 -2
- package/dist/entities/artwork-ring.js +78 -61
- package/dist/entities/media-browser.d.ts +3 -1
- package/dist/entities/media-browser.js +41 -15
- package/dist/entities/media-player-bar.js +1 -1
- package/dist/entities/media-player-column.d.ts +5 -2
- package/dist/entities/media-player-column.js +33 -5
- package/dist/entities/media-player-full.d.ts +12 -2
- package/dist/entities/media-player-full.js +45 -3
- package/dist/entities/now-playing.d.ts +3 -1
- package/dist/entities/now-playing.js +8 -3
- package/dist/entities/top-bar.js +6 -2
- package/dist/gallery/gallery.js +3 -1
- package/dist/gallery/props-data.js +102 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/layout/board.d.ts +26 -0
- package/dist/layout/board.js +85 -0
- package/dist/layout/debug-menu.d.ts +11 -0
- package/dist/layout/debug-menu.js +99 -0
- package/dist/layout/drawer-controls.js +18 -6
- package/dist/layout/grid-overlay.d.ts +6 -0
- package/dist/layout/grid-overlay.js +143 -0
- package/dist/layout/page.d.ts +7 -1
- package/dist/layout/page.js +43 -6
- package/dist/layout/room-header.js +1 -1
- package/dist/layout/tile.js +10 -2
- package/dist/layout/top-row.d.ts +9 -0
- package/dist/layout/top-row.js +10 -0
- package/dist/provider.d.ts +7 -3
- package/dist/provider.js +39 -21
- package/dist/theme/grid.d.ts +30 -0
- package/dist/theme/grid.js +38 -0
- 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
|
-
? {
|
|
108
|
-
|
|
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:
|
|
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: '
|
|
117
|
-
}, children: [
|
|
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
|
+
}
|
package/dist/layout/page.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/layout/page.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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] }));
|
package/dist/layout/tile.js
CHANGED
|
@@ -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 }) =>
|
|
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
|
+
}
|
package/dist/provider.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
*
|
|
57
|
-
function
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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",
|