@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/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 { createContext, useContext, useEffect, useMemo, useState } from 'react';
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
- /** Resolves `'system'` against the live OS/browser preference, updating if it changes while open —
53
- * a kiosk tablet left running overnight should follow a scheduled OS-level dark mode, for example.
54
- * `override` (set via `useThemeToggle().toggle()`) wins over both until the page reloads. */
55
- function useThemeMode(mode) {
56
- const [systemDark, setSystemDark] = useState(() => mode === 'system' && systemPrefersDark());
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 (mode !== 'system' || typeof window.matchMedia !== 'function') {
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
- }, [mode]);
67
- const configured = mode === 'light' ? 'light' : mode === 'dark' ? 'dark' : systemDark ? 'dark' : 'light';
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
- const themeMode = useThemeMode(theme);
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 },
@@ -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: '#FF7A45',
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
+ }
@@ -42,6 +42,8 @@ declare module 'e-prim' {
42
42
  interface TShadow {
43
43
  /** `EntityDrawer`'s own drop shadow. */
44
44
  drawer: string;
45
+ /** `NavDock`'s: a softer one, for a small pill that floats over the page. */
46
+ dock: string;
45
47
  }
46
48
  interface TZIndex {
47
49
  scrim: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hashsome/ui",
3
- "version": "0.4.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.4.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",