@hashsome/ui 0.4.0 → 0.5.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/entities/artwork-ring.js +11 -0
- package/dist/entities/light-tile.d.ts +3 -1
- package/dist/entities/light-tile.js +29 -20
- package/dist/entities/media-player-bar.d.ts +9 -1
- package/dist/entities/media-player-bar.js +122 -12
- 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/seek-line.js +12 -0
- package/dist/entities/top-bar.d.ts +21 -0
- package/dist/entities/top-bar.js +13 -8
- package/dist/gallery/gallery.js +16 -3
- package/dist/gallery/props-data.js +86 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/layout/animated-outlet.d.ts +14 -0
- package/dist/layout/animated-outlet.js +44 -0
- package/dist/layout/detail-provider.d.ts +4 -2
- package/dist/layout/detail-provider.js +2 -2
- package/dist/layout/drawer-controls.js +18 -2
- package/dist/layout/entity-drawer.js +1 -1
- package/dist/layout/hold-progress.d.ts +8 -2
- package/dist/layout/hold-progress.js +20 -4
- package/dist/layout/tile.js +6 -10
- package/dist/layout/use-drawer.d.ts +3 -2
- package/dist/layout/use-drawer.js +5 -3
- package/dist/provider.d.ts +10 -3
- package/dist/provider.js +124 -11
- package/dist/theme/global-styles.js +3 -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
package/dist/provider.js
CHANGED
|
@@ -2,12 +2,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { RemoteClient } from '@hashsome/core';
|
|
3
3
|
import { Global, ThemeProvider as EmotionThemeProvider } from '@emotion/react';
|
|
4
4
|
import { ThemeProvider } from 'e-prim';
|
|
5
|
-
import {
|
|
5
|
+
import { MotionGlobalConfig } from 'motion/react';
|
|
6
|
+
import { createContext, useContext, useEffect, useMemo, useState, useSyncExternalStore, } from 'react';
|
|
6
7
|
import { DetailProvider } from './layout/detail-provider.js';
|
|
7
8
|
import { EntityDrawer } from './layout/entity-drawer.js';
|
|
8
9
|
import { UNITS_PER_SPACE } from './theme/density.js';
|
|
9
10
|
import { globalStyles } from './theme/global-styles.js';
|
|
10
11
|
import { applyThemeOverrides, densityTokens } from './theme/overrides.js';
|
|
12
|
+
import { isDarkAt, msUntilSwitch, sunIsDown } from './theme/schedule.js';
|
|
11
13
|
import { DEFAULT_FONT, darkTheme, googleFontHref, lightTheme, withFontFamily, } from './theme/index.js';
|
|
12
14
|
const HashsomeContext = createContext(null);
|
|
13
15
|
/** Loads a Google Fonts family at runtime via a single `<link>` this hook owns and reuses (keyed
|
|
@@ -49,29 +51,140 @@ export function useThemeToggle() {
|
|
|
49
51
|
}
|
|
50
52
|
return value;
|
|
51
53
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
*
|
|
55
|
-
function
|
|
56
|
-
|
|
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() {
|
|
58
|
+
try {
|
|
59
|
+
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;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Settles motion once, when the app starts, before anything renders: in `reduced` every animation
|
|
76
|
+
* motion would run is skipped, and a flag on the page lets the global styles switch off CSS
|
|
77
|
+
* transitions and animations too. */
|
|
78
|
+
function useMotionMode(preference) {
|
|
79
|
+
const [mode] = useState(() => {
|
|
80
|
+
const chosen = motionFromDevice() ?? preference;
|
|
81
|
+
MotionGlobalConfig.skipAnimations = chosen === 'reduced';
|
|
82
|
+
if (typeof document !== 'undefined') {
|
|
83
|
+
if (chosen === 'reduced') {
|
|
84
|
+
document.documentElement.dataset.motion = 'reduced';
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
delete document.documentElement.dataset.motion;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return chosen;
|
|
91
|
+
});
|
|
92
|
+
return mode;
|
|
93
|
+
}
|
|
94
|
+
const SUN_KEY = 'hashsome:sun-down';
|
|
95
|
+
/** The last answer a sun entity gave, kept across page loads so a reload at night does not start light. */
|
|
96
|
+
function rememberedSun() {
|
|
97
|
+
try {
|
|
98
|
+
const stored = localStorage.getItem(SUN_KEY);
|
|
99
|
+
return stored === null ? undefined : stored === '1';
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Resolves the configured mode: `'system'` against the live OS/browser preference (updating if it
|
|
106
|
+
* changes while open — a kiosk tablet left running overnight should follow a scheduled OS-level dark
|
|
107
|
+
* mode, for example), a time range against the clock (re-checked at each boundary and whenever the
|
|
108
|
+
* page is shown again, since a sleeping tablet misses timers) and a sun entity against what it says.
|
|
109
|
+
* `override` (set via `useThemeToggle().toggle()`) wins over all of them until the page reloads. */
|
|
110
|
+
function useThemeMode(mode, client) {
|
|
111
|
+
const range = typeof mode === 'object' && 'dark' in mode ? mode.dark : undefined;
|
|
112
|
+
const from = range?.from;
|
|
113
|
+
const to = range?.to;
|
|
114
|
+
const sunRef = typeof mode === 'object' && 'sun' in mode ? mode.sun : undefined;
|
|
115
|
+
const followsSystem = mode === 'system' || sunRef !== undefined;
|
|
116
|
+
const [systemDark, setSystemDark] = useState(() => followsSystem && systemPrefersDark());
|
|
57
117
|
const [override, setOverride] = useState(null);
|
|
118
|
+
const [now, setNow] = useState(() => new Date());
|
|
58
119
|
useEffect(() => {
|
|
59
|
-
if (
|
|
120
|
+
if (!followsSystem || typeof window.matchMedia !== 'function') {
|
|
60
121
|
return;
|
|
61
122
|
}
|
|
62
123
|
const query = window.matchMedia('(prefers-color-scheme: dark)');
|
|
63
124
|
const onChange = () => setSystemDark(query.matches);
|
|
64
125
|
query.addEventListener('change', onChange);
|
|
65
126
|
return () => query.removeEventListener('change', onChange);
|
|
66
|
-
}, [
|
|
67
|
-
|
|
127
|
+
}, [followsSystem]);
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (from === undefined || to === undefined) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const bounds = { from, to };
|
|
133
|
+
let timer;
|
|
134
|
+
const arm = () => {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
const wait = msUntilSwitch(bounds, new Date());
|
|
137
|
+
if (Number.isFinite(wait)) {
|
|
138
|
+
// A moment past the boundary, so the clock has certainly crossed it.
|
|
139
|
+
timer = setTimeout(refresh, wait + 500);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
const refresh = () => {
|
|
143
|
+
setNow(new Date());
|
|
144
|
+
arm();
|
|
145
|
+
};
|
|
146
|
+
const onVisible = () => document.visibilityState === 'visible' && refresh();
|
|
147
|
+
refresh();
|
|
148
|
+
document.addEventListener('visibilitychange', onVisible);
|
|
149
|
+
return () => {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
document.removeEventListener('visibilitychange', onVisible);
|
|
152
|
+
};
|
|
153
|
+
}, [from, to]);
|
|
154
|
+
const sun = useSyncExternalStore((onChange) => (sunRef ? client.subscribe(sunRef, onChange) : () => undefined), () => (sunRef ? client.getEntity(sunRef) : undefined), () => undefined);
|
|
155
|
+
const sunDown = sunIsDown(sun);
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (sunDown === undefined) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
localStorage.setItem(SUN_KEY, sunDown ? '1' : '0');
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// Without storage the next load just starts from the system preference again.
|
|
165
|
+
}
|
|
166
|
+
}, [sunDown]);
|
|
167
|
+
let configured;
|
|
168
|
+
if (mode === 'light' || mode === 'dark') {
|
|
169
|
+
configured = mode;
|
|
170
|
+
}
|
|
171
|
+
else if (range) {
|
|
172
|
+
configured = isDarkAt(range, now) ? 'dark' : 'light';
|
|
173
|
+
}
|
|
174
|
+
else if (sunRef) {
|
|
175
|
+
configured = (sunDown ?? rememberedSun() ?? systemDark) ? 'dark' : 'light';
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
configured = systemDark ? 'dark' : 'light';
|
|
179
|
+
}
|
|
68
180
|
const resolved = override ?? configured;
|
|
69
181
|
return { resolved, toggle: () => setOverride(resolved === 'dark' ? 'light' : 'dark') };
|
|
70
182
|
}
|
|
71
183
|
/** Connects the tree to the runtime proxy. Render only on the client. */
|
|
72
|
-
export function HashsomeProvider({ url, client, clientOptions, theme = 'dark', font = DEFAULT_FONT, density = 'comfortable', overrides, children, }) {
|
|
184
|
+
export function HashsomeProvider({ url, client, clientOptions, theme = 'dark', motion = 'auto', font = DEFAULT_FONT, density = 'comfortable', overrides, children, }) {
|
|
73
185
|
const instance = useMemo(() => client ?? new RemoteClient({ url: url ?? defaultUrl(), ...clientOptions }), [client, url, clientOptions]);
|
|
74
|
-
|
|
186
|
+
useMotionMode(motion);
|
|
187
|
+
const themeMode = useThemeMode(theme, instance);
|
|
75
188
|
// Memoized: Emotion recomputes the merged theme (and every `css` prop) when the function changes.
|
|
76
189
|
const withDensity = useMemo(() => (outer) => ({ ...outer, density: densityTokens(density, overrides) }), [density, overrides]);
|
|
77
190
|
useGoogleFont(font);
|
|
@@ -20,6 +20,9 @@ export function globalStyles({ palette, typography }) {
|
|
|
20
20
|
color: palette.text,
|
|
21
21
|
fontFamily: typography.default.fontFamily,
|
|
22
22
|
},
|
|
23
|
+
// `?motion=reduced` (see `HashsomeProvider`): no CSS transitions or animations, except the ones
|
|
24
|
+
// that are a cue the user waits for (a hold's progress line, marked `data-keep-motion`).
|
|
25
|
+
'html[data-motion="reduced"] *:not([data-keep-motion]), html[data-motion="reduced"] *:not([data-keep-motion])::before, html[data-motion="reduced"] *:not([data-keep-motion])::after': { transition: 'none !important', animation: 'none !important' },
|
|
23
26
|
button: { font: 'inherit', color: 'inherit' },
|
|
24
27
|
// Buttons and inputs start borderless; one that wants a border asks for it with the `border` prop.
|
|
25
28
|
'button, input': { border: 0 },
|
package/dist/theme/index.js
CHANGED
|
@@ -38,6 +38,7 @@ const shared = {
|
|
|
38
38
|
},
|
|
39
39
|
shadow: {
|
|
40
40
|
drawer: '0 20px 48px rgba(0, 0, 0, 0.5)',
|
|
41
|
+
dock: '0 6px 20px rgba(0, 0, 0, 0.16)',
|
|
41
42
|
},
|
|
42
43
|
radius: {
|
|
43
44
|
full: '999px',
|
|
@@ -75,7 +76,7 @@ export const darkTheme = {
|
|
|
75
76
|
surfaceRaised: '#211E26',
|
|
76
77
|
text: '#F2EFEA',
|
|
77
78
|
textMuted: '#96908C',
|
|
78
|
-
accent: '#
|
|
79
|
+
accent: '#B85C38',
|
|
79
80
|
accentText: '#FFFFFF',
|
|
80
81
|
onAccent: '#1B1B1F',
|
|
81
82
|
warm: '#E3B341',
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { EntityRef } from '@hashsome/core';
|
|
2
|
+
/**
|
|
3
|
+
* When the dark theme is on, for a display that should change with the time of day:
|
|
4
|
+
* - `{ dark: { from: '19:00', to: '07:00' } }`: dark between two times of day on the display's own
|
|
5
|
+
* clock. `to` may be earlier than `from` (it is the next morning); the same time twice means never dark.
|
|
6
|
+
* - `{ sun: 'ha:sun.sun' }`: dark while a `daylight` sensor (the sun entity: `on` while the sun is up) is off.
|
|
7
|
+
*/
|
|
8
|
+
export type ThemeSchedule = {
|
|
9
|
+
dark: {
|
|
10
|
+
from: string;
|
|
11
|
+
to: string;
|
|
12
|
+
};
|
|
13
|
+
} | {
|
|
14
|
+
sun: EntityRef;
|
|
15
|
+
};
|
|
16
|
+
/** Minutes after midnight for `HH:MM`. Throws on anything else, naming what was wrong. */
|
|
17
|
+
export declare function minutesOf(time: string, name: string): number;
|
|
18
|
+
/** Whether `now` is inside the dark hours. */
|
|
19
|
+
export declare function isDarkAt(range: {
|
|
20
|
+
from: string;
|
|
21
|
+
to: string;
|
|
22
|
+
}, now: Date): boolean;
|
|
23
|
+
/** How long until the theme next changes, in ms; `Infinity` when it never does. */
|
|
24
|
+
export declare function msUntilSwitch(range: {
|
|
25
|
+
from: string;
|
|
26
|
+
to: string;
|
|
27
|
+
}, now: Date): number;
|
|
28
|
+
/** What a `daylight` sensor says (`on` while the sun is up): dark once it is `off`. `undefined` for anything else (not loaded yet, unavailable, not that kind of sensor). */
|
|
29
|
+
export declare function sunIsDown(entity: {
|
|
30
|
+
kind: string;
|
|
31
|
+
value?: string;
|
|
32
|
+
measurement?: string;
|
|
33
|
+
} | null | undefined): boolean | undefined;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const TIME = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
2
|
+
/** Minutes after midnight for `HH:MM`. Throws on anything else, naming what was wrong. */
|
|
3
|
+
export function minutesOf(time, name) {
|
|
4
|
+
const match = TIME.exec(time);
|
|
5
|
+
if (!match) {
|
|
6
|
+
throw new Error(`theme.dark.${name} must be a time like "19:00", not "${time}"`);
|
|
7
|
+
}
|
|
8
|
+
return Number(match[1]) * 60 + Number(match[2]);
|
|
9
|
+
}
|
|
10
|
+
const minutesNow = (now) => now.getHours() * 60 + now.getMinutes();
|
|
11
|
+
/** Whether `now` is inside the dark hours. */
|
|
12
|
+
export function isDarkAt(range, now) {
|
|
13
|
+
const from = minutesOf(range.from, 'from');
|
|
14
|
+
const to = minutesOf(range.to, 'to');
|
|
15
|
+
const at = minutesNow(now);
|
|
16
|
+
if (from === to) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
// Dark hours that run past midnight are everything except the daytime between `to` and `from`.
|
|
20
|
+
return from < to ? at >= from && at < to : at >= from || at < to;
|
|
21
|
+
}
|
|
22
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
23
|
+
/** How long until the theme next changes, in ms; `Infinity` when it never does. */
|
|
24
|
+
export function msUntilSwitch(range, now) {
|
|
25
|
+
const from = minutesOf(range.from, 'from');
|
|
26
|
+
const to = minutesOf(range.to, 'to');
|
|
27
|
+
if (from === to) {
|
|
28
|
+
return Infinity;
|
|
29
|
+
}
|
|
30
|
+
const wait = (minutes) => {
|
|
31
|
+
const at = new Date(now);
|
|
32
|
+
at.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
|
|
33
|
+
const ms = at.getTime() - now.getTime();
|
|
34
|
+
return ms > 0 ? ms : ms + DAY_MS;
|
|
35
|
+
};
|
|
36
|
+
return Math.min(wait(from), wait(to));
|
|
37
|
+
}
|
|
38
|
+
/** What a `daylight` sensor says (`on` while the sun is up): dark once it is `off`. `undefined` for anything else (not loaded yet, unavailable, not that kind of sensor). */
|
|
39
|
+
export function sunIsDown(entity) {
|
|
40
|
+
if (entity?.kind !== 'sensor' || entity.measurement !== 'daylight') {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
return entity.value === 'off' ? true : entity.value === 'on' ? false : undefined;
|
|
44
|
+
}
|
package/dist/theme/tokens.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hashsome/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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.5.0",
|
|
20
20
|
"e-prim": "2.0.1",
|
|
21
21
|
"motion": "13.4.5",
|
|
22
22
|
"react": "19.3.0",
|