@hadoku/themes 0.0.0-stage → 5.12.15

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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE-lucide +43 -0
  3. package/README.md +433 -2
  4. package/THEME_USAGE_GUIDE.md +308 -0
  5. package/dist/HadokuThemeRoot.d.ts +53 -0
  6. package/dist/HadokuThemeRoot.js +66 -0
  7. package/dist/ThemeIcons.d.ts +31 -0
  8. package/dist/ThemeIcons.js +91 -0
  9. package/dist/effects.d.ts +25 -0
  10. package/dist/effects.js +25 -0
  11. package/dist/icons/Icon.d.ts +36 -0
  12. package/dist/icons/Icon.js +89 -0
  13. package/dist/icons/index.d.ts +61 -0
  14. package/dist/icons/index.js +87 -0
  15. package/dist/icons/registry.generated.d.ts +158 -0
  16. package/dist/icons/registry.generated.js +446 -0
  17. package/dist/index.d.ts +82 -0
  18. package/dist/index.js +169 -0
  19. package/dist/metadata.d.ts +35 -0
  20. package/dist/metadata.js +108 -0
  21. package/dist/platform/index.d.ts +94 -0
  22. package/dist/platform/index.js +149 -0
  23. package/dist/platform/usePlatform.d.ts +29 -0
  24. package/dist/platform/usePlatform.js +65 -0
  25. package/dist/prefsApiBase.d.ts +37 -0
  26. package/dist/prefsApiBase.js +40 -0
  27. package/dist/theme-types.d.ts +34 -0
  28. package/dist/theme-types.js +1 -0
  29. package/dist/themeContext.d.ts +62 -0
  30. package/dist/themeContext.js +70 -0
  31. package/dist/themePrefs.d.ts +29 -0
  32. package/dist/themePrefs.js +48 -0
  33. package/dist/useRefElement.d.ts +19 -0
  34. package/dist/useRefElement.js +52 -0
  35. package/dist/useTheme.d.ts +46 -0
  36. package/dist/useTheme.js +216 -0
  37. package/package.json +83 -4
  38. package/scripts/audit-tokens.mjs +126 -0
  39. package/scripts/check-contrast.mjs +24 -0
  40. package/scripts/check-docs.mjs +53 -0
  41. package/scripts/check-icons.mjs +473 -0
  42. package/scripts/check-usage.mjs +297 -0
  43. package/scripts/codemod-icons.mjs +396 -0
  44. package/scripts/generate-icons.mjs +199 -0
  45. package/scripts/generate-preview.mjs +374 -0
  46. package/scripts/generate-tailwind-colors.mjs +109 -0
  47. package/scripts/lib/color.mjs +184 -0
  48. package/scripts/lib/contract.d.mts +58 -0
  49. package/scripts/lib/contract.mjs +348 -0
  50. package/scripts/lib/derive.mjs +324 -0
  51. package/scripts/lib/icon-registry.mjs +92 -0
  52. package/scripts/lib/parse-themes.mjs +19 -0
  53. package/scripts/lib/tokens.mjs +188 -0
  54. package/scripts/normalize-tokens.mjs +161 -0
  55. package/src/advanced.css +218 -0
  56. package/src/icons/Icon.tsx +151 -0
  57. package/src/icons/emoji-map.json +202 -0
  58. package/src/icons/index.ts +151 -0
  59. package/src/icons/registry.generated.ts +455 -0
  60. package/src/icons/sources.json +150 -0
  61. package/src/icons.css +145 -0
  62. package/src/style.css +1421 -0
  63. package/src/tailwind-colors.css +88 -0
  64. 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
+ };
@@ -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
+ }