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

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 +302 -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 +72 -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 +120 -0
  102. package/dist/lib/settingsClient.d.ts.map +1 -0
  103. package/dist/lib/settingsClient.js +140 -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 +465 -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 +140 -0
  156. package/src/lib/menuPlacement.ts +31 -0
  157. package/src/lib/selectMenus.ts +303 -0
  158. package/src/lib/settingsClient.ts +377 -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,303 @@
1
+ /**
2
+ * The themed list for every native <select> on a page, with no change to the
3
+ * markup.
4
+ *
5
+ * import { installSelectMenus } from '@wolffm/task-ui-components/select-menus'
6
+ * import '@wolffm/task-ui-components/select.css'
7
+ * installSelectMenus()
8
+ *
9
+ * hadoku_site calls this once from its shell, which covers every
10
+ * micro-frontend; an app does not call it itself. Framework-free and
11
+ * React-free, like installTitleTooltips, for the same reason.
12
+ *
13
+ * A native select's closed box takes CSS, but its open list is drawn by the
14
+ * OS and takes almost none. So a mouse press or an opening key on the select
15
+ * is intercepted (preventDefault on mousedown is what stops the OS list) and
16
+ * the same list <Select> renders opens instead, built from the select's own
17
+ * <option>s and <optgroup>s at that moment.
18
+ *
19
+ * The <select> stays the source of truth. Choosing sets its selectedIndex and
20
+ * fires `input` and `change` exactly as the OS list would, so every framework
21
+ * (React's onChange included) and every plain listener sees an ordinary
22
+ * change, and a form still submits the select's value.
23
+ *
24
+ * Left native on purpose:
25
+ * - touch: a phone's own picker is the better control there
26
+ * - `multiple` or `size > 1`, which render as an inline list, not a popup
27
+ * - anything under `data-native-select`
28
+ */
29
+
30
+ import { themeScopeOf } from './themeScope'
31
+ import { placeMenu } from './menuPlacement'
32
+
33
+ const TYPEAHEAD_RESET_MS = 600
34
+ const LIST_ID = 'hdk-select-menu'
35
+ // `check` from the @wolffm/themes registry (Lucide circle-check), inlined so
36
+ // this entry does not pull the whole registry into the shell.
37
+ const CHECK_SVG =
38
+ '<svg class="hdk-icon hdk-select__check" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="m9 12 2 2 4-4" /></svg>'
39
+
40
+ const OPEN_KEYS = new Set(['ArrowDown', 'ArrowUp', ' ', 'F4'])
41
+
42
+ let uninstallCurrent: (() => void) | null = null
43
+
44
+ function eligible(el: Element | null): el is HTMLSelectElement {
45
+ return (
46
+ el instanceof HTMLSelectElement &&
47
+ !el.multiple &&
48
+ el.size <= 1 &&
49
+ !el.disabled &&
50
+ !el.closest('[data-native-select]')
51
+ )
52
+ }
53
+
54
+ export function installSelectMenus(): () => void {
55
+ if (uninstallCurrent) return uninstallCurrent
56
+
57
+ let lastPointer = 'mouse'
58
+ let select: HTMLSelectElement | null = null
59
+ let list: HTMLUListElement | null = null
60
+ let options: HTMLOptionElement[] = []
61
+ let items: HTMLLIElement[] = []
62
+ let active = -1
63
+ const typeahead = { text: '', at: 0 }
64
+
65
+ function setActive(i: number) {
66
+ if (items[active]) items[active].classList.remove('hdk-select__option--active')
67
+ active = i
68
+ const item = items[i]
69
+ if (!item || !list) return
70
+ item.classList.add('hdk-select__option--active')
71
+ list.setAttribute('aria-activedescendant', item.id)
72
+ item.scrollIntoView({ block: 'nearest' })
73
+ }
74
+
75
+ function step(from: number, dir: 1 | -1) {
76
+ for (let i = 1; i <= options.length; i++) {
77
+ const next = (from + dir * i + options.length) % options.length
78
+ if (!isOff(next)) return next
79
+ }
80
+ return from
81
+ }
82
+
83
+ const edge = (dir: 1 | -1) => step(dir === 1 ? -1 : options.length, dir)
84
+
85
+ function matchTypeahead(key: string) {
86
+ const now = Date.now()
87
+ typeahead.text = now - typeahead.at > TYPEAHEAD_RESET_MS ? key : typeahead.text + key
88
+ typeahead.at = now
89
+ const needle = typeahead.text.toLowerCase()
90
+ const start = Math.max(active, 0)
91
+ for (let i = 0; i < options.length; i++) {
92
+ // A single repeated key cycles; a longer prefix stays on the current match.
93
+ const idx = (start + (typeahead.text.length === 1 ? i + 1 : i)) % options.length
94
+ const o = options[idx]
95
+ if (!isOff(idx) && labelOf(o).toLowerCase().startsWith(needle)) return idx
96
+ }
97
+ return -1
98
+ }
99
+
100
+ const labelOf = (o: HTMLOptionElement) => o.label || o.text
101
+ // A disabled <optgroup> disables its options too.
102
+ const isOff = (i: number) =>
103
+ options[i].disabled ||
104
+ (options[i].parentElement as HTMLOptGroupElement | null)?.disabled === true
105
+
106
+ function place() {
107
+ if (!select || !list) return
108
+ if (!select.isConnected) {
109
+ close(false)
110
+ return
111
+ }
112
+ const p = placeMenu(select.getBoundingClientRect(), list.scrollHeight)
113
+ list.style.left = `${p.left}px`
114
+ list.style.minWidth = `${p.width}px`
115
+ list.style.top = p.top === undefined ? '' : `${p.top}px`
116
+ list.style.bottom = p.bottom === undefined ? '' : `${p.bottom}px`
117
+ list.style.maxHeight = `${p.maxHeight}px`
118
+ list.style.visibility = ''
119
+ }
120
+
121
+ function open(el: HTMLSelectElement, start?: 'last') {
122
+ close(false)
123
+ const scope = themeScopeOf(el)
124
+ if (!scope) return
125
+ select = el
126
+ options = Array.from(el.options).filter(o => !o.hidden)
127
+ if (options.length === 0) return
128
+
129
+ list = document.createElement('ul')
130
+ list.id = LIST_ID
131
+ list.setAttribute('role', 'listbox')
132
+ list.tabIndex = -1
133
+ list.className = 'hdk-select__menu hdk-scroll hdk-scroll--thin'
134
+ const name = el.getAttribute('aria-label') ?? el.labels?.[0]?.textContent?.trim()
135
+ if (name) list.setAttribute('aria-label', name)
136
+ // The list is portalled, so it cannot inherit the select's type size.
137
+ const cs = getComputedStyle(el)
138
+ list.style.fontSize = cs.fontSize
139
+ list.style.fontFamily = cs.fontFamily
140
+ list.style.visibility = 'hidden'
141
+
142
+ let group: HTMLOptGroupElement | null = null
143
+ items = options.map((o, i) => {
144
+ const g = o.parentElement instanceof HTMLOptGroupElement ? o.parentElement : null
145
+ if (g && g !== group) {
146
+ const head = document.createElement('li')
147
+ head.setAttribute('role', 'presentation')
148
+ head.className = 'hdk-select__group'
149
+ head.textContent = g.label
150
+ list?.append(head)
151
+ }
152
+ group = g
153
+ const item = document.createElement('li')
154
+ item.id = `${LIST_ID}-${i}`
155
+ item.setAttribute('role', 'option')
156
+ const selected = o.index === el.selectedIndex
157
+ item.setAttribute('aria-selected', String(selected))
158
+ if (o.disabled || g?.disabled) item.setAttribute('aria-disabled', 'true')
159
+ item.className = `hdk-select__option${selected ? ' hdk-select__option--selected' : ''}`
160
+ const text = document.createElement('span')
161
+ text.className = 'hdk-select__option-label'
162
+ text.textContent = labelOf(o)
163
+ const wrap = document.createElement('span')
164
+ wrap.className = 'hdk-select__option-text'
165
+ wrap.append(text)
166
+ item.append(wrap)
167
+ if (selected) item.insertAdjacentHTML('beforeend', CHECK_SVG)
168
+ item.addEventListener('pointermove', () => {
169
+ if (!isOff(i) && i !== active) setActive(i)
170
+ })
171
+ item.addEventListener('click', () => choose(i))
172
+ list?.append(item)
173
+ return item
174
+ })
175
+ list.addEventListener('keydown', onListKeyDown)
176
+ scope.appendChild(list)
177
+ el.setAttribute('aria-expanded', 'true')
178
+ el.setAttribute('aria-controls', LIST_ID)
179
+
180
+ const current = options.findIndex(o => o.index === el.selectedIndex)
181
+ setActive(start === 'last' ? edge(-1) : current >= 0 && !isOff(current) ? current : edge(1))
182
+ place()
183
+ list.focus({ preventScroll: true })
184
+ window.addEventListener('scroll', onScroll, true)
185
+ window.addEventListener('resize', place)
186
+ }
187
+
188
+ function close(refocus: boolean) {
189
+ window.removeEventListener('scroll', onScroll, true)
190
+ window.removeEventListener('resize', place)
191
+ list?.remove()
192
+ list = null
193
+ if (!select) return
194
+ select.removeAttribute('aria-expanded')
195
+ select.removeAttribute('aria-controls')
196
+ if (refocus) select.focus({ preventScroll: true })
197
+ select = null
198
+ items = []
199
+ options = []
200
+ active = -1
201
+ }
202
+
203
+ function choose(i: number) {
204
+ const el = select
205
+ const o = options[i]
206
+ if (!el || !o || isOff(i)) return
207
+ const changed = o.index !== el.selectedIndex
208
+ close(true)
209
+ if (!changed) return
210
+ el.selectedIndex = o.index
211
+ el.dispatchEvent(new Event('input', { bubbles: true }))
212
+ el.dispatchEvent(new Event('change', { bubbles: true }))
213
+ }
214
+
215
+ // Scrolling the list itself must not re-place (or close) it.
216
+ const onScroll = (e: Event) => {
217
+ if (list && e.target instanceof Node && list.contains(e.target)) return
218
+ place()
219
+ }
220
+
221
+ function onListKeyDown(e: KeyboardEvent) {
222
+ switch (e.key) {
223
+ case 'ArrowDown':
224
+ e.preventDefault()
225
+ setActive(step(active, 1))
226
+ break
227
+ case 'ArrowUp':
228
+ e.preventDefault()
229
+ setActive(step(active, -1))
230
+ break
231
+ case 'Home':
232
+ case 'PageUp':
233
+ e.preventDefault()
234
+ setActive(edge(1))
235
+ break
236
+ case 'End':
237
+ case 'PageDown':
238
+ e.preventDefault()
239
+ setActive(edge(-1))
240
+ break
241
+ case 'Enter':
242
+ case ' ':
243
+ e.preventDefault()
244
+ choose(active)
245
+ break
246
+ case 'Escape':
247
+ // Stop here so an enclosing modal does not close along with the list.
248
+ e.preventDefault()
249
+ e.stopPropagation()
250
+ close(true)
251
+ break
252
+ case 'Tab':
253
+ close(true)
254
+ break
255
+ default:
256
+ if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
257
+ const idx = matchTypeahead(e.key)
258
+ if (idx >= 0) setActive(idx)
259
+ }
260
+ }
261
+ }
262
+
263
+ const onPointerDown = (e: PointerEvent) => {
264
+ lastPointer = e.pointerType
265
+ const target = e.target as Node
266
+ if (list && !list.contains(target) && target !== select) close(false)
267
+ }
268
+
269
+ const onMouseDown = (e: MouseEvent) => {
270
+ if (e.button !== 0 || lastPointer === 'touch') return
271
+ const el = (e.target as Element | null)?.closest?.('select') ?? null
272
+ if (!eligible(el)) return
273
+ // This is what keeps the OS list from opening.
274
+ e.preventDefault()
275
+ if (select === el) {
276
+ close(true)
277
+ return
278
+ }
279
+ el.focus({ preventScroll: true })
280
+ open(el)
281
+ }
282
+
283
+ const onKeyDown = (e: KeyboardEvent) => {
284
+ const el = e.target as Element | null
285
+ if (!eligible(el) || select === el) return
286
+ if (!OPEN_KEYS.has(e.key) || e.ctrlKey || e.metaKey) return
287
+ e.preventDefault()
288
+ open(el, e.key === 'ArrowUp' && !e.altKey ? 'last' : undefined)
289
+ }
290
+
291
+ document.addEventListener('pointerdown', onPointerDown, true)
292
+ document.addEventListener('mousedown', onMouseDown, true)
293
+ document.addEventListener('keydown', onKeyDown, true)
294
+
295
+ uninstallCurrent = () => {
296
+ close(false)
297
+ document.removeEventListener('pointerdown', onPointerDown, true)
298
+ document.removeEventListener('mousedown', onMouseDown, true)
299
+ document.removeEventListener('keydown', onKeyDown, true)
300
+ uninstallCurrent = null
301
+ }
302
+ return uninstallCurrent
303
+ }
@@ -0,0 +1,377 @@
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<SignIn | null> | null = null
249
+
250
+ export function loadSignIn(): Promise<SignIn | null> {
251
+ signInModule ??= import(/* @vite-ignore */ SIGN_IN_MODULE).then(
252
+ (m: { signIn?: unknown }) => (typeof m.signIn === 'function' ? (m.signIn as SignIn) : null),
253
+ () => {
254
+ // Let a later open retry: a failed fetch is not cached by the browser.
255
+ signInModule = null
256
+ return null
257
+ }
258
+ )
259
+ return signInModule
260
+ }
261
+
262
+ /* ------------------------------------------------------------------ *
263
+ * Prefetch
264
+ * ------------------------------------------------------------------ */
265
+
266
+ /** Everything the settings popout displays, resolved together. */
267
+ export interface SettingsSnapshot {
268
+ identity: Identity
269
+ /** null = public caller (no pill) or the request failed. */
270
+ content: ContentLevelState | null
271
+ }
272
+
273
+ /** Identity the host already knows, so we don't ask the server for it again. */
274
+ export interface SettingsPrefetchHints {
275
+ /** Caller's tier. When supplied, whoami is skipped entirely. */
276
+ userType?: Tier
277
+ /** Caller's display name. Only meaningful alongside `userType`. */
278
+ name?: string | null
279
+ }
280
+
281
+ let snapshot: Promise<SettingsSnapshot> | null = null
282
+
283
+ /**
284
+ * Resolve — once per page load — everything the settings popout shows.
285
+ *
286
+ * This is THE entry point, and it is called from ConnectedSettings' MOUNT, not
287
+ * from its open handler. Settings data is user identity: it is known at boot,
288
+ * it doesn't change while the page is up, and making the gear the trigger left
289
+ * the panel on screen but blank for a round trip (measured on prod: whoami
290
+ * 167ms and content-level 186ms, issued concurrently). It now rides along with
291
+ * the app's own boot traffic and the click costs nothing.
292
+ *
293
+ * Idempotent and shared: every caller after the first gets the same promise, so
294
+ * N mounted consumers still produce at most one whoami for the whole page.
295
+ * Mutations below write through to it rather than invalidating, so a remount
296
+ * never shows a value the user just changed.
297
+ *
298
+ * Against a current edge-router this issues NO requests of its own at all — the
299
+ * level rides on the whoami the shell already had in flight. See
300
+ * resolveSettings for the fallback when it doesn't.
301
+ */
302
+ export function prefetchSettings(hints: SettingsPrefetchHints = {}): Promise<SettingsSnapshot> {
303
+ snapshot ??= resolveSettings(hints)
304
+ return snapshot
305
+ }
306
+
307
+ async function resolveSettings(hints: SettingsPrefetchHints): Promise<SettingsSnapshot> {
308
+ // The whoami body, not just the adapted identity — edge-router carries the
309
+ // content level on it (see below), and reading the raw body is how we get at
310
+ // it without a second request.
311
+ //
312
+ // Skipped entirely when the host supplied identity: that prop exists for apps
313
+ // that CANNOT reach /session/whoami (conjure sits behind a path-prefixed shim
314
+ // where it 404s), so asking anyway would spend a request to learn nothing.
315
+ const selfResolving = hints.userType === undefined && typeof window !== 'undefined'
316
+ const bodyPromise = selfResolving ? sharedWhoami() : Promise.resolve(null)
317
+
318
+ const identityPromise: Promise<Identity> =
319
+ hints.userType !== undefined
320
+ ? Promise.resolve({ userType: hints.userType, name: hints.name ?? null })
321
+ : whoami()
322
+
323
+ // PREFERRED PATH: take the level straight off whoami.
324
+ //
325
+ // edge-router's authGate has already resolved it (registry key → userId →
326
+ // prefs D1) to stamp X-Hadoku-Content-Level, so reporting it on whoami costs
327
+ // it nothing — and it saves us a proxied round trip to prefs-api that
328
+ // measured 132-420ms against prod. Better still, whoami is in flight before
329
+ // this bundle has even loaded, so the value is here the moment we ask.
330
+ //
331
+ // FALLBACK: the standalone GET, for any edge-router older than the
332
+ // 2026-08-07 change, and for callers not behind it at all (Capacitor,
333
+ // Storybook, a non-hadoku host). Gated on identity because the pill is
334
+ // hidden for public callers — fetching an anonymous visitor's level is a
335
+ // guaranteed-wasted request on every page view. `hadoku_user_type` is
336
+ // written by the loader before anything mounts, so where it already says
337
+ // "signed in" the fallback goes out concurrently rather than behind whoami.
338
+ const contentPromise = bodyPromise.then(body => {
339
+ const carried = contentFromWhoami(body)
340
+ if (carried) return carried
341
+ if (looksSignedIn(hints.userType)) return getContentLevel()
342
+ return identityPromise.then(id => (id.userType === 'public' ? null : getContentLevel()))
343
+ })
344
+
345
+ const [identity, content] = await Promise.all([identityPromise, contentPromise])
346
+ return { identity, content }
347
+ }
348
+
349
+ /**
350
+ * Adapt the content level off a whoami body, or null if that edge-router
351
+ * doesn't report it.
352
+ *
353
+ * Both fields are required, and both must be numbers: a body carrying a level
354
+ * without its ceiling would render a pill with zero segments — worse than the
355
+ * fallback fetch, because it looks like a working control that offers nothing.
356
+ */
357
+ function contentFromWhoami(body: WhoamiBody | null): ContentLevelState | null {
358
+ if (!body?.valid) return null
359
+ const { contentLevel, maxContentLevel } = body
360
+ if (typeof contentLevel !== 'number' || typeof maxContentLevel !== 'number') return null
361
+ if (maxContentLevel < 1) return null
362
+ return { level: contentLevel, maxLevel: maxContentLevel }
363
+ }
364
+
365
+ /** Best-effort "is this caller signed in?" from state available synchronously. */
366
+ function looksSignedIn(hinted?: Tier): boolean {
367
+ if (hinted !== undefined) return hinted !== 'public'
368
+ if (typeof window === 'undefined') return false
369
+ const cached = localStorage.getItem('hadoku_user_type')
370
+ return cached !== null && cached !== 'public'
371
+ }
372
+
373
+ /** Keep the prefetched snapshot in step with a successful mutation. */
374
+ function patchSnapshot(patch: (prev: SettingsSnapshot) => SettingsSnapshot) {
375
+ if (!snapshot) return
376
+ snapshot = snapshot.then(patch)
377
+ }