@hashsome/ui 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +79 -51
- package/dist/entities/light-tile.d.ts +3 -1
- package/dist/entities/light-tile.js +29 -20
- package/dist/entities/media-browser.d.ts +3 -1
- package/dist/entities/media-browser.js +41 -15
- package/dist/entities/media-player-bar.d.ts +9 -1
- package/dist/entities/media-player-bar.js +122 -12
- 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/media-seek.d.ts +4 -1
- package/dist/entities/media-seek.js +22 -3
- package/dist/entities/nav-dock.js +3 -3
- package/dist/entities/now-playing.d.ts +3 -1
- package/dist/entities/now-playing.js +8 -3
- package/dist/entities/seek-line.js +12 -0
- package/dist/entities/top-bar.d.ts +21 -0
- package/dist/entities/top-bar.js +19 -10
- package/dist/gallery/gallery.js +18 -3
- package/dist/gallery/props-data.js +187 -3
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/layout/animated-outlet.d.ts +14 -0
- package/dist/layout/animated-outlet.js +44 -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/detail-provider.d.ts +4 -2
- package/dist/layout/detail-provider.js +2 -2
- package/dist/layout/drawer-controls.js +36 -8
- package/dist/layout/entity-drawer.js +1 -1
- package/dist/layout/grid-overlay.d.ts +6 -0
- package/dist/layout/grid-overlay.js +143 -0
- package/dist/layout/hold-progress.d.ts +8 -2
- package/dist/layout/hold-progress.js +20 -4
- 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 +14 -10
- package/dist/layout/top-row.d.ts +9 -0
- package/dist/layout/top-row.js +10 -0
- package/dist/layout/use-drawer.d.ts +3 -2
- package/dist/layout/use-drawer.js +5 -3
- package/dist/provider.d.ts +15 -4
- package/dist/provider.js +144 -13
- package/dist/theme/global-styles.js +3 -0
- package/dist/theme/grid.d.ts +30 -0
- package/dist/theme/grid.js +38 -0
- package/dist/theme/index.js +2 -1
- package/dist/theme/schedule.d.ts +33 -0
- package/dist/theme/schedule.js +44 -0
- package/dist/theme/tokens.d.ts +2 -0
- package/package.json +2 -2
|
@@ -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
|
+
}
|
|
@@ -4,11 +4,13 @@ interface DetailState {
|
|
|
4
4
|
header: ReactNode;
|
|
5
5
|
body: ReactNode;
|
|
6
6
|
expanded: boolean;
|
|
7
|
+
/** Stays at full size: the drawer has no button to collapse it. */
|
|
8
|
+
locked: boolean;
|
|
7
9
|
}
|
|
8
10
|
interface DetailContextValue {
|
|
9
11
|
detail: DetailState | null;
|
|
10
|
-
/** `expanded` opens it at full size instead of the side panel. */
|
|
11
|
-
openDetail: (id: string, header: ReactNode, body: ReactNode, expanded?: boolean) => void;
|
|
12
|
+
/** `expanded` opens it at full size instead of the side panel; `locked` keeps it there, with no button to collapse it. */
|
|
13
|
+
openDetail: (id: string, header: ReactNode, body: ReactNode, expanded?: boolean, locked?: boolean) => void;
|
|
12
14
|
/** Keeps an already-open drawer's content live; a no-op for any other `id`. */
|
|
13
15
|
updateDetail: (id: string, header: ReactNode, body: ReactNode) => void;
|
|
14
16
|
closeDetail: () => void;
|
|
@@ -4,8 +4,8 @@ const DetailContext = createContext(null);
|
|
|
4
4
|
/** Backs the single shared `EntityDrawer`; instantiated once by `HashsomeProvider`. */
|
|
5
5
|
export function DetailProvider({ children }) {
|
|
6
6
|
const [detail, setDetail] = useState(null);
|
|
7
|
-
const openDetail = useCallback((id, header, body, expanded = false) => {
|
|
8
|
-
setDetail({ id, header, body, expanded });
|
|
7
|
+
const openDetail = useCallback((id, header, body, expanded = false, locked = false) => {
|
|
8
|
+
setDetail({ id, header, body, expanded: expanded || locked, locked });
|
|
9
9
|
}, []);
|
|
10
10
|
const updateDetail = useCallback((id, header, body) => {
|
|
11
11
|
setDetail((current) => (current && current.id === id ? { ...current, header, body } : current));
|
|
@@ -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';
|
|
@@ -36,6 +36,7 @@ export function StepButton({ icon, label, delta, holdDelta = delta * 10, onStep,
|
|
|
36
36
|
* `fill` shows accent up to the value; a `gradient` track stays vivid up to the value and dims past it instead (no marker or thumb). */
|
|
37
37
|
export function ValueBar({ label, value, min, max, keyStep, round = 1, gradient, height = 28, onDrag, onCommit, }) {
|
|
38
38
|
const dragging = useRef(false);
|
|
39
|
+
const lastDrag = useRef(null);
|
|
39
40
|
const fraction = max === min ? 0 : (value - min) / (max - min);
|
|
40
41
|
const pct = Math.min(1, Math.max(0, fraction)) * 100;
|
|
41
42
|
const clampValue = (v) => Math.min(max, Math.max(min, v));
|
|
@@ -64,16 +65,31 @@ export function ValueBar({ label, value, min, max, keyStep, round = 1, gradient,
|
|
|
64
65
|
width: "100%", height: height, radius: "full", background: "surface", border: !gradient, cursor: "pointer", overflow: "hidden", css: { touchAction: 'none', ...(gradient ? { backgroundImage: gradient } : {}) }, onPointerDown: (event) => {
|
|
65
66
|
dragging.current = true;
|
|
66
67
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
67
|
-
|
|
68
|
+
lastDrag.current = valueAt(event);
|
|
69
|
+
onDrag(lastDrag.current);
|
|
68
70
|
}, onPointerMove: (event) => {
|
|
69
71
|
if (dragging.current) {
|
|
70
|
-
|
|
72
|
+
lastDrag.current = valueAt(event);
|
|
73
|
+
onDrag(lastDrag.current);
|
|
71
74
|
}
|
|
72
75
|
}, onPointerUp: (event) => {
|
|
73
76
|
if (dragging.current) {
|
|
74
77
|
dragging.current = false;
|
|
75
78
|
onCommit(valueAt(event));
|
|
76
79
|
}
|
|
80
|
+
},
|
|
81
|
+
// A touch the browser took or lost never ends with a release. Ending the drag with the last
|
|
82
|
+
// value the finger reached lets whoever holds it let go, instead of leaving it held for good.
|
|
83
|
+
onPointerCancel: () => {
|
|
84
|
+
if (dragging.current) {
|
|
85
|
+
dragging.current = false;
|
|
86
|
+
onCommit(lastDrag.current ?? value);
|
|
87
|
+
}
|
|
88
|
+
}, onLostPointerCapture: () => {
|
|
89
|
+
if (dragging.current) {
|
|
90
|
+
dragging.current = false;
|
|
91
|
+
onCommit(lastDrag.current ?? value);
|
|
92
|
+
}
|
|
77
93
|
}, onKeyDown: onKeyDown, children: gradient ? (_jsx(Box, { as: motion.span, position: "absolute", background: "surface", initial: false, animate: { left: `${Math.max(pct, 4)}%` }, transition: { duration: 0 }, css: { top: 0, bottom: 0, right: 0, opacity: 0.72, pointerEvents: 'none' } })) : (_jsx(Box, { as: motion.span, position: "absolute", background: "accent", initial: false, animate: { width: `${pct}%` }, transition: { duration: 0 }, css: { top: 0, bottom: 0, left: 0 } })) }));
|
|
78
94
|
}
|
|
79
95
|
/** One round power icon button with two states: filled with the accent while on, neutral while off. */
|
|
@@ -85,19 +101,31 @@ export const CHIP_HEIGHT = 28;
|
|
|
85
101
|
/** A wrapping row of pill choices (modes, presets) with the selected one filled. With `tabs` it is
|
|
86
102
|
* a tab list (the selected chip is the open tab) that never wraps: too many to fit scroll sideways. */
|
|
87
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();
|
|
88
107
|
// Tabs scroll sideways when there are more than fit, and fade at the edge that has more.
|
|
89
108
|
const Wrap = tabs ? FadeScroll : Flex;
|
|
90
109
|
return (_jsx(Wrap, { gap: 2, ...(tabs ? { role: 'tablist' } : {}), css: tabs
|
|
91
|
-
? {
|
|
92
|
-
|
|
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) => {
|
|
93
117
|
const selected = option.value === value;
|
|
94
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",
|
|
95
119
|
// A fixed height, not the text's: an emoji is drawn from another font with a taller line,
|
|
96
120
|
// which would make this one chip taller than the rest.
|
|
97
|
-
height: CHIP_HEIGHT, px: 3, background:
|
|
121
|
+
height: CHIP_HEIGHT, px: 3, background: "surface", color: selected ? 'accentText' : 'text', css: {
|
|
98
122
|
flex: 'none',
|
|
123
|
+
position: 'relative',
|
|
99
124
|
whiteSpace: 'nowrap',
|
|
100
|
-
transition: '
|
|
101
|
-
}, 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));
|
|
102
130
|
}) }));
|
|
103
131
|
}
|
|
@@ -68,7 +68,7 @@ export function EntityDrawer() {
|
|
|
68
68
|
// its default `min-height: auto` can win out and push it past the stretched height
|
|
69
69
|
// instead of scrolling internally. `height: '100%'` here is well-defined regardless
|
|
70
70
|
// (the wrapper's `inset: 0` gives it an unambiguous content-box size).
|
|
71
|
-
height: "100%", minHeight: 0, maxHeight: "100%", overflow: "auto", p: 5, css: { pointerEvents: 'auto' }, children: [_jsxs(Flex, { justify: "space-between", align: "center", gap: 2.5, mb: 4, children: [detail.header, _jsxs(Flex, { gap: 1.5, grow: 0, children: [_jsx(DrawerActionButton, { icon: expanded ? 'lu:minimize-2' : 'lu:maximize-2', label: expanded ? 'Collapse' : 'Expand', onClick: toggleExpanded }), _jsx(DrawerActionButton, { icon: "lu:x", label: "Close", onClick: closeDetail })] })] }), detail.body] }, "hash-drawer") })] })) : null }));
|
|
71
|
+
height: "100%", minHeight: 0, maxHeight: "100%", overflow: "auto", p: 5, css: { pointerEvents: 'auto' }, children: [_jsxs(Flex, { justify: "space-between", align: "center", gap: 2.5, mb: 4, children: [detail.header, _jsxs(Flex, { gap: 1.5, grow: 0, children: [detail.locked ? null : (_jsx(DrawerActionButton, { icon: expanded ? 'lu:minimize-2' : 'lu:maximize-2', label: expanded ? 'Collapse' : 'Expand', onClick: toggleExpanded })), _jsx(DrawerActionButton, { icon: "lu:x", label: "Close", onClick: closeDetail })] })] }), detail.body] }, "hash-drawer") })] })) : null }));
|
|
72
72
|
}
|
|
73
73
|
const ACTION_BACKGROUND = 'surfaceRaised';
|
|
74
74
|
const ACTION_COLOR = 'text';
|
|
@@ -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
|
+
}
|
|
@@ -1,2 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
export interface HoldProgressProps {
|
|
2
|
+
/** Which edge of the card the line runs along. Default `top`. */
|
|
3
|
+
edge?: 'top' | 'bottom';
|
|
4
|
+
/** A literal CSS color for the line, alpha included. Left out, a half-faded muted text color. */
|
|
5
|
+
color?: string;
|
|
6
|
+
}
|
|
7
|
+
/** The thin line along a card's edge that fills while a press is being timed as a hold. */
|
|
8
|
+
export declare function HoldProgress({ edge, color }: HoldProgressProps): import("@emotion/react/jsx-runtime").JSX.Element;
|
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
import { jsx as _jsx } from "@emotion/react/jsx-runtime";
|
|
2
2
|
/** @jsxImportSource @emotion/react */
|
|
3
|
+
import { keyframes } from '@emotion/react';
|
|
3
4
|
import { Box } from 'e-prim';
|
|
4
|
-
import { motion } from 'motion/react';
|
|
5
5
|
import { HOLD_MS } from './use-hold.js';
|
|
6
|
-
/**
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
/** Grows the line from its left end by scaling it, which the browser animates on its own, without
|
|
7
|
+
* the page's JavaScript or a layout for each frame: on a slow display, animating the width is what
|
|
8
|
+
* made the hold look stuck. */
|
|
9
|
+
const fill = keyframes({
|
|
10
|
+
from: { transform: 'scaleX(0)' },
|
|
11
|
+
to: { transform: 'scaleX(1)' },
|
|
12
|
+
});
|
|
13
|
+
/** The thin line along a card's edge that fills while a press is being timed as a hold. */
|
|
14
|
+
export function HoldProgress({ edge = 'top', color }) {
|
|
15
|
+
return (_jsx(Box, { as: "span", "data-keep-motion": "", position: "absolute", width: "100%", height: 2, ...(color ? {} : { background: 'textMuted' }), css: {
|
|
16
|
+
[edge]: 0,
|
|
17
|
+
left: 0,
|
|
18
|
+
opacity: color ? 1 : 0.5,
|
|
19
|
+
...(color ? { backgroundColor: color } : {}),
|
|
20
|
+
pointerEvents: 'none',
|
|
21
|
+
transformOrigin: 'left center',
|
|
22
|
+
willChange: 'transform',
|
|
23
|
+
animation: `${fill} ${HOLD_MS}ms linear forwards`,
|
|
24
|
+
} }));
|
|
9
25
|
}
|
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] }));
|