@hadoku/themes 0.0.0-stage → 5.12.16
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/LICENSE +21 -0
- package/LICENSE-lucide +43 -0
- package/README.md +433 -2
- package/THEME_USAGE_GUIDE.md +308 -0
- package/dist/HadokuThemeRoot.d.ts +53 -0
- package/dist/HadokuThemeRoot.js +66 -0
- package/dist/ThemeIcons.d.ts +31 -0
- package/dist/ThemeIcons.js +91 -0
- package/dist/effects.d.ts +25 -0
- package/dist/effects.js +25 -0
- package/dist/icons/Icon.d.ts +36 -0
- package/dist/icons/Icon.js +89 -0
- package/dist/icons/index.d.ts +61 -0
- package/dist/icons/index.js +87 -0
- package/dist/icons/registry.generated.d.ts +158 -0
- package/dist/icons/registry.generated.js +446 -0
- package/dist/index.d.ts +82 -0
- package/dist/index.js +169 -0
- package/dist/metadata.d.ts +35 -0
- package/dist/metadata.js +108 -0
- package/dist/platform/index.d.ts +94 -0
- package/dist/platform/index.js +149 -0
- package/dist/platform/usePlatform.d.ts +29 -0
- package/dist/platform/usePlatform.js +65 -0
- package/dist/prefsApiBase.d.ts +37 -0
- package/dist/prefsApiBase.js +40 -0
- package/dist/theme-types.d.ts +34 -0
- package/dist/theme-types.js +1 -0
- package/dist/themeContext.d.ts +62 -0
- package/dist/themeContext.js +70 -0
- package/dist/themePrefs.d.ts +29 -0
- package/dist/themePrefs.js +48 -0
- package/dist/useRefElement.d.ts +19 -0
- package/dist/useRefElement.js +52 -0
- package/dist/useTheme.d.ts +46 -0
- package/dist/useTheme.js +216 -0
- package/package.json +83 -4
- package/scripts/audit-tokens.mjs +126 -0
- package/scripts/check-contrast.mjs +24 -0
- package/scripts/check-docs.mjs +53 -0
- package/scripts/check-icons.mjs +473 -0
- package/scripts/check-usage.mjs +297 -0
- package/scripts/codemod-icons.mjs +396 -0
- package/scripts/generate-icons.mjs +199 -0
- package/scripts/generate-preview.mjs +374 -0
- package/scripts/generate-tailwind-colors.mjs +109 -0
- package/scripts/lib/color.mjs +184 -0
- package/scripts/lib/contract.d.mts +58 -0
- package/scripts/lib/contract.mjs +348 -0
- package/scripts/lib/derive.mjs +324 -0
- package/scripts/lib/icon-registry.mjs +92 -0
- package/scripts/lib/parse-themes.mjs +19 -0
- package/scripts/lib/tokens.mjs +188 -0
- package/scripts/normalize-tokens.mjs +161 -0
- package/src/advanced.css +218 -0
- package/src/icons/Icon.tsx +151 -0
- package/src/icons/emoji-map.json +202 -0
- package/src/icons/index.ts +151 -0
- package/src/icons/registry.generated.ts +455 -0
- package/src/icons/sources.json +150 -0
- package/src/icons.css +145 -0
- package/src/style.css +1421 -0
- package/src/tailwind-colors.css +88 -0
- package/src/tailwind-integration.css +49 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hadoku Theme System - Utilities
|
|
3
|
+
* Framework-agnostic theme management for Hadoku themes
|
|
4
|
+
* 18 beautiful themes across 9 theme families with light/dark variants
|
|
5
|
+
* Now with React integration support!
|
|
6
|
+
*/
|
|
7
|
+
export const THEMES = [
|
|
8
|
+
'light',
|
|
9
|
+
'dark',
|
|
10
|
+
'coffee-light',
|
|
11
|
+
'coffee-dark',
|
|
12
|
+
'nature-light',
|
|
13
|
+
'nature-dark',
|
|
14
|
+
'lavender-light',
|
|
15
|
+
'lavender-dark',
|
|
16
|
+
'strawberry-light',
|
|
17
|
+
'strawberry-dark',
|
|
18
|
+
'ocean-light',
|
|
19
|
+
'ocean-dark',
|
|
20
|
+
'cyberpunk-light',
|
|
21
|
+
'cyberpunk-dark',
|
|
22
|
+
'pink-light',
|
|
23
|
+
'pink-dark',
|
|
24
|
+
'izakaya-light',
|
|
25
|
+
'izakaya-dark'
|
|
26
|
+
];
|
|
27
|
+
/** Read the theme key from one storage, tolerating environments where the
|
|
28
|
+
* storage object exists but throws on access (private mode, blocked cookies).
|
|
29
|
+
* Returns null rather than throwing, so a blocked storage degrades to the
|
|
30
|
+
* browser-preference fallback instead of breaking theme load entirely. */
|
|
31
|
+
function readStored(which) {
|
|
32
|
+
try {
|
|
33
|
+
return window[which].getItem('hadoku-theme');
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Set the active theme
|
|
41
|
+
* @param theme - Theme name
|
|
42
|
+
*/
|
|
43
|
+
export function setTheme(theme) {
|
|
44
|
+
if (theme === 'light') {
|
|
45
|
+
document.documentElement.removeAttribute('data-theme');
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Get the currently active theme
|
|
53
|
+
* @returns Current theme name
|
|
54
|
+
*/
|
|
55
|
+
export function getTheme() {
|
|
56
|
+
const attr = document.documentElement.getAttribute('data-theme');
|
|
57
|
+
return attr || 'light';
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Persist the theme and apply it.
|
|
61
|
+
*
|
|
62
|
+
* BOTH storages, deliberately. sessionStorage is what the pre-paint inline
|
|
63
|
+
* `<head>` script reads within a tab, but it dies with the tab — so writing
|
|
64
|
+
* only there meant a browser restart came back with nothing persisted, the
|
|
65
|
+
* script fell through to the browser preference, and the app then swapped to
|
|
66
|
+
* the real theme once React mounted. A visible default-then-swap flash, and
|
|
67
|
+
* "my theme reset itself" for anyone whose theme wasn't the system default.
|
|
68
|
+
* localStorage is what survives, so it is what makes the next cold load paint
|
|
69
|
+
* correctly the first time.
|
|
70
|
+
*/
|
|
71
|
+
export function saveTheme(theme) {
|
|
72
|
+
try {
|
|
73
|
+
localStorage.setItem('hadoku-theme', theme);
|
|
74
|
+
sessionStorage.setItem('hadoku-theme', theme);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
/* storage unavailable (private mode, blocked cookies) — the theme still
|
|
78
|
+
applies for this page, it just won't survive the navigation. */
|
|
79
|
+
}
|
|
80
|
+
setTheme(theme);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Load the saved theme.
|
|
84
|
+
* @returns Saved theme, or browser preference, or 'light' if none available
|
|
85
|
+
*/
|
|
86
|
+
export function loadTheme() {
|
|
87
|
+
// sessionStorage first — within a tab it is the freshest value (the FOUC
|
|
88
|
+
// script and same-tab writes both go there). localStorage is the fallback
|
|
89
|
+
// that carries a theme across a browser restart.
|
|
90
|
+
const saved = (readStored('sessionStorage') ?? readStored('localStorage'));
|
|
91
|
+
if (saved && THEMES.includes(saved)) {
|
|
92
|
+
setTheme(saved);
|
|
93
|
+
return saved;
|
|
94
|
+
}
|
|
95
|
+
// If no saved theme, respect browser's color scheme preference
|
|
96
|
+
if (typeof window !== 'undefined' && window.matchMedia) {
|
|
97
|
+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
98
|
+
const browserTheme = prefersDark ? 'dark' : 'light';
|
|
99
|
+
setTheme(browserTheme);
|
|
100
|
+
return browserTheme;
|
|
101
|
+
}
|
|
102
|
+
// Final fallback to light
|
|
103
|
+
return 'light';
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Initialize theme system on page load
|
|
107
|
+
* Loads saved theme, respects browser preference, or defaults to light
|
|
108
|
+
*/
|
|
109
|
+
export function initTheme() {
|
|
110
|
+
return loadTheme();
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Clear saved theme (reset to light)
|
|
114
|
+
*/
|
|
115
|
+
export function clearTheme() {
|
|
116
|
+
// Both, to match saveTheme — clearing only sessionStorage left the
|
|
117
|
+
// localStorage copy to resurrect the theme on the next cold load.
|
|
118
|
+
try {
|
|
119
|
+
sessionStorage.removeItem('hadoku-theme');
|
|
120
|
+
localStorage.removeItem('hadoku-theme');
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
/* storage unavailable — nothing was persisted to clear */
|
|
124
|
+
}
|
|
125
|
+
setTheme('light');
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Set the active theme mode (writes data-theme-mode on documentElement).
|
|
129
|
+
* 'advanced' renders the gradient + effects; 'simple' uses flat colors.
|
|
130
|
+
*
|
|
131
|
+
* Nothing calls this with 'advanced' any more: the Simple/Advanced toggle was
|
|
132
|
+
* removed from the picker, and `useTheme` pins the attribute to 'simple' on
|
|
133
|
+
* every theme apply. The mode is not persisted or read back for the same
|
|
134
|
+
* reason — there is no user choice left to remember. The advanced kit itself
|
|
135
|
+
* (advanced.css, THEME_EFFECTS, the hdk-advanced-* class hooks) is untouched
|
|
136
|
+
* and still keys off this attribute, so bringing advanced back means restoring
|
|
137
|
+
* the toggle, not rebuilding the visuals.
|
|
138
|
+
*/
|
|
139
|
+
export function setThemeMode(mode) {
|
|
140
|
+
document.documentElement.setAttribute('data-theme-mode', mode);
|
|
141
|
+
}
|
|
142
|
+
// Theme metadata and React integration (optional peer dependencies)
|
|
143
|
+
export { THEME_FAMILIES, THEME_ICON_MAP } from './metadata.js';
|
|
144
|
+
export { useTheme } from './useTheme.js';
|
|
145
|
+
// The one thing a child app mounts, and the context it fills — deliberately in
|
|
146
|
+
// the same package now (see HadokuThemeRoot.tsx).
|
|
147
|
+
export { HadokuThemeRoot } from './HadokuThemeRoot.js';
|
|
148
|
+
export { HadokuThemeProvider, useHadokuTheme, useHadokuThemeOptional } from './themeContext.js';
|
|
149
|
+
// Theme-family icons — moved here from task-ui-components so this package
|
|
150
|
+
// depends on nothing of ours.
|
|
151
|
+
export { SunIcon, MoonIcon, StrawberryIcon, WaveIcon, ZapIcon, CoffeeIcon, FlowerIcon, HeartIcon, LeafIcon, LanternIcon } from './ThemeIcons.js';
|
|
152
|
+
// Canonical theme prefs client — was copy-pasted into every child app.
|
|
153
|
+
export { themePrefs, ThemePrefsSchema } from './themePrefs.js';
|
|
154
|
+
// Shared by every prefs client in the ecosystem, so a dev/E2E stack can point
|
|
155
|
+
// them all at a local prefs-api with one global instead of mocking the network.
|
|
156
|
+
export { resolvePrefsApiBase, PREFS_API_BASE_GLOBAL } from './prefsApiBase.js';
|
|
157
|
+
export { THEME_EFFECTS, hasAdvanced, getThemeEffects } from './effects.js';
|
|
158
|
+
// Icon module — the enforced ecosystem icon set. Artwork is vendored from lucide
|
|
159
|
+
// (ISC, see LICENSE-lucide) and generated into src/icons/registry.generated.ts, so
|
|
160
|
+
// this package still ships with zero runtime dependencies and serves the Astro/Qwik
|
|
161
|
+
// consumers that have no React at all.
|
|
162
|
+
export { Icon } from './icons/Icon.js';
|
|
163
|
+
export { ICON_MARKUP, ICON_NAMES, ICON_SOURCE_SLUGS, ICON_FAMILIES, LUCIDE_VERSION, getIconSvg, getIconTileClass, isIconName } from './icons/index.js';
|
|
164
|
+
// Platform context — one device definition for the fleet, replacing six
|
|
165
|
+
// hand-rolled `useIsMobile` implementations with four different thresholds.
|
|
166
|
+
// Framework-free half is also published at `@wolffm/themes/platform` for the
|
|
167
|
+
// plain-TS consumers, exactly as the icon module is.
|
|
168
|
+
export { detectPlatform, createPlatform, stampPlatform, isSamePlatform, NARROW_BREAKPOINT, NARROW_QUERY, TOUCH_FIRST_QUERY } from './platform/index.js';
|
|
169
|
+
export { usePlatform } from './platform/usePlatform.js';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme metadata - icon mappings and theme family configurations
|
|
3
|
+
* Icons and the ThemeFamily type are local to this package (see ThemeIcons.tsx).
|
|
4
|
+
*/
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import type { ThemeFamily } from './theme-types';
|
|
7
|
+
/**
|
|
8
|
+
* Theme families configuration for all 9 theme pairs
|
|
9
|
+
* Each family has a light and dark variant with corresponding icons
|
|
10
|
+
*/
|
|
11
|
+
export declare const THEME_FAMILIES: ThemeFamily[];
|
|
12
|
+
/**
|
|
13
|
+
* Theme icon mapping - maps each theme name to its icon component
|
|
14
|
+
* Useful for getting the icon for a specific theme
|
|
15
|
+
*/
|
|
16
|
+
export declare const THEME_ICON_MAP: {
|
|
17
|
+
readonly light: () => React.JSX.Element;
|
|
18
|
+
readonly dark: () => React.JSX.Element;
|
|
19
|
+
readonly 'strawberry-light': () => React.JSX.Element;
|
|
20
|
+
readonly 'strawberry-dark': () => React.JSX.Element;
|
|
21
|
+
readonly 'ocean-light': () => React.JSX.Element;
|
|
22
|
+
readonly 'ocean-dark': () => React.JSX.Element;
|
|
23
|
+
readonly 'cyberpunk-light': () => React.JSX.Element;
|
|
24
|
+
readonly 'cyberpunk-dark': () => React.JSX.Element;
|
|
25
|
+
readonly 'coffee-light': () => React.JSX.Element;
|
|
26
|
+
readonly 'coffee-dark': () => React.JSX.Element;
|
|
27
|
+
readonly 'lavender-light': () => React.JSX.Element;
|
|
28
|
+
readonly 'lavender-dark': () => React.JSX.Element;
|
|
29
|
+
readonly 'nature-light': () => React.JSX.Element;
|
|
30
|
+
readonly 'nature-dark': () => React.JSX.Element;
|
|
31
|
+
readonly 'pink-light': () => React.JSX.Element;
|
|
32
|
+
readonly 'pink-dark': () => React.JSX.Element;
|
|
33
|
+
readonly 'izakaya-light': () => React.JSX.Element;
|
|
34
|
+
readonly 'izakaya-dark': () => React.JSX.Element;
|
|
35
|
+
};
|
package/dist/metadata.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme metadata - icon mappings and theme family configurations
|
|
3
|
+
* Icons and the ThemeFamily type are local to this package (see ThemeIcons.tsx).
|
|
4
|
+
*/
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { SunIcon, MoonIcon, StrawberryIcon, WaveIcon, ZapIcon, CoffeeIcon, FlowerIcon, HeartIcon, LeafIcon, LanternIcon } from './ThemeIcons.js';
|
|
7
|
+
/**
|
|
8
|
+
* Theme families configuration for all 9 theme pairs
|
|
9
|
+
* Each family has a light and dark variant with corresponding icons
|
|
10
|
+
*/
|
|
11
|
+
export const THEME_FAMILIES = [
|
|
12
|
+
{
|
|
13
|
+
lightTheme: 'light',
|
|
14
|
+
darkTheme: 'dark',
|
|
15
|
+
lightLabel: 'Sun',
|
|
16
|
+
darkLabel: 'Night',
|
|
17
|
+
lightIcon: React.createElement(SunIcon, null),
|
|
18
|
+
darkIcon: React.createElement(MoonIcon, null)
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
lightTheme: 'strawberry-light',
|
|
22
|
+
darkTheme: 'strawberry-dark',
|
|
23
|
+
lightLabel: 'Vine Light',
|
|
24
|
+
darkLabel: 'Vine Dark',
|
|
25
|
+
lightIcon: React.createElement(StrawberryIcon, null),
|
|
26
|
+
darkIcon: React.createElement(StrawberryIcon, null)
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
lightTheme: 'ocean-light',
|
|
30
|
+
darkTheme: 'ocean-dark',
|
|
31
|
+
lightLabel: 'Ocean Light',
|
|
32
|
+
darkLabel: 'Ocean Dark',
|
|
33
|
+
lightIcon: React.createElement(WaveIcon, null),
|
|
34
|
+
darkIcon: React.createElement(WaveIcon, null)
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
lightTheme: 'cyberpunk-light',
|
|
38
|
+
darkTheme: 'cyberpunk-dark',
|
|
39
|
+
lightLabel: 'Neon Light',
|
|
40
|
+
darkLabel: 'Neon Dark',
|
|
41
|
+
lightIcon: React.createElement(ZapIcon, null),
|
|
42
|
+
darkIcon: React.createElement(ZapIcon, null)
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
lightTheme: 'coffee-light',
|
|
46
|
+
darkTheme: 'coffee-dark',
|
|
47
|
+
lightLabel: 'Coffee Light',
|
|
48
|
+
darkLabel: 'Coffee Dark',
|
|
49
|
+
lightIcon: React.createElement(CoffeeIcon, null),
|
|
50
|
+
darkIcon: React.createElement(CoffeeIcon, null)
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
lightTheme: 'lavender-light',
|
|
54
|
+
darkTheme: 'lavender-dark',
|
|
55
|
+
lightLabel: 'Lavender Light',
|
|
56
|
+
darkLabel: 'Lavender Dark',
|
|
57
|
+
lightIcon: React.createElement(FlowerIcon, null),
|
|
58
|
+
darkIcon: React.createElement(FlowerIcon, null)
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
lightTheme: 'nature-light',
|
|
62
|
+
darkTheme: 'nature-dark',
|
|
63
|
+
lightLabel: 'Nature Light',
|
|
64
|
+
darkLabel: 'Nature Dark',
|
|
65
|
+
lightIcon: React.createElement(LeafIcon, null),
|
|
66
|
+
darkIcon: React.createElement(LeafIcon, null)
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
lightTheme: 'pink-light',
|
|
70
|
+
darkTheme: 'pink-dark',
|
|
71
|
+
lightLabel: 'Pink Light',
|
|
72
|
+
darkLabel: 'Pink Dark',
|
|
73
|
+
lightIcon: React.createElement(HeartIcon, null),
|
|
74
|
+
darkIcon: React.createElement(HeartIcon, null)
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
lightTheme: 'izakaya-light',
|
|
78
|
+
darkTheme: 'izakaya-dark',
|
|
79
|
+
lightLabel: 'Izakaya Light',
|
|
80
|
+
darkLabel: 'Izakaya Dark',
|
|
81
|
+
lightIcon: React.createElement(LanternIcon, null),
|
|
82
|
+
darkIcon: React.createElement(LanternIcon, null)
|
|
83
|
+
}
|
|
84
|
+
];
|
|
85
|
+
/**
|
|
86
|
+
* Theme icon mapping - maps each theme name to its icon component
|
|
87
|
+
* Useful for getting the icon for a specific theme
|
|
88
|
+
*/
|
|
89
|
+
export const THEME_ICON_MAP = {
|
|
90
|
+
light: SunIcon,
|
|
91
|
+
dark: MoonIcon,
|
|
92
|
+
'strawberry-light': StrawberryIcon,
|
|
93
|
+
'strawberry-dark': StrawberryIcon,
|
|
94
|
+
'ocean-light': WaveIcon,
|
|
95
|
+
'ocean-dark': WaveIcon,
|
|
96
|
+
'cyberpunk-light': ZapIcon,
|
|
97
|
+
'cyberpunk-dark': ZapIcon,
|
|
98
|
+
'coffee-light': CoffeeIcon,
|
|
99
|
+
'coffee-dark': CoffeeIcon,
|
|
100
|
+
'lavender-light': FlowerIcon,
|
|
101
|
+
'lavender-dark': FlowerIcon,
|
|
102
|
+
'nature-light': LeafIcon,
|
|
103
|
+
'nature-dark': LeafIcon,
|
|
104
|
+
'pink-light': HeartIcon,
|
|
105
|
+
'pink-dark': HeartIcon,
|
|
106
|
+
'izakaya-light': LanternIcon,
|
|
107
|
+
'izakaya-dark': LanternIcon
|
|
108
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform context — the one device-detection module for the hadoku ecosystem.
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS REPLACES
|
|
5
|
+
* ------------------
|
|
6
|
+
* Six independent implementations across the fleet, using four different
|
|
7
|
+
* thresholds (768, 820, 900, 1000) to answer three different questions.
|
|
8
|
+
* `hadoku-task` asked about viewport WIDTH and `hadoku-dataplatform` about
|
|
9
|
+
* POINTER TYPE — the same "am I on mobile?" question, answered two ways, and
|
|
10
|
+
* neither app could tell you which one it meant from the name `useIsMobile`.
|
|
11
|
+
* Those two are what this replaces.
|
|
12
|
+
*
|
|
13
|
+
* (The other three turned out on inspection to be component CAPACITY
|
|
14
|
+
* thresholds, each already agreeing with its own stylesheet — pygmalion's 900
|
|
15
|
+
* is "room for three panes", merchant's 1000 is "room for a second column".
|
|
16
|
+
* Those are legitimately local and are NOT candidates for this module. An
|
|
17
|
+
* earlier draft catalogued them as drift; that was a bad read.)
|
|
18
|
+
*
|
|
19
|
+
* The failure this DOES fix is the same one as the eight hand-copied
|
|
20
|
+
* `useTheme.ts` files this package already absorbed — see the header of
|
|
21
|
+
* ../useTheme.tsx — so it gets the same fix: one definition here, a seed passed
|
|
22
|
+
* down by the host on mount, and a shared hook that owns the live value.
|
|
23
|
+
*
|
|
24
|
+
* TWO FLAGS, NOT ONE
|
|
25
|
+
* ------------------
|
|
26
|
+
* `touchFirst` and `narrow` are different questions and a single boolean makes
|
|
27
|
+
* one caller wrong: a phone in landscape is touch-first but not narrow, and a
|
|
28
|
+
* narrow desktop window is narrow but not touch-first. There is deliberately no
|
|
29
|
+
* `isMobile` convenience OR — it would become the default reach and put the
|
|
30
|
+
* fleet back on one blessed threshold under a new name.
|
|
31
|
+
*
|
|
32
|
+
* This module is framework-free (no React import) so plain-TS consumers like
|
|
33
|
+
* hadoku-merchant can use it. The React hook lives in ./usePlatform.
|
|
34
|
+
*/
|
|
35
|
+
export interface PlatformSeed {
|
|
36
|
+
/**
|
|
37
|
+
* Input is finger-first: `(pointer: coarse) and (hover: none)`.
|
|
38
|
+
*
|
|
39
|
+
* True on phones and tablets. False on desktops — INCLUDING touchscreen
|
|
40
|
+
* laptops, whose primary pointer is still a mouse. Keys off the primary
|
|
41
|
+
* INPUT rather than the user-agent string (UAs lie; iPadOS reports a desktop
|
|
42
|
+
* UA) or viewport width (a narrow desktop window is not a phone).
|
|
43
|
+
*
|
|
44
|
+
* Use for: tap targets, hover affordances, gesture vs. drag, control overlays.
|
|
45
|
+
*/
|
|
46
|
+
touchFirst: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Viewport is narrow: `(max-width: 767px)`.
|
|
49
|
+
*
|
|
50
|
+
* Use for: column counts, stacking, lane caps, drawer-vs-pane.
|
|
51
|
+
*/
|
|
52
|
+
narrow: boolean;
|
|
53
|
+
}
|
|
54
|
+
/** The width below which `narrow` is true. 767px is the boundary; this is the
|
|
55
|
+
* Tailwind-style `md` breakpoint the fleet already uses most (hadoku_site
|
|
56
|
+
* alone carries 67 `max-width: 768px` rules). */
|
|
57
|
+
export declare const NARROW_BREAKPOINT = 768;
|
|
58
|
+
export declare const TOUCH_FIRST_QUERY = "(pointer: coarse) and (hover: none)";
|
|
59
|
+
export declare const NARROW_QUERY = "(max-width: 767px)";
|
|
60
|
+
/**
|
|
61
|
+
* Read the platform straight from the media queries.
|
|
62
|
+
*
|
|
63
|
+
* Both flags come from `matchMedia`, deliberately: `hadoku-task`'s hook seeded
|
|
64
|
+
* from `window.innerWidth < 768` and then listened on `(max-width: 767px)` —
|
|
65
|
+
* two mechanisms for one boundary. They disagree under fractional zoom, and
|
|
66
|
+
* `innerWidth` reads stale during iOS `orientationchange`, which is exactly the
|
|
67
|
+
* rotation case the listener exists to catch.
|
|
68
|
+
*/
|
|
69
|
+
export declare function detectPlatform(): PlatformSeed;
|
|
70
|
+
export declare function isSamePlatform(a: PlatformSeed, b: PlatformSeed): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Mirror the flags onto an element as `data-touch-first` / `data-narrow`, so
|
|
73
|
+
* CSS keys off the IDENTICAL source the JS does.
|
|
74
|
+
*
|
|
75
|
+
* A stylesheet writing `[data-narrow] .lane { … }` cannot drift from the hook,
|
|
76
|
+
* because there is only one query left to keep in step. `hadoku-dataplatform`
|
|
77
|
+
* already did exactly this for itself with an `is-mobile` class it set from the
|
|
78
|
+
* same value it branched on — this is that idea with one fleet-wide name.
|
|
79
|
+
* Present/absent rather than `="true"`/`="false"` so the plain attribute
|
|
80
|
+
* selector works.
|
|
81
|
+
*/
|
|
82
|
+
export declare function stampPlatform(el: Element | null | undefined, platform: PlatformSeed): void;
|
|
83
|
+
export interface PlatformStore {
|
|
84
|
+
get(): PlatformSeed;
|
|
85
|
+
/** Fires on every real change. Returns the unsubscribe. */
|
|
86
|
+
subscribe(listener: (platform: PlatformSeed) => void): () => void;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Framework-free equivalent of `usePlatform`, for consumers with no React
|
|
90
|
+
* (hadoku-merchant is plain TS). Attaches its listeners on the first
|
|
91
|
+
* `subscribe` and detaches when the last one unsubscribes, so an unused store
|
|
92
|
+
* costs nothing.
|
|
93
|
+
*/
|
|
94
|
+
export declare function createPlatform(): PlatformStore;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform context — the one device-detection module for the hadoku ecosystem.
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS REPLACES
|
|
5
|
+
* ------------------
|
|
6
|
+
* Six independent implementations across the fleet, using four different
|
|
7
|
+
* thresholds (768, 820, 900, 1000) to answer three different questions.
|
|
8
|
+
* `hadoku-task` asked about viewport WIDTH and `hadoku-dataplatform` about
|
|
9
|
+
* POINTER TYPE — the same "am I on mobile?" question, answered two ways, and
|
|
10
|
+
* neither app could tell you which one it meant from the name `useIsMobile`.
|
|
11
|
+
* Those two are what this replaces.
|
|
12
|
+
*
|
|
13
|
+
* (The other three turned out on inspection to be component CAPACITY
|
|
14
|
+
* thresholds, each already agreeing with its own stylesheet — pygmalion's 900
|
|
15
|
+
* is "room for three panes", merchant's 1000 is "room for a second column".
|
|
16
|
+
* Those are legitimately local and are NOT candidates for this module. An
|
|
17
|
+
* earlier draft catalogued them as drift; that was a bad read.)
|
|
18
|
+
*
|
|
19
|
+
* The failure this DOES fix is the same one as the eight hand-copied
|
|
20
|
+
* `useTheme.ts` files this package already absorbed — see the header of
|
|
21
|
+
* ../useTheme.tsx — so it gets the same fix: one definition here, a seed passed
|
|
22
|
+
* down by the host on mount, and a shared hook that owns the live value.
|
|
23
|
+
*
|
|
24
|
+
* TWO FLAGS, NOT ONE
|
|
25
|
+
* ------------------
|
|
26
|
+
* `touchFirst` and `narrow` are different questions and a single boolean makes
|
|
27
|
+
* one caller wrong: a phone in landscape is touch-first but not narrow, and a
|
|
28
|
+
* narrow desktop window is narrow but not touch-first. There is deliberately no
|
|
29
|
+
* `isMobile` convenience OR — it would become the default reach and put the
|
|
30
|
+
* fleet back on one blessed threshold under a new name.
|
|
31
|
+
*
|
|
32
|
+
* This module is framework-free (no React import) so plain-TS consumers like
|
|
33
|
+
* hadoku-merchant can use it. The React hook lives in ./usePlatform.
|
|
34
|
+
*/
|
|
35
|
+
/** The width below which `narrow` is true. 767px is the boundary; this is the
|
|
36
|
+
* Tailwind-style `md` breakpoint the fleet already uses most (hadoku_site
|
|
37
|
+
* alone carries 67 `max-width: 768px` rules). */
|
|
38
|
+
export const NARROW_BREAKPOINT = 768;
|
|
39
|
+
export const TOUCH_FIRST_QUERY = '(pointer: coarse) and (hover: none)';
|
|
40
|
+
export const NARROW_QUERY = `(max-width: ${NARROW_BREAKPOINT - 1}px)`;
|
|
41
|
+
/** SSG prerender and jsdom-without-a-polyfill both land here. Never throws;
|
|
42
|
+
* all-false means "assume desktop", which is the safe default because every
|
|
43
|
+
* consumer's desktop branch is the unconstrained one. */
|
|
44
|
+
function unsupported() {
|
|
45
|
+
return typeof window === 'undefined' || !window.matchMedia;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Read the platform straight from the media queries.
|
|
49
|
+
*
|
|
50
|
+
* Both flags come from `matchMedia`, deliberately: `hadoku-task`'s hook seeded
|
|
51
|
+
* from `window.innerWidth < 768` and then listened on `(max-width: 767px)` —
|
|
52
|
+
* two mechanisms for one boundary. They disagree under fractional zoom, and
|
|
53
|
+
* `innerWidth` reads stale during iOS `orientationchange`, which is exactly the
|
|
54
|
+
* rotation case the listener exists to catch.
|
|
55
|
+
*/
|
|
56
|
+
export function detectPlatform() {
|
|
57
|
+
if (unsupported())
|
|
58
|
+
return { touchFirst: false, narrow: false };
|
|
59
|
+
return {
|
|
60
|
+
touchFirst: window.matchMedia(TOUCH_FIRST_QUERY).matches,
|
|
61
|
+
narrow: window.matchMedia(NARROW_QUERY).matches
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export function isSamePlatform(a, b) {
|
|
65
|
+
return a.touchFirst === b.touchFirst && a.narrow === b.narrow;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Mirror the flags onto an element as `data-touch-first` / `data-narrow`, so
|
|
69
|
+
* CSS keys off the IDENTICAL source the JS does.
|
|
70
|
+
*
|
|
71
|
+
* A stylesheet writing `[data-narrow] .lane { … }` cannot drift from the hook,
|
|
72
|
+
* because there is only one query left to keep in step. `hadoku-dataplatform`
|
|
73
|
+
* already did exactly this for itself with an `is-mobile` class it set from the
|
|
74
|
+
* same value it branched on — this is that idea with one fleet-wide name.
|
|
75
|
+
* Present/absent rather than `="true"`/`="false"` so the plain attribute
|
|
76
|
+
* selector works.
|
|
77
|
+
*/
|
|
78
|
+
export function stampPlatform(el, platform) {
|
|
79
|
+
if (!el)
|
|
80
|
+
return;
|
|
81
|
+
for (const [attr, on] of [
|
|
82
|
+
['data-touch-first', platform.touchFirst],
|
|
83
|
+
['data-narrow', platform.narrow]
|
|
84
|
+
]) {
|
|
85
|
+
if (on)
|
|
86
|
+
el.setAttribute(attr, '');
|
|
87
|
+
else
|
|
88
|
+
el.removeAttribute(attr);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Framework-free equivalent of `usePlatform`, for consumers with no React
|
|
93
|
+
* (hadoku-merchant is plain TS). Attaches its listeners on the first
|
|
94
|
+
* `subscribe` and detaches when the last one unsubscribes, so an unused store
|
|
95
|
+
* costs nothing.
|
|
96
|
+
*/
|
|
97
|
+
export function createPlatform() {
|
|
98
|
+
let current = detectPlatform();
|
|
99
|
+
const listeners = new Set();
|
|
100
|
+
let queries = [];
|
|
101
|
+
const sync = () => {
|
|
102
|
+
const next = detectPlatform();
|
|
103
|
+
if (isSamePlatform(current, next))
|
|
104
|
+
return;
|
|
105
|
+
current = next;
|
|
106
|
+
for (const listener of listeners)
|
|
107
|
+
listener(current);
|
|
108
|
+
};
|
|
109
|
+
const attach = () => {
|
|
110
|
+
if (unsupported())
|
|
111
|
+
return;
|
|
112
|
+
queries = [window.matchMedia(TOUCH_FIRST_QUERY), window.matchMedia(NARROW_QUERY)];
|
|
113
|
+
for (const mq of queries) {
|
|
114
|
+
// Safari < 14 only exposes the deprecated addListener signature.
|
|
115
|
+
if (mq.addEventListener)
|
|
116
|
+
mq.addEventListener('change', sync);
|
|
117
|
+
else
|
|
118
|
+
mq.addListener(sync);
|
|
119
|
+
}
|
|
120
|
+
// Resync: the queries may have changed between construction and the first
|
|
121
|
+
// subscribe, and that change fired before anything was listening.
|
|
122
|
+
sync();
|
|
123
|
+
};
|
|
124
|
+
const detach = () => {
|
|
125
|
+
for (const mq of queries) {
|
|
126
|
+
if (mq.removeEventListener)
|
|
127
|
+
mq.removeEventListener('change', sync);
|
|
128
|
+
else
|
|
129
|
+
mq.removeListener(sync);
|
|
130
|
+
}
|
|
131
|
+
queries = [];
|
|
132
|
+
};
|
|
133
|
+
return {
|
|
134
|
+
get: () => current,
|
|
135
|
+
subscribe(listener) {
|
|
136
|
+
// Add BEFORE attaching: `attach` ends with a repair `sync()`, and a
|
|
137
|
+
// listener registered after it would miss the very change that sync
|
|
138
|
+
// exists to surface.
|
|
139
|
+
listeners.add(listener);
|
|
140
|
+
if (listeners.size === 1)
|
|
141
|
+
attach();
|
|
142
|
+
return () => {
|
|
143
|
+
listeners.delete(listener);
|
|
144
|
+
if (listeners.size === 0)
|
|
145
|
+
detach();
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usePlatform — the React half of the platform contract. See ./index.ts for why
|
|
3
|
+
* this exists and what it replaces.
|
|
4
|
+
*
|
|
5
|
+
* Shape deliberately mirrors `useTheme`: a seed passed down by the host on
|
|
6
|
+
* mount, and a hook that owns every value after that.
|
|
7
|
+
*/
|
|
8
|
+
import { type RefObject } from 'react';
|
|
9
|
+
import { type PlatformSeed } from './index';
|
|
10
|
+
export interface UsePlatformOptions {
|
|
11
|
+
/**
|
|
12
|
+
* Platform seeded by a parent shell, from its mount props.
|
|
13
|
+
*
|
|
14
|
+
* A SEED, not an override — unlike `UseThemeOptions.propsTheme`, which wins
|
|
15
|
+
* over everything. The host and the app are in the same document looking at
|
|
16
|
+
* the same viewport, so the host has no privileged knowledge here; the seed
|
|
17
|
+
* only saves the first render from a flash of the wrong layout. The media
|
|
18
|
+
* queries take over from the first effect onward, which is what keeps a
|
|
19
|
+
* rotation from being lost.
|
|
20
|
+
*/
|
|
21
|
+
propsPlatform?: PlatformSeed;
|
|
22
|
+
/**
|
|
23
|
+
* Mirror `data-touch-first` / `data-narrow` onto a container element, so this
|
|
24
|
+
* app's CSS can key off the same source as its JS. Same escape hatch
|
|
25
|
+
* `useTheme` gives micro-frontend mounts via `containerRef`.
|
|
26
|
+
*/
|
|
27
|
+
containerRef?: RefObject<HTMLElement | null>;
|
|
28
|
+
}
|
|
29
|
+
export declare function usePlatform(options?: UsePlatformOptions): PlatformSeed;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usePlatform — the React half of the platform contract. See ./index.ts for why
|
|
3
|
+
* this exists and what it replaces.
|
|
4
|
+
*
|
|
5
|
+
* Shape deliberately mirrors `useTheme`: a seed passed down by the host on
|
|
6
|
+
* mount, and a hook that owns every value after that.
|
|
7
|
+
*/
|
|
8
|
+
import { useState, useEffect, useRef } from 'react';
|
|
9
|
+
import { detectPlatform, isSamePlatform, stampPlatform, TOUCH_FIRST_QUERY, NARROW_QUERY } from './index.js';
|
|
10
|
+
import { useRefElement } from '../useRefElement.js';
|
|
11
|
+
export function usePlatform(options = {}) {
|
|
12
|
+
const { propsPlatform, containerRef } = options;
|
|
13
|
+
// The seed governs the first render only. When there is no seed — a
|
|
14
|
+
// standalone mount, a Capacitor shell, an older host that predates the prop —
|
|
15
|
+
// this detects for itself, so there is no "missing prop" branch for an app
|
|
16
|
+
// author to get wrong.
|
|
17
|
+
const [platform, setPlatform] = useState(() => propsPlatform ?? detectPlatform());
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (typeof window === 'undefined' || !window.matchMedia)
|
|
20
|
+
return;
|
|
21
|
+
const queries = [window.matchMedia(TOUCH_FIRST_QUERY), window.matchMedia(NARROW_QUERY)];
|
|
22
|
+
const sync = () => {
|
|
23
|
+
const next = detectPlatform();
|
|
24
|
+
// Returning the previous object bails out of the re-render, so the
|
|
25
|
+
// resync below is free when the seed was already right.
|
|
26
|
+
setPlatform(prev => (isSamePlatform(prev, next) ? prev : next));
|
|
27
|
+
};
|
|
28
|
+
for (const mq of queries) {
|
|
29
|
+
// Safari < 14 only exposes the deprecated addListener signature.
|
|
30
|
+
if (mq.addEventListener)
|
|
31
|
+
mq.addEventListener('change', sync);
|
|
32
|
+
else
|
|
33
|
+
mq.addListener(sync);
|
|
34
|
+
}
|
|
35
|
+
// Repair a stale seed. The host computes it at DOMContentLoaded and the
|
|
36
|
+
// bundle is then imported dynamically, so a rotation inside that window
|
|
37
|
+
// fires its `change` event before anything is listening — without this the
|
|
38
|
+
// app would stay on the wrong value until the NEXT change.
|
|
39
|
+
sync();
|
|
40
|
+
return () => {
|
|
41
|
+
for (const mq of queries) {
|
|
42
|
+
if (mq.removeEventListener)
|
|
43
|
+
mq.removeEventListener('change', sync);
|
|
44
|
+
else
|
|
45
|
+
mq.removeListener(sync);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}, []);
|
|
49
|
+
// Stamp on every change. No cleanup here on purpose — clearing and
|
|
50
|
+
// re-setting on every flip would churn the attribute for no reason.
|
|
51
|
+
// State, not the ref: the container can attach after the first stamp (#102).
|
|
52
|
+
const container = useRefElement(containerRef);
|
|
53
|
+
const stampedRef = useRef(null);
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
stampedRef.current = container;
|
|
56
|
+
stampPlatform(container, platform);
|
|
57
|
+
}, [container, platform]);
|
|
58
|
+
// Clear on unmount only. The host's mount root outlives the app it holds —
|
|
59
|
+
// mf-loader re-mounts watchparty into the same element — so leaving the
|
|
60
|
+
// attributes behind would hand the next tenant a stale value.
|
|
61
|
+
useEffect(() => () => {
|
|
62
|
+
stampPlatform(stampedRef.current, { touchFirst: false, narrow: false });
|
|
63
|
+
}, []);
|
|
64
|
+
return platform;
|
|
65
|
+
}
|