@hadoku/task-ui-components 0.0.0-stage → 4.20.4

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 (169) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +917 -2
  3. package/dist/app-header.css +288 -0
  4. package/dist/bento.css +143 -0
  5. package/dist/button.css +227 -0
  6. package/dist/components/AppHeader.d.ts +61 -0
  7. package/dist/components/AppHeader.d.ts.map +1 -0
  8. package/dist/components/AppHeader.js +23 -0
  9. package/dist/components/AppHeader.js.map +1 -0
  10. package/dist/components/BentoCard.d.ts +23 -0
  11. package/dist/components/BentoCard.d.ts.map +1 -0
  12. package/dist/components/BentoCard.js +49 -0
  13. package/dist/components/BentoCard.js.map +1 -0
  14. package/dist/components/BentoGrid.d.ts +24 -0
  15. package/dist/components/BentoGrid.d.ts.map +1 -0
  16. package/dist/components/BentoGrid.js +22 -0
  17. package/dist/components/BentoGrid.js.map +1 -0
  18. package/dist/components/Button.d.ts +28 -0
  19. package/dist/components/Button.d.ts.map +1 -0
  20. package/dist/components/Button.js +51 -0
  21. package/dist/components/Button.js.map +1 -0
  22. package/dist/components/ConnectedSettings.d.ts +53 -0
  23. package/dist/components/ConnectedSettings.d.ts.map +1 -0
  24. package/dist/components/ConnectedSettings.js +368 -0
  25. package/dist/components/ConnectedSettings.js.map +1 -0
  26. package/dist/components/ConnectedThemePicker.d.ts +52 -0
  27. package/dist/components/ConnectedThemePicker.d.ts.map +1 -0
  28. package/dist/components/ConnectedThemePicker.js +32 -0
  29. package/dist/components/ConnectedThemePicker.js.map +1 -0
  30. package/dist/components/ContextMenu.d.ts +22 -0
  31. package/dist/components/ContextMenu.d.ts.map +1 -0
  32. package/dist/components/ContextMenu.js +23 -0
  33. package/dist/components/ContextMenu.js.map +1 -0
  34. package/dist/components/HadokuThemePicker.d.ts +27 -0
  35. package/dist/components/HadokuThemePicker.d.ts.map +1 -0
  36. package/dist/components/HadokuThemePicker.js +19 -0
  37. package/dist/components/HadokuThemePicker.js.map +1 -0
  38. package/dist/components/LoadingSkeleton.d.ts +14 -0
  39. package/dist/components/LoadingSkeleton.d.ts.map +1 -0
  40. package/dist/components/LoadingSkeleton.js +62 -0
  41. package/dist/components/LoadingSkeleton.js.map +1 -0
  42. package/dist/components/Modal.d.ts +39 -0
  43. package/dist/components/Modal.d.ts.map +1 -0
  44. package/dist/components/Modal.js +106 -0
  45. package/dist/components/Modal.js.map +1 -0
  46. package/dist/components/NumberInput.d.ts +26 -0
  47. package/dist/components/NumberInput.d.ts.map +1 -0
  48. package/dist/components/NumberInput.js +131 -0
  49. package/dist/components/NumberInput.js.map +1 -0
  50. package/dist/components/Select.d.ts +52 -0
  51. package/dist/components/Select.d.ts.map +1 -0
  52. package/dist/components/Select.js +281 -0
  53. package/dist/components/Select.js.map +1 -0
  54. package/dist/components/SettingsModal.d.ts +69 -0
  55. package/dist/components/SettingsModal.d.ts.map +1 -0
  56. package/dist/components/SettingsModal.js +141 -0
  57. package/dist/components/SettingsModal.js.map +1 -0
  58. package/dist/components/ThemeIcons.d.ts +26 -0
  59. package/dist/components/ThemeIcons.d.ts.map +1 -0
  60. package/dist/components/ThemeIcons.js +74 -0
  61. package/dist/components/ThemeIcons.js.map +1 -0
  62. package/dist/components/ThemePicker.d.ts +28 -0
  63. package/dist/components/ThemePicker.d.ts.map +1 -0
  64. package/dist/components/ThemePicker.js +101 -0
  65. package/dist/components/ThemePicker.js.map +1 -0
  66. package/dist/components/Toast.d.ts +13 -0
  67. package/dist/components/Toast.d.ts.map +1 -0
  68. package/dist/components/Toast.js +70 -0
  69. package/dist/components/Toast.js.map +1 -0
  70. package/dist/components/Toaster.d.ts +19 -0
  71. package/dist/components/Toaster.d.ts.map +1 -0
  72. package/dist/components/Toaster.js +19 -0
  73. package/dist/components/Toaster.js.map +1 -0
  74. package/dist/components/Tooltip.d.ts +49 -0
  75. package/dist/components/Tooltip.d.ts.map +1 -0
  76. package/dist/components/Tooltip.js +143 -0
  77. package/dist/components/Tooltip.js.map +1 -0
  78. package/dist/context-menu.css +11 -0
  79. package/dist/controls.css +137 -0
  80. package/dist/hooks/useReducedMotion.d.ts +7 -0
  81. package/dist/hooks/useReducedMotion.d.ts.map +1 -0
  82. package/dist/hooks/useReducedMotion.js +25 -0
  83. package/dist/hooks/useReducedMotion.js.map +1 -0
  84. package/dist/hooks/useToast.d.ts +18 -0
  85. package/dist/hooks/useToast.d.ts.map +1 -0
  86. package/dist/hooks/useToast.js +28 -0
  87. package/dist/hooks/useToast.js.map +1 -0
  88. package/dist/icon-btn.css +64 -0
  89. package/dist/index.d.ts +49 -0
  90. package/dist/index.d.ts.map +1 -0
  91. package/dist/index.js +75 -0
  92. package/dist/index.js.map +1 -0
  93. package/dist/lib/menuPlacement.d.ts +16 -0
  94. package/dist/lib/menuPlacement.d.ts.map +1 -0
  95. package/dist/lib/menuPlacement.js +19 -0
  96. package/dist/lib/menuPlacement.js.map +1 -0
  97. package/dist/lib/selectMenus.d.ts +30 -0
  98. package/dist/lib/selectMenus.d.ts.map +1 -0
  99. package/dist/lib/selectMenus.js +236 -0
  100. package/dist/lib/selectMenus.js.map +1 -0
  101. package/dist/lib/settingsClient.d.ts +144 -0
  102. package/dist/lib/settingsClient.d.ts.map +1 -0
  103. package/dist/lib/settingsClient.js +157 -0
  104. package/dist/lib/settingsClient.js.map +1 -0
  105. package/dist/lib/themeScope.d.ts +19 -0
  106. package/dist/lib/themeScope.d.ts.map +1 -0
  107. package/dist/lib/themeScope.js +10 -0
  108. package/dist/lib/themeScope.js.map +1 -0
  109. package/dist/lib/titleTooltips.d.ts +38 -0
  110. package/dist/lib/titleTooltips.d.ts.map +1 -0
  111. package/dist/lib/titleTooltips.js +166 -0
  112. package/dist/lib/titleTooltips.js.map +1 -0
  113. package/dist/lib/tooltipPlacement.d.ts +14 -0
  114. package/dist/lib/tooltipPlacement.d.ts.map +1 -0
  115. package/dist/lib/tooltipPlacement.js +34 -0
  116. package/dist/lib/tooltipPlacement.js.map +1 -0
  117. package/dist/modal.css +129 -0
  118. package/dist/number-input.css +131 -0
  119. package/dist/scrollbar.css +165 -0
  120. package/dist/select.css +251 -0
  121. package/dist/styles.css +1866 -0
  122. package/dist/task-ui-components.css +1866 -0
  123. package/dist/theme-picker.css +219 -0
  124. package/dist/toaster.css +159 -0
  125. package/dist/tooltip.css +103 -0
  126. package/dist/types/index.d.ts +37 -0
  127. package/dist/types/index.d.ts.map +1 -0
  128. package/package.json +60 -4
  129. package/src/app-header.css +288 -0
  130. package/src/bento.css +143 -0
  131. package/src/button.css +227 -0
  132. package/src/components/AppHeader.tsx +87 -0
  133. package/src/components/BentoCard.tsx +72 -0
  134. package/src/components/BentoGrid.tsx +48 -0
  135. package/src/components/Button.tsx +81 -0
  136. package/src/components/ConnectedSettings.tsx +545 -0
  137. package/src/components/ConnectedThemePicker.tsx +79 -0
  138. package/src/components/ContextMenu.tsx +48 -0
  139. package/src/components/HadokuThemePicker.tsx +39 -0
  140. package/src/components/LoadingSkeleton.tsx +84 -0
  141. package/src/components/Modal.tsx +171 -0
  142. package/src/components/NumberInput.tsx +161 -0
  143. package/src/components/Select.tsx +359 -0
  144. package/src/components/SettingsModal.tsx +235 -0
  145. package/src/components/ThemeIcons.tsx +127 -0
  146. package/src/components/ThemePicker.tsx +149 -0
  147. package/src/components/Toast.tsx +92 -0
  148. package/src/components/Toaster.tsx +43 -0
  149. package/src/components/Tooltip.tsx +212 -0
  150. package/src/context-menu.css +11 -0
  151. package/src/controls.css +137 -0
  152. package/src/hooks/useReducedMotion.ts +36 -0
  153. package/src/hooks/useToast.ts +57 -0
  154. package/src/icon-btn.css +64 -0
  155. package/src/index.ts +153 -0
  156. package/src/lib/menuPlacement.ts +31 -0
  157. package/src/lib/selectMenus.ts +303 -0
  158. package/src/lib/settingsClient.ts +425 -0
  159. package/src/lib/themeScope.ts +23 -0
  160. package/src/lib/titleTooltips.ts +236 -0
  161. package/src/lib/tooltipPlacement.ts +59 -0
  162. package/src/modal.css +129 -0
  163. package/src/number-input.css +131 -0
  164. package/src/scrollbar.css +165 -0
  165. package/src/select.css +251 -0
  166. package/src/theme-picker.css +219 -0
  167. package/src/toaster.css +159 -0
  168. package/src/tooltip.css +103 -0
  169. package/src/types/index.ts +43 -0
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Client for the unified user-settings modal (ConnectedSettings).
3
+ *
4
+ * Two concerns, both cookie-authed against the hadoku.me edge-router, which
5
+ * resolves the `hadoku_session` cookie / `X-Session-Id` header → X-User-Key +
6
+ * X-User-Id for the backend:
7
+ * - display name → POST /session/name (edge-router, self rename)
8
+ * - content level → GET/PUT /prefs/api/v1/content-level (prefs-api)
9
+ *
10
+ * Switching keys goes through the SITE's sign-in client, never a call of ours:
11
+ * see `loadSignIn()`, with `switchKeyHref()` as the fallback.
12
+ *
13
+ * These are same-origin relative paths, so ConnectedSettings is portable to any
14
+ * app served under hadoku.me with zero wiring. Off-origin (e.g. a Storybook or
15
+ * a non-hadoku host) every call resolves to null and the UI degrades to a
16
+ * read-only shell — it never throws.
17
+ *
18
+ * The raw key is never held in the browser (the auth page deliberately drops
19
+ * it), so name + content-level changes go through session identity, not a key
20
+ * header, and no app ever asks for a key at all.
21
+ *
22
+ * WHEN THESE RUN
23
+ * --------------
24
+ * Everything the popout displays is resolved at PAGE LOAD, not on gear-click —
25
+ * see `prefetchSettings()`. Opening settings must cost zero requests. Pinned by
26
+ * `e2e/settings-prefetch.spec.ts` in hadoku-task.
27
+ */
28
+
29
+ /** Edge-router / prefs-api endpoints (same-origin relative). */
30
+ const CONTENT_LEVEL_PATH = '/prefs/api/v1/content-level'
31
+ const SESSION_NAME = '/session/name'
32
+ const SESSION_WHOAMI = '/session/whoami'
33
+
34
+ /**
35
+ * The ecosystem's shared boot-whoami promise. hadoku_site's `mf-loader.js`
36
+ * kicks `GET /session/whoami` in parallel with the micro-frontend's module
37
+ * import and parks the promise here precisely so the app it loads doesn't
38
+ * resolve identity a second time; @wolffm/prefs-client already consumes it
39
+ * under the same name. Reading it is what makes settings' identity free.
40
+ *
41
+ * Absent outside that shell (standalone vite, Capacitor, Storybook) — there we
42
+ * fetch, and publish the result under the same key so the next reader is free
43
+ * too.
44
+ */
45
+ const WHOAMI_GLOBAL = '__hadokuWhoami'
46
+
47
+ /**
48
+ * Raw whoami body, as both mf-loader and this module see it off the wire.
49
+ *
50
+ * `contentLevel` / `maxContentLevel` are OPTIONAL because the shape is
51
+ * versioned by whatever edge-router is deployed, not by this bundle. They
52
+ * arrive from edge-router ≥ the 2026-08-07 change, which reports the level
53
+ * `authGate` had already resolved to stamp X-Hadoku-Content-Level. When they
54
+ * are absent we fall back to the standalone GET — see resolveSettings.
55
+ */
56
+ interface WhoamiBody {
57
+ valid?: boolean
58
+ userType?: Tier
59
+ name?: string | null
60
+ contentLevel?: number
61
+ maxContentLevel?: number
62
+ }
63
+
64
+ // Hard timeout on every settings request. Without it, a request throttled by
65
+ // the browser (e.g. the tab going to the background) leaves the caller's
66
+ // "saving" flag stuck true — which disabled the content-level pills until the
67
+ // tab regained focus. AbortSignal.timeout guarantees the promise settles.
68
+ const REQUEST_TIMEOUT_MS = 8000
69
+
70
+ /** Session-id header the same way whoami() sends it (cookie is the fallback). */
71
+ function sessionHeaders(extra?: Record<string, string>): Record<string, string> {
72
+ const headers: Record<string, string> = { 'Content-Type': 'application/json', ...extra }
73
+ const sid = typeof window !== 'undefined' ? localStorage.getItem('hadoku_session_id') : null
74
+ if (sid) headers['X-Session-Id'] = sid
75
+ return headers
76
+ }
77
+
78
+ export interface ContentLevelState {
79
+ /** Current global content-visibility level (1 = safest default). */
80
+ level: number
81
+ /** Highest level this caller's tier may select (friend=3, admin=4). */
82
+ maxLevel: number
83
+ }
84
+
85
+ /** GET the caller's current content level + tier ceiling. null on error. */
86
+ export async function getContentLevel(): Promise<ContentLevelState | null> {
87
+ try {
88
+ const res = await fetch(CONTENT_LEVEL_PATH, {
89
+ credentials: 'same-origin',
90
+ headers: sessionHeaders(),
91
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
92
+ })
93
+ if (!res.ok) return null
94
+ return (await res.json()) as ContentLevelState
95
+ } catch {
96
+ return null
97
+ }
98
+ }
99
+
100
+ /** PUT a new content level. Server clamps to the tier ceiling → 400 if out of
101
+ * range. Returns the stored state on success, null on any failure. */
102
+ export async function setContentLevel(level: number): Promise<ContentLevelState | null> {
103
+ try {
104
+ const res = await fetch(CONTENT_LEVEL_PATH, {
105
+ method: 'PUT',
106
+ credentials: 'same-origin',
107
+ headers: sessionHeaders(),
108
+ body: JSON.stringify({ level }),
109
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
110
+ })
111
+ if (!res.ok) return null
112
+ const stored = (await res.json()) as ContentLevelState
113
+ patchSnapshot(prev => ({ ...prev, content: stored }))
114
+ return stored
115
+ } catch {
116
+ return null
117
+ }
118
+ }
119
+
120
+ /** POST a new display name for the signed-in user. Returns the stored name
121
+ * (may differ from the request for wp-* protected keys) or null on failure. */
122
+ export async function setDisplayName(name: string): Promise<string | null> {
123
+ try {
124
+ const res = await fetch(SESSION_NAME, {
125
+ method: 'POST',
126
+ credentials: 'same-origin',
127
+ headers: sessionHeaders(),
128
+ body: JSON.stringify({ name }),
129
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
130
+ })
131
+ if (!res.ok) return null
132
+ const body = (await res.json()) as { ok: boolean; name: string | null }
133
+ patchSnapshot(prev => ({ ...prev, identity: { ...prev.identity, name: body.name } }))
134
+ return body.name
135
+ } catch {
136
+ return null
137
+ }
138
+ }
139
+
140
+ /**
141
+ * The hadoku tier ladder, LOW to HIGH: `public < friend < service < wife < admin`.
142
+ * Mirrors TIER_RANK in @wolffm/worker-utils, which this browser bundle cannot
143
+ * import (it would pull hono into the client). A tier missing here does not
144
+ * error — it fails the union and, worse, drops out of every `Record<Tier, …>`
145
+ * lookup, so the caller renders as if unrecognised. Keep it complete.
146
+ */
147
+ export type Tier = 'public' | 'friend' | 'service' | 'wife' | 'admin'
148
+
149
+ export interface Identity {
150
+ userType: Tier
151
+ name: string | null
152
+ }
153
+
154
+ /**
155
+ * The page's one whoami body, resolved at most once no matter how many callers.
156
+ *
157
+ * Under the hadoku.me shell this costs NO request: mf-loader kicks whoami in
158
+ * parallel with the micro-frontend's module import and parks the promise on
159
+ * WHOAMI_GLOBAL for exactly this reason (@wolffm/prefs-client reads the same
160
+ * key). Elsewhere — standalone vite, Capacitor, Storybook — we fetch once and
161
+ * publish under the same key so the next reader is free too.
162
+ *
163
+ * Resolves to null rather than throwing on any failure.
164
+ */
165
+ function sharedWhoami(): Promise<WhoamiBody | null> {
166
+ const g = globalThis as { [WHOAMI_GLOBAL]?: Promise<WhoamiBody | null> }
167
+ g[WHOAMI_GLOBAL] ??= fetch(SESSION_WHOAMI, {
168
+ credentials: 'same-origin',
169
+ headers: sessionHeaders(),
170
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
171
+ })
172
+ .then(res => (res.ok ? (res.json() as Promise<WhoamiBody>) : null))
173
+ .catch(() => null)
174
+ return g[WHOAMI_GLOBAL]
175
+ }
176
+
177
+ /**
178
+ * Resolve the signed-in caller's tier + display name from the edge-router.
179
+ * Returns { userType: 'public', name: null } for anonymous / off-origin / any
180
+ * failure — never throws, so ConnectedSettings can render unconditionally.
181
+ */
182
+ export async function whoami(): Promise<Identity> {
183
+ if (typeof window === 'undefined') return { userType: 'public', name: null }
184
+ try {
185
+ const body = await sharedWhoami()
186
+ if (!body?.valid) return { userType: 'public', name: null }
187
+ return { userType: body.userType ?? 'public', name: body.name ?? null }
188
+ } catch {
189
+ return { userType: 'public', name: null }
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Where a "Switch key" control sends the user when the inline sign-in client
195
+ * (`loadSignIn()`) can't be loaded: the site's sign-in page, which brings them
196
+ * back here afterwards.
197
+ *
198
+ * A browser session is created ONLY by /auth (hadoku_site, 2026-10-25): every
199
+ * render of that page carries an edge-signed funnel token and the device
200
+ * fingerprint, and /session/create alerts on — and will refuse — a sign-in
201
+ * without them. So an app never posts a key itself.
202
+ *
203
+ * `switch=1` stops /auth from sending an already signed-in visitor straight
204
+ * back, which would make switching impossible. `return` is this page's path,
205
+ * query and hash; /auth redirects there with the new session cookie, and the
206
+ * shell's mf-loader resyncs the localStorage mirror (hadoku_session_id /
207
+ * hadoku_user_type) from /session/whoami on that load, so nothing here writes it.
208
+ */
209
+ export function switchKeyHref(): string {
210
+ return (
211
+ '/auth?switch=1&return=' +
212
+ encodeURIComponent(window.location.pathname + window.location.search + window.location.hash)
213
+ )
214
+ }
215
+
216
+ /** What the site's sign-in client resolves to on success. */
217
+ export interface SignInResult {
218
+ valid: boolean
219
+ userType?: string
220
+ sessionId?: string
221
+ }
222
+
223
+ /** `signIn` from hadoku.me's `/auth/sign-in.js`. Resolves on success; throws an
224
+ * `Error` carrying the server's message on a refused or empty key. */
225
+ export type SignIn = (key: string) => Promise<SignInResult>
226
+
227
+ /**
228
+ * The site serves its sign-in page's own client as a module, so an app can sign
229
+ * in INLINE on the same code path as /auth: a fresh funnel token per call, the
230
+ * device fingerprint, and the `/session/create` post that sets the cookie (and
231
+ * writes the hadoku_session_id / hadoku_user_type mirror). This package never
232
+ * posts /session/create itself — a direct post would skip the fingerprint and
233
+ * read as off-funnel — and it does not vendor a copy.
234
+ *
235
+ * The specifier is a VARIABLE on purpose. A literal `import('/auth/sign-in.js')`
236
+ * would have every bundler that re-bundles this package (hadoku_site's build
237
+ * among them) try to resolve the path at build time; this one is only ever
238
+ * resolved by the browser, against whatever origin the page is on. For the
239
+ * same reason it is typed here (`SignIn`) rather than by an ambient module
240
+ * declaration, which only a literal specifier would pick up.
241
+ *
242
+ * The module starts collecting the fingerprint as soon as it evaluates, so call
243
+ * this when a sign-in form OPENS, not when it submits. Memoised; resolves to
244
+ * null when the module can't be loaded (a dev server that isn't hadoku.me),
245
+ * which is the caller's cue to fall back to `switchKeyHref()`.
246
+ */
247
+ const SIGN_IN_MODULE = '/auth/sign-in.js'
248
+ let signInModule: Promise<SignInClient | null> | null = null
249
+
250
+ /**
251
+ * The whole sign-in client. The passkey half is optional: a site build from
252
+ * before it exports only `signIn`, and the passkey controls then fall back to
253
+ * the /auth page (`passkeyHref()`), as a missing module does.
254
+ */
255
+ export interface SignInClient {
256
+ signIn: SignIn
257
+ /** Like `signIn`, with a passkey in place of the key. Throws Error(message). */
258
+ signInWithPasskey?: () => Promise<SignInResult>
259
+ /** Saves a passkey for the signed-in person on this device. Throws Error(message). */
260
+ addPasskey?: () => Promise<{ saved: boolean; passkeys: number }>
261
+ }
262
+
263
+ export function loadSignInClient(): Promise<SignInClient | null> {
264
+ signInModule ??= import(/* @vite-ignore */ SIGN_IN_MODULE).then(
265
+ (m: Partial<Record<keyof SignInClient, unknown>>) =>
266
+ typeof m.signIn === 'function'
267
+ ? {
268
+ signIn: m.signIn as SignIn,
269
+ signInWithPasskey:
270
+ typeof m.signInWithPasskey === 'function'
271
+ ? (m.signInWithPasskey as SignInClient['signInWithPasskey'])
272
+ : undefined,
273
+ addPasskey:
274
+ typeof m.addPasskey === 'function'
275
+ ? (m.addPasskey as SignInClient['addPasskey'])
276
+ : undefined
277
+ }
278
+ : null,
279
+ () => {
280
+ // Let a later open retry: a failed fetch is not cached by the browser.
281
+ signInModule = null
282
+ return null
283
+ }
284
+ )
285
+ return signInModule
286
+ }
287
+
288
+ export function loadSignIn(): Promise<SignIn | null> {
289
+ return loadSignInClient().then(c => c?.signIn ?? null)
290
+ }
291
+
292
+ /** Whether this browser can use a passkey at all (WebAuthn present). */
293
+ export function passkeysSupported(): boolean {
294
+ return typeof window !== 'undefined' && 'PublicKeyCredential' in window
295
+ }
296
+
297
+ /**
298
+ * The /auth page's passkey flows, for when the sign-in client can't be
299
+ * loaded: `add` saves one for the signed-in visitor, `signin` is the page with
300
+ * its passkey button. Both come back here afterwards.
301
+ */
302
+ export function passkeyHref(mode: 'add' | 'signin'): string {
303
+ if (mode === 'signin') return switchKeyHref()
304
+ return (
305
+ '/auth?passkey=add&return=' +
306
+ encodeURIComponent(window.location.pathname + window.location.search + window.location.hash)
307
+ )
308
+ }
309
+
310
+ /* ------------------------------------------------------------------ *
311
+ * Prefetch
312
+ * ------------------------------------------------------------------ */
313
+
314
+ /** Everything the settings popout displays, resolved together. */
315
+ export interface SettingsSnapshot {
316
+ identity: Identity
317
+ /** null = public caller (no pill) or the request failed. */
318
+ content: ContentLevelState | null
319
+ }
320
+
321
+ /** Identity the host already knows, so we don't ask the server for it again. */
322
+ export interface SettingsPrefetchHints {
323
+ /** Caller's tier. When supplied, whoami is skipped entirely. */
324
+ userType?: Tier
325
+ /** Caller's display name. Only meaningful alongside `userType`. */
326
+ name?: string | null
327
+ }
328
+
329
+ let snapshot: Promise<SettingsSnapshot> | null = null
330
+
331
+ /**
332
+ * Resolve — once per page load — everything the settings popout shows.
333
+ *
334
+ * This is THE entry point, and it is called from ConnectedSettings' MOUNT, not
335
+ * from its open handler. Settings data is user identity: it is known at boot,
336
+ * it doesn't change while the page is up, and making the gear the trigger left
337
+ * the panel on screen but blank for a round trip (measured on prod: whoami
338
+ * 167ms and content-level 186ms, issued concurrently). It now rides along with
339
+ * the app's own boot traffic and the click costs nothing.
340
+ *
341
+ * Idempotent and shared: every caller after the first gets the same promise, so
342
+ * N mounted consumers still produce at most one whoami for the whole page.
343
+ * Mutations below write through to it rather than invalidating, so a remount
344
+ * never shows a value the user just changed.
345
+ *
346
+ * Against a current edge-router this issues NO requests of its own at all — the
347
+ * level rides on the whoami the shell already had in flight. See
348
+ * resolveSettings for the fallback when it doesn't.
349
+ */
350
+ export function prefetchSettings(hints: SettingsPrefetchHints = {}): Promise<SettingsSnapshot> {
351
+ snapshot ??= resolveSettings(hints)
352
+ return snapshot
353
+ }
354
+
355
+ async function resolveSettings(hints: SettingsPrefetchHints): Promise<SettingsSnapshot> {
356
+ // The whoami body, not just the adapted identity — edge-router carries the
357
+ // content level on it (see below), and reading the raw body is how we get at
358
+ // it without a second request.
359
+ //
360
+ // Skipped entirely when the host supplied identity: that prop exists for apps
361
+ // that CANNOT reach /session/whoami (conjure sits behind a path-prefixed shim
362
+ // where it 404s), so asking anyway would spend a request to learn nothing.
363
+ const selfResolving = hints.userType === undefined && typeof window !== 'undefined'
364
+ const bodyPromise = selfResolving ? sharedWhoami() : Promise.resolve(null)
365
+
366
+ const identityPromise: Promise<Identity> =
367
+ hints.userType !== undefined
368
+ ? Promise.resolve({ userType: hints.userType, name: hints.name ?? null })
369
+ : whoami()
370
+
371
+ // PREFERRED PATH: take the level straight off whoami.
372
+ //
373
+ // edge-router's authGate has already resolved it (registry key → userId →
374
+ // prefs D1) to stamp X-Hadoku-Content-Level, so reporting it on whoami costs
375
+ // it nothing — and it saves us a proxied round trip to prefs-api that
376
+ // measured 132-420ms against prod. Better still, whoami is in flight before
377
+ // this bundle has even loaded, so the value is here the moment we ask.
378
+ //
379
+ // FALLBACK: the standalone GET, for any edge-router older than the
380
+ // 2026-08-07 change, and for callers not behind it at all (Capacitor,
381
+ // Storybook, a non-hadoku host). Gated on identity because the pill is
382
+ // hidden for public callers — fetching an anonymous visitor's level is a
383
+ // guaranteed-wasted request on every page view. `hadoku_user_type` is
384
+ // written by the loader before anything mounts, so where it already says
385
+ // "signed in" the fallback goes out concurrently rather than behind whoami.
386
+ const contentPromise = bodyPromise.then(body => {
387
+ const carried = contentFromWhoami(body)
388
+ if (carried) return carried
389
+ if (looksSignedIn(hints.userType)) return getContentLevel()
390
+ return identityPromise.then(id => (id.userType === 'public' ? null : getContentLevel()))
391
+ })
392
+
393
+ const [identity, content] = await Promise.all([identityPromise, contentPromise])
394
+ return { identity, content }
395
+ }
396
+
397
+ /**
398
+ * Adapt the content level off a whoami body, or null if that edge-router
399
+ * doesn't report it.
400
+ *
401
+ * Both fields are required, and both must be numbers: a body carrying a level
402
+ * without its ceiling would render a pill with zero segments — worse than the
403
+ * fallback fetch, because it looks like a working control that offers nothing.
404
+ */
405
+ function contentFromWhoami(body: WhoamiBody | null): ContentLevelState | null {
406
+ if (!body?.valid) return null
407
+ const { contentLevel, maxContentLevel } = body
408
+ if (typeof contentLevel !== 'number' || typeof maxContentLevel !== 'number') return null
409
+ if (maxContentLevel < 1) return null
410
+ return { level: contentLevel, maxLevel: maxContentLevel }
411
+ }
412
+
413
+ /** Best-effort "is this caller signed in?" from state available synchronously. */
414
+ function looksSignedIn(hinted?: Tier): boolean {
415
+ if (hinted !== undefined) return hinted !== 'public'
416
+ if (typeof window === 'undefined') return false
417
+ const cached = localStorage.getItem('hadoku_user_type')
418
+ return cached !== null && cached !== 'public'
419
+ }
420
+
421
+ /** Keep the prefetched snapshot in step with a successful mutation. */
422
+ function patchSnapshot(patch: (prev: SettingsSnapshot) => SettingsSnapshot) {
423
+ if (!snapshot) return
424
+ snapshot = snapshot.then(patch)
425
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Where a popup (tooltip bubble, dropdown list) should be portalled.
3
+ *
4
+ * Theme tokens are scoped: HadokuThemeRoot puts `data-theme` on the app
5
+ * container, and a nested `[data-theme]` scope (a theme preview, an embedded
6
+ * board) can sit inside that. A popup must render inside the SAME scope as its
7
+ * anchor, or it paints in the wrong theme — portalled to <body>, it loses the
8
+ * container's tokens entirely.
9
+ *
10
+ * Rendering it in place keeps the theme but breaks on any ancestor with a
11
+ * `transform`, which becomes the containing block for `position: fixed` and
12
+ * drags the popup off its anchor. The nearest `[data-theme]` ancestor is the
13
+ * best of both: the right tokens, and above every transform between the
14
+ * anchor and the theme root. When the only match is <html>, <body> takes it —
15
+ * :root tokens reach body, and a portal into <html> itself would sit outside
16
+ * the document's body.
17
+ */
18
+ export function themeScopeOf(anchor: Element | null): HTMLElement | null {
19
+ if (!anchor) return null
20
+ const scope = anchor.closest<HTMLElement>('[data-theme]')
21
+ if (!scope || scope === document.documentElement) return document.body
22
+ return scope
23
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Card tooltips for every `title=` on a page, with no change to the markup.
3
+ *
4
+ * import { installTitleTooltips } from '@wolffm/task-ui-components/title-tooltips'
5
+ * import '@wolffm/task-ui-components/tooltip.css'
6
+ * installTitleTooltips()
7
+ *
8
+ * hadoku_site calls this once from its shell, which covers every
9
+ * micro-frontend at once; an app does not call it itself. Framework-free and
10
+ * React-free on purpose: the shell has no React, and this must not pull a
11
+ * second copy into the page.
12
+ *
13
+ * One delegated listener set on the document. On hover (mouse only — touch
14
+ * never showed a native title either) or keyboard focus, the nearest element
15
+ * carrying `title` lends it to the bubble: the attribute moves to
16
+ * `data-hdk-title` for as long as the bubble is up, which is what stops the
17
+ * browser drawing its own on top, and goes back the moment it closes. So
18
+ * outside a hover the DOM is exactly what the app rendered, and code or a
19
+ * framework that reads or re-sets `title` sees its own value. A title the
20
+ * app changes mid-hover (a MutationObserver watches the anchor) re-stashes and
21
+ * updates the open bubble.
22
+ *
23
+ * Same contract as <Tooltip>: hover or focus shows it, Escape dismisses it
24
+ * until the pointer leaves, the pointer can move onto the bubble, a press
25
+ * closes it, and the anchor gets `aria-describedby` while it is up (skipped
26
+ * when the title only repeats the aria-label). The bubble is portalled into
27
+ * the anchor's theme scope, so a nested `[data-theme]` gets its own colours.
28
+ *
29
+ * Opt out per subtree with `data-native-title`.
30
+ */
31
+
32
+ import { themeScopeOf } from './themeScope'
33
+ import { placeTooltip, type TooltipPlacement } from './tooltipPlacement'
34
+
35
+ export interface TitleTooltipOptions {
36
+ /** Hover delay before showing, ms. Focus shows immediately. */
37
+ delay?: number
38
+ placement?: TooltipPlacement
39
+ }
40
+
41
+ const HIDE_GRACE_MS = 120
42
+ const STASH = 'data-hdk-title'
43
+ const BUBBLE_ID = 'hdk-title-tooltip'
44
+
45
+ let uninstallCurrent: (() => void) | null = null
46
+
47
+ export function installTitleTooltips(options: TitleTooltipOptions = {}): () => void {
48
+ if (uninstallCurrent) return uninstallCurrent
49
+ const delay = options.delay ?? 300
50
+ const placement = options.placement ?? 'top'
51
+
52
+ let anchor: Element | null = null
53
+ let bubble: HTMLDivElement | null = null
54
+ let timer: ReturnType<typeof setTimeout> | undefined
55
+ // Escape closes the bubble until the pointer leaves that anchor (WCAG 1.4.13).
56
+ let dismissed: Element | null = null
57
+
58
+ const observer = new MutationObserver(() => {
59
+ if (!anchor || !anchor.hasAttribute('title')) return
60
+ stash(anchor)
61
+ if (bubble) {
62
+ bubble.textContent = anchor.getAttribute(STASH)
63
+ place()
64
+ }
65
+ })
66
+
67
+ function stash(el: Element) {
68
+ el.setAttribute(STASH, el.getAttribute('title') ?? '')
69
+ el.removeAttribute('title')
70
+ }
71
+
72
+ function restore(el: Element) {
73
+ const text = el.getAttribute(STASH)
74
+ if (text === null) return
75
+ el.removeAttribute(STASH)
76
+ if (!el.hasAttribute('title')) el.setAttribute('title', text)
77
+ }
78
+
79
+ function titledFrom(target: EventTarget | null): Element | null {
80
+ if (!(target instanceof Element)) return null
81
+ // The stash too: while the bubble is up the anchor carries data-hdk-title,
82
+ // not title, and a pointerover on its own icon must still find IT rather
83
+ // than a titled ancestor.
84
+ const el = target.closest(`[title], [${STASH}]`)
85
+ if (!el || el.tagName === 'IFRAME' || el.closest('[data-native-title]')) return null
86
+ return (el.getAttribute('title') ?? el.getAttribute(STASH) ?? '').trim() ? el : null
87
+ }
88
+
89
+ function place() {
90
+ if (!anchor || !bubble) return
91
+ if (!anchor.isConnected) {
92
+ hide(0)
93
+ return
94
+ }
95
+ const p = placeTooltip(
96
+ placement,
97
+ anchor.getBoundingClientRect(),
98
+ bubble.offsetWidth,
99
+ bubble.offsetHeight
100
+ )
101
+ bubble.style.left = `${p.left}px`
102
+ bubble.style.top = `${p.top}px`
103
+ bubble.style.setProperty('--hdk-tooltip-arrow', `${p.arrow}px`)
104
+ bubble.className = `hdk-tooltip hdk-tooltip--card hdk-tooltip--plain hdk-tooltip--${p.side} hdk-tooltip--visible`
105
+ }
106
+
107
+ /** Take the title off `el` now (before the browser's own ~1s delay runs
108
+ * out) and open the bubble after `after` ms. */
109
+ function arm(el: Element, after: number) {
110
+ clearTimeout(timer)
111
+ if (el !== anchor) {
112
+ release()
113
+ anchor = el
114
+ stash(el)
115
+ observer.observe(el, { attributes: true, attributeFilter: ['title'] })
116
+ }
117
+ if (dismissed === el) return
118
+ timer = setTimeout(open, after)
119
+ }
120
+
121
+ function open() {
122
+ if (!anchor) return
123
+ const scope = themeScopeOf(anchor)
124
+ if (!scope) return
125
+ if (!bubble) {
126
+ bubble = document.createElement('div')
127
+ bubble.id = BUBBLE_ID
128
+ bubble.setAttribute('role', 'tooltip')
129
+ bubble.addEventListener('pointerenter', () => clearTimeout(timer))
130
+ bubble.addEventListener('pointerleave', () => hide(HIDE_GRACE_MS))
131
+ }
132
+ const text = anchor.getAttribute(STASH) ?? ''
133
+ bubble.textContent = text
134
+ // Parked invisibly at the origin so it can be measured, then placed.
135
+ bubble.className = `hdk-tooltip hdk-tooltip--card hdk-tooltip--plain hdk-tooltip--${placement}`
136
+ bubble.style.left = '0px'
137
+ bubble.style.top = '0px'
138
+ if (bubble.parentNode !== scope) scope.appendChild(bubble)
139
+ if (text !== anchor.getAttribute('aria-label')) {
140
+ const ids = (anchor.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean)
141
+ if (!ids.includes(BUBBLE_ID))
142
+ anchor.setAttribute('aria-describedby', [...ids, BUBBLE_ID].join(' '))
143
+ }
144
+ place()
145
+ window.addEventListener('scroll', place, true)
146
+ window.addEventListener('resize', place)
147
+ }
148
+
149
+ /** Close the bubble and hand the anchor its title back. */
150
+ function release() {
151
+ window.removeEventListener('scroll', place, true)
152
+ window.removeEventListener('resize', place)
153
+ bubble?.remove()
154
+ if (!anchor) return
155
+ observer.disconnect()
156
+ const ids = (anchor.getAttribute('aria-describedby') ?? '')
157
+ .split(/\s+/)
158
+ .filter(id => id && id !== BUBBLE_ID)
159
+ if (ids.length) anchor.setAttribute('aria-describedby', ids.join(' '))
160
+ else anchor.removeAttribute('aria-describedby')
161
+ restore(anchor)
162
+ anchor = null
163
+ }
164
+
165
+ function hide(after: number) {
166
+ clearTimeout(timer)
167
+ if (after === 0) release()
168
+ else timer = setTimeout(release, after)
169
+ }
170
+
171
+ const onPointerOver = (e: PointerEvent) => {
172
+ if (e.pointerType !== 'mouse') return
173
+ if (bubble && bubble.contains(e.target as Node)) return
174
+ // Back over the anchor of a bubble still up: just cancel the pending hide.
175
+ // Over it with no bubble (inside the hide grace, or after a dismissal
176
+ // that has since cleared), arm it again — otherwise it stays silent.
177
+ const el = titledFrom(e.target)
178
+ if (el && el === anchor && bubble?.isConnected) clearTimeout(timer)
179
+ else if (el) arm(el, delay)
180
+ }
181
+
182
+ const onPointerOut = (e: PointerEvent) => {
183
+ if (e.pointerType !== 'mouse') return
184
+ const to = e.relatedTarget as Node | null
185
+ // Checked before `anchor`: a dismissal has already released the anchor.
186
+ if (dismissed && !(to && dismissed.contains(to))) dismissed = null
187
+ if (!anchor || (to && (anchor.contains(to) || bubble?.contains(to)))) return
188
+ hide(HIDE_GRACE_MS)
189
+ }
190
+
191
+ // A press closes it, as it does a native title: the click usually opens
192
+ // something the bubble would sit on top of.
193
+ const onPointerDown = () => {
194
+ if (anchor) dismissed = anchor
195
+ hide(0)
196
+ }
197
+
198
+ const onFocusIn = (e: FocusEvent) => {
199
+ const target = e.target
200
+ if (!(target instanceof Element) || !target.matches(':focus-visible')) return
201
+ const el = titledFrom(target)
202
+ if (el) {
203
+ dismissed = null
204
+ arm(el, 0)
205
+ }
206
+ }
207
+
208
+ const onFocusOut = (e: FocusEvent) => {
209
+ if (anchor && anchor.contains(e.target as Node)) hide(0)
210
+ }
211
+
212
+ const onKeyDown = (e: KeyboardEvent) => {
213
+ if (e.key !== 'Escape' || !anchor || !bubble?.isConnected) return
214
+ dismissed = anchor
215
+ hide(0)
216
+ }
217
+
218
+ document.addEventListener('pointerover', onPointerOver)
219
+ document.addEventListener('pointerout', onPointerOut)
220
+ document.addEventListener('pointerdown', onPointerDown, true)
221
+ document.addEventListener('focusin', onFocusIn)
222
+ document.addEventListener('focusout', onFocusOut)
223
+ document.addEventListener('keydown', onKeyDown)
224
+
225
+ uninstallCurrent = () => {
226
+ hide(0)
227
+ document.removeEventListener('pointerover', onPointerOver)
228
+ document.removeEventListener('pointerout', onPointerOut)
229
+ document.removeEventListener('pointerdown', onPointerDown, true)
230
+ document.removeEventListener('focusin', onFocusIn)
231
+ document.removeEventListener('focusout', onFocusOut)
232
+ document.removeEventListener('keydown', onKeyDown)
233
+ uninstallCurrent = null
234
+ }
235
+ return uninstallCurrent
236
+ }