@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,545 @@
1
+ /**
2
+ * ConnectedSettings — the unified per-user settings control for the hadoku
3
+ * ecosystem: a gear button that opens a popout housing everything that is a
4
+ * property of the PERSON (not the app):
5
+ * - current access tier (read-only, friendly label)
6
+ * - display name (inline edit → POST /session/name)
7
+ * - global content-visibility level (tiered pill → PUT /prefs/api/v1/content-level)
8
+ * - switch key (an inline sign-in form, on the site's own sign-in client)
9
+ *
10
+ * "Connected" (parallel to ConnectedThemePicker): it self-wires to the
11
+ * hadoku.me edge-router via same-origin fetches (see lib/settingsClient), so
12
+ * dropping `<ConnectedSettings />` into any app under hadoku.me needs zero
13
+ * props. Identity is resolved via whoami() unless the host passes `userType` /
14
+ * `name` (avoids a redundant fetch when the app already knows).
15
+ *
16
+ * All of that is resolved AT MOUNT via `prefetchSettings()`, never on
17
+ * gear-click — opening the popout must cost zero requests. See the effect
18
+ * below, and `e2e/settings-prefetch.spec.ts` in hadoku-task, which fails if the
19
+ * fetch moves back behind the click.
20
+ *
21
+ * Mirrors ThemePicker's controlled-popout pattern: owns its own `open` state +
22
+ * a full-viewport overlay for click-outside dismiss. The content pill renders
23
+ * `maxLevel` segments (friend ⇒ 3, admin ⇒ 4) and is hidden for public callers.
24
+ * maxLevel comes from the server so the tier ceiling is never trusted from the
25
+ * client.
26
+ */
27
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
28
+ import { SettingsIcon } from './ThemeIcons'
29
+ import {
30
+ prefetchSettings,
31
+ setContentLevel,
32
+ setDisplayName,
33
+ loadSignIn,
34
+ loadSignInClient,
35
+ passkeyHref,
36
+ passkeysSupported,
37
+ switchKeyHref,
38
+ type ContentLevelState,
39
+ type Tier
40
+ } from '../lib/settingsClient'
41
+ import '../app-header.css'
42
+
43
+ export interface ConnectedSettingsProps {
44
+ /** Caller's tier. Omit to self-resolve via whoami().
45
+ *
46
+ * This exists for apps that CANNOT reach `/session/whoami` — conjure sits
47
+ * behind a path-prefixed shim that serves nothing outside `/conjure`, so
48
+ * whoami 404s and the tier would fall back to guest. It is an identity
49
+ * hint, not a way to present a different tier than the edge assigns: every
50
+ * gate that matters is enforced server-side off the edge-stamped tier. */
51
+ userType?: Tier
52
+ /** Display name. Omit to self-resolve via whoami(). Same rationale. */
53
+ name?: string | null
54
+ /** Notify the host (e.g. a "Signed in as …" label) of a rename. */
55
+ onNameChange?: (name: string | null) => void
56
+ /** App-specific settings rendered as a final section in the same popout, so
57
+ * an app with its own preferences keeps ONE unified gear instead of a second
58
+ * control. Shown under an "App preferences" divider.
59
+ *
60
+ * This slot is deliberately unconstrained — put whatever the app needs in
61
+ * it. The constraint is only that it lands AFTER the four canonical rows and
62
+ * cannot change them. */
63
+ children?: React.ReactNode
64
+ }
65
+
66
+ /** Rejection sink for fire-and-forget settings actions. The client helpers
67
+ * already resolve to null on failure (state reflects it), so this only trips
68
+ * on truly unexpected throws — log rather than swallow silently. */
69
+ function reportErr(err: unknown) {
70
+ // task-ui-components v2 dropped its logger; keep the package dependency-free.
71
+ console.error('[ConnectedSettings] action failed', (err as Error)?.message ?? err)
72
+ }
73
+
74
+ const TIER_LABEL: Record<Tier, string> = {
75
+ admin: 'Administrator',
76
+ wife: 'Wife',
77
+ service: 'Service',
78
+ friend: 'Friend',
79
+ public: 'Guest'
80
+ }
81
+
82
+ export function ConnectedSettings({
83
+ userType: userTypeProp,
84
+ name: nameProp,
85
+ onNameChange,
86
+ children
87
+ }: ConnectedSettingsProps) {
88
+ const [open, setOpen] = useState(false)
89
+ const rootRef = useRef<HTMLDivElement>(null)
90
+
91
+ // Identity — from props if supplied, else resolved lazily via whoami().
92
+ const [userType, setUserType] = useState<Tier>(userTypeProp ?? 'public')
93
+ const [resolvedName, setResolvedName] = useState<string | null>(nameProp ?? null)
94
+
95
+ // Name editing
96
+ const [nameDraft, setNameDraft] = useState(resolvedName ?? '')
97
+ const [editingName, setEditingName] = useState(false)
98
+ const [nameSaving, setNameSaving] = useState(false)
99
+ const [displayName, setLocalName] = useState<string | null>(resolvedName)
100
+
101
+ // Key swap. The draft lives only in this state and the input: it is never
102
+ // logged, stored or sent anywhere but the site's sign-in client.
103
+ const [keyFormOpen, setKeyFormOpen] = useState(false)
104
+ const [keyDraft, setKeyDraft] = useState('')
105
+ const [keyBusy, setKeyBusy] = useState(false)
106
+ const [keyError, setKeyError] = useState<string | null>(null)
107
+
108
+ // Passkeys, through the same site client: save one while signed in, or
109
+ // sign in with one from the key form.
110
+ const [passkeyBusy, setPasskeyBusy] = useState(false)
111
+ const [passkeySaved, setPasskeySaved] = useState(false)
112
+ const [passkeyError, setPasskeyError] = useState<string | null>(null)
113
+
114
+ // Content level
115
+ const [content, setContent] = useState<ContentLevelState | null>(null)
116
+ const [levelSaving, setLevelSaving] = useState(false)
117
+
118
+ // One flag for the whole prefetch, not one per request — the panel body
119
+ // reveals when everything has settled, so nothing pops in a beat late. In
120
+ // practice the user never sees it: the prefetch starts at mount and is long
121
+ // done by the time the gear is clicked. It only shows if they click within
122
+ // the first moments of the page.
123
+ const [resolving, setResolving] = useState(true)
124
+
125
+ // Everyone who is SIGNED IN, expressed as "not public" rather than as a list
126
+ // of tiers. The list form (`=== 'admin' || === 'friend' || === 'service'`)
127
+ // is an exact-match allowlist: it silently excluded `wife` the moment that
128
+ // tier existed, which is the failure the rank model is meant to prevent.
129
+ // There is no rank helper in this bundle, but "above public" needs none.
130
+ const showContentPill = userType !== 'public'
131
+
132
+ // Keep local identity in sync when the host controls it via props.
133
+ useEffect(() => {
134
+ if (userTypeProp !== undefined) setUserType(userTypeProp)
135
+ }, [userTypeProp])
136
+ useEffect(() => {
137
+ if (nameProp !== undefined) {
138
+ setResolvedName(nameProp)
139
+ setLocalName(nameProp)
140
+ setNameDraft(nameProp ?? '')
141
+ }
142
+ }, [nameProp])
143
+
144
+ // Resolve everything the panel shows AT MOUNT — not on open.
145
+ //
146
+ // This used to be gated on `open`, which made the gear the trigger for
147
+ // whoami + content-level (167ms / 186ms on prod, concurrent) — a round trip
148
+ // the user waited out with the panel already on screen and blank. Nothing
149
+ // about that data depends on the click:
150
+ // it's the signed-in user's own identity, settled at boot and unchanged for
151
+ // the life of the page. So it rides along with the app's boot traffic, and
152
+ // clicking the gear costs zero requests. `e2e/settings-prefetch.spec.ts` in
153
+ // hadoku-task pins that.
154
+ //
155
+ // The client memoises across callers, so mounting this in several places (or
156
+ // remounting it) does not multiply requests, and on hadoku.me the identity
157
+ // half is free — it awaits the whoami the shell already had in flight.
158
+ useEffect(() => {
159
+ let live = true
160
+ prefetchSettings({ userType: userTypeProp, name: nameProp })
161
+ .then(({ identity, content: state }) => {
162
+ if (!live) return
163
+ // Props stay authoritative: the host passed them because it can see an
164
+ // identity this component can't resolve for itself.
165
+ if (userTypeProp === undefined) {
166
+ setUserType(identity.userType)
167
+ setResolvedName(identity.name)
168
+ setLocalName(identity.name)
169
+ setNameDraft(identity.name ?? '')
170
+ }
171
+ if (state) setContent(state)
172
+ })
173
+ .catch(reportErr)
174
+ .finally(() => {
175
+ if (live) setResolving(false)
176
+ })
177
+ return () => {
178
+ live = false
179
+ }
180
+ // Hints are read once, on the first mount that gets there — the prefetch is
181
+ // page-scoped and a later prop change cannot un-issue it. Identity changes
182
+ // arrive through the sync effects above, and a key swap reloads the page.
183
+ // eslint-disable-next-line react-hooks/exhaustive-deps
184
+ }, [])
185
+
186
+ // Escape closes.
187
+ useEffect(() => {
188
+ if (!open) return
189
+ const onKey = (e: KeyboardEvent) => {
190
+ if (e.key === 'Escape') setOpen(false)
191
+ }
192
+ window.addEventListener('keydown', onKey)
193
+ return () => window.removeEventListener('keydown', onKey)
194
+ }, [open])
195
+
196
+ const saveName = useCallback(async () => {
197
+ const trimmed = nameDraft.trim()
198
+ if (!trimmed || trimmed === displayName) {
199
+ setEditingName(false)
200
+ return
201
+ }
202
+ setNameSaving(true)
203
+ const stored = await setDisplayName(trimmed)
204
+ setNameSaving(false)
205
+ if (stored !== null) {
206
+ setLocalName(stored)
207
+ setNameDraft(stored)
208
+ onNameChange?.(stored)
209
+ }
210
+ setEditingName(false)
211
+ }, [nameDraft, displayName, onNameChange])
212
+
213
+ // Open the form AND start loading the site's sign-in client, which begins its
214
+ // fingerprint collection as it evaluates — so it is ready by submit. Where the
215
+ // client cannot load at all (not on hadoku.me), the page-level /auth flow is
216
+ // the only way to sign in, so go there.
217
+ const openKeyForm = useCallback(() => {
218
+ setKeyFormOpen(true)
219
+ setKeyError(null)
220
+ loadSignIn()
221
+ .then(signIn => {
222
+ if (!signIn) window.location.assign(switchKeyHref())
223
+ })
224
+ .catch(reportErr)
225
+ }, [])
226
+
227
+ const submitKey = useCallback(async () => {
228
+ setKeyBusy(true)
229
+ setKeyError(null)
230
+ const signIn = await loadSignIn()
231
+ if (!signIn) {
232
+ window.location.assign(switchKeyHref())
233
+ return
234
+ }
235
+ try {
236
+ await signIn(keyDraft)
237
+ } catch (err) {
238
+ // A refused key is an inline error, never a redirect.
239
+ setKeyError((err as Error)?.message || 'Invalid key')
240
+ setKeyBusy(false)
241
+ return
242
+ }
243
+ // The page now acts for a different identity: everything must re-mount.
244
+ // Busy stays on through the reload so the form can't be resubmitted.
245
+ window.location.reload()
246
+ }, [keyDraft])
247
+
248
+ const savePasskey = useCallback(async () => {
249
+ setPasskeyBusy(true)
250
+ setPasskeyError(null)
251
+ const client = await loadSignInClient()
252
+ if (!client?.addPasskey) {
253
+ window.location.assign(passkeyHref('add'))
254
+ return
255
+ }
256
+ try {
257
+ await client.addPasskey()
258
+ setPasskeySaved(true)
259
+ } catch (err) {
260
+ setPasskeyError((err as Error)?.message || 'Passkey not saved')
261
+ }
262
+ setPasskeyBusy(false)
263
+ }, [])
264
+
265
+ const signInWithPasskey = useCallback(async () => {
266
+ setKeyBusy(true)
267
+ setKeyError(null)
268
+ const client = await loadSignInClient()
269
+ if (!client?.signInWithPasskey) {
270
+ window.location.assign(passkeyHref('signin'))
271
+ return
272
+ }
273
+ try {
274
+ await client.signInWithPasskey()
275
+ } catch (err) {
276
+ setKeyError((err as Error)?.message || 'Passkey sign-in cancelled')
277
+ setKeyBusy(false)
278
+ return
279
+ }
280
+ window.location.reload()
281
+ }, [])
282
+
283
+ const pickLevel = useCallback(
284
+ async (level: number) => {
285
+ if (!content || level === content.level || levelSaving) return
286
+ const prev = content
287
+ setContent({ ...content, level }) // optimistic
288
+ setLevelSaving(true)
289
+ const result = await setContentLevel(level)
290
+ setLevelSaving(false)
291
+ setContent(result ?? prev) // adopt server truth, or revert
292
+ },
293
+ [content, levelSaving]
294
+ )
295
+
296
+ // No caller-supplied class on the root. The four rows below are the
297
+ // platform's, not the app's, and a className here is enough to restyle or
298
+ // hide any of them — which is the drift this component exists to prevent.
299
+ // Apps extend via `children`, never by reaching into this markup.
300
+ return (
301
+ <div className="settings-popout" ref={rootRef}>
302
+ <button
303
+ type="button"
304
+ className="app-header__icon-btn settings-toggle-btn"
305
+ aria-label="User settings"
306
+ aria-expanded={open}
307
+ onClick={() => setOpen(v => !v)}
308
+ >
309
+ <SettingsIcon />
310
+ </button>
311
+
312
+ {open && <div className="settings-popout__overlay" onClick={() => setOpen(false)} />}
313
+
314
+ {open && (
315
+ <div className="settings-popout__panel" role="dialog" aria-label="User settings">
316
+ <div className="settings-popout__header">Settings</div>
317
+
318
+ {/* Access tier. While resolving, the state still says 'public' —
319
+ rendering "Guest" there would be a wrong answer that corrects
320
+ itself a moment later, which reads exactly like the lag this
321
+ placeholder exists to remove. */}
322
+ <section className="settings-popout__row">
323
+ <span className="settings-popout__label">Access tier</span>
324
+ {resolving ? (
325
+ <span className="settings-popout__value">…</span>
326
+ ) : (
327
+ <span className={`settings-popout__tier settings-popout__tier--${userType}`}>
328
+ {TIER_LABEL[userType]}
329
+ </span>
330
+ )}
331
+ </section>
332
+
333
+ {/* Display name */}
334
+ <section className="settings-popout__row">
335
+ <span className="settings-popout__label">Display name</span>
336
+ {resolving ? (
337
+ <span className="settings-popout__value">…</span>
338
+ ) : editingName ? (
339
+ <span className="settings-popout__inline">
340
+ <input
341
+ className="settings-popout__input"
342
+ value={nameDraft}
343
+ maxLength={64}
344
+ autoFocus
345
+ disabled={nameSaving}
346
+ onChange={e => setNameDraft(e.target.value)}
347
+ onKeyDown={e => {
348
+ if (e.key === 'Enter') {
349
+ saveName().catch(reportErr)
350
+ }
351
+ if (e.key === 'Escape') {
352
+ setEditingName(false)
353
+ setNameDraft(displayName ?? '')
354
+ }
355
+ }}
356
+ />
357
+ <button
358
+ type="button"
359
+ className="settings-popout__btn settings-popout__btn--primary"
360
+ disabled={nameSaving}
361
+ onClick={() => {
362
+ saveName().catch(reportErr)
363
+ }}
364
+ >
365
+ {nameSaving ? '…' : 'Save'}
366
+ </button>
367
+ </span>
368
+ ) : (
369
+ <span className="settings-popout__inline">
370
+ <span className="settings-popout__value">{displayName || 'Unnamed'}</span>
371
+ <button
372
+ type="button"
373
+ className="settings-popout__btn"
374
+ onClick={() => {
375
+ setNameDraft(displayName ?? '')
376
+ setEditingName(true)
377
+ }}
378
+ >
379
+ Edit
380
+ </button>
381
+ </span>
382
+ )}
383
+ </section>
384
+
385
+ {/* Content visibility level. Held back until the first-open fetch
386
+ settles, so it lands in the same paint as the tier and name
387
+ rather than a round trip behind them. `content` is fetched
388
+ concurrently with whoami(), so by then it is already here. */}
389
+ {!resolving && showContentPill && (
390
+ <section className="settings-popout__row settings-popout__row--stack">
391
+ <span className="settings-popout__label">Content visibility</span>
392
+ {content ? (
393
+ <>
394
+ <div
395
+ className="settings-popout__pill"
396
+ role="radiogroup"
397
+ aria-label="Content visibility level"
398
+ >
399
+ {Array.from({ length: content.maxLevel }, (_, i) => i + 1).map(lvl => (
400
+ <button
401
+ key={lvl}
402
+ type="button"
403
+ role="radio"
404
+ aria-checked={lvl === content.level}
405
+ aria-label={`Level ${lvl}`}
406
+ className={`settings-popout__seg${
407
+ lvl <= content.level ? ' settings-popout__seg--filled' : ''
408
+ }`}
409
+ disabled={levelSaving}
410
+ onClick={() => {
411
+ pickLevel(lvl).catch(reportErr)
412
+ }}
413
+ >
414
+ {lvl}
415
+ </button>
416
+ ))}
417
+ </div>
418
+ <span className="settings-popout__hint">
419
+ Higher levels reveal more mature content across apps. Level 1 is
420
+ safe-for-everyone.
421
+ </span>
422
+ </>
423
+ ) : (
424
+ // Not "Loading…" — the fetch has already settled by the time
425
+ // this section renders at all, so a null here means it failed.
426
+ <span className="settings-popout__hint">Currently unavailable.</span>
427
+ )}
428
+ </section>
429
+ )}
430
+
431
+ {/* Save a passkey: signed-in people only (the site refuses others). */}
432
+ {!resolving && userType !== 'public' && passkeysSupported() && (
433
+ <>
434
+ <section className="settings-popout__row">
435
+ <span className="settings-popout__label">Passkey</span>
436
+ <button
437
+ type="button"
438
+ className="settings-popout__btn"
439
+ disabled={passkeyBusy || passkeySaved}
440
+ onClick={() => {
441
+ savePasskey().catch(reportErr)
442
+ }}
443
+ >
444
+ {passkeySaved ? 'Saved' : passkeyBusy ? 'Saving…' : 'Save'}
445
+ </button>
446
+ </section>
447
+ {passkeyError && (
448
+ <span className="settings-popout__error" role="alert">
449
+ {passkeyError}
450
+ </span>
451
+ )}
452
+ </>
453
+ )}
454
+
455
+ {/* Switch key. Signs in inline through the SITE's sign-in client
456
+ (the /auth page's own code path), never a post of ours. A real
457
+ <form> with a username + current-password pair, so password
458
+ managers offer and save the key. */}
459
+ {keyFormOpen ? (
460
+ <section className="settings-popout__row settings-popout__row--stack">
461
+ <form
462
+ className="settings-popout__key-form"
463
+ onSubmit={e => {
464
+ e.preventDefault()
465
+ submitKey().catch(reportErr)
466
+ }}
467
+ >
468
+ <input
469
+ type="text"
470
+ name="username"
471
+ autoComplete="username"
472
+ value="hadoku"
473
+ readOnly
474
+ hidden
475
+ />
476
+ <label className="settings-popout__label" htmlFor="settings-popout-key">
477
+ Access key
478
+ </label>
479
+ <span className="settings-popout__inline">
480
+ <input
481
+ id="settings-popout-key"
482
+ className="settings-popout__input"
483
+ type="password"
484
+ name="password"
485
+ autoComplete="current-password"
486
+ autoCapitalize="none"
487
+ spellCheck={false}
488
+ autoFocus
489
+ value={keyDraft}
490
+ disabled={keyBusy}
491
+ aria-invalid={keyError !== null}
492
+ aria-describedby={keyError ? 'settings-popout-key-error' : undefined}
493
+ onChange={e => setKeyDraft(e.target.value)}
494
+ />
495
+ <button
496
+ type="submit"
497
+ className="settings-popout__btn settings-popout__btn--primary"
498
+ disabled={keyBusy}
499
+ >
500
+ {keyBusy ? 'Signing in…' : 'Sign in'}
501
+ </button>
502
+ </span>
503
+ {passkeysSupported() && (
504
+ <button
505
+ type="button"
506
+ className="settings-popout__btn"
507
+ disabled={keyBusy}
508
+ onClick={() => {
509
+ signInWithPasskey().catch(reportErr)
510
+ }}
511
+ >
512
+ Use a passkey
513
+ </button>
514
+ )}
515
+ {keyError && (
516
+ <span
517
+ id="settings-popout-key-error"
518
+ className="settings-popout__error"
519
+ role="alert"
520
+ >
521
+ {keyError}
522
+ </span>
523
+ )}
524
+ </form>
525
+ </section>
526
+ ) : (
527
+ <section className="settings-popout__row">
528
+ <span className="settings-popout__label">Access key</span>
529
+ <button type="button" className="settings-popout__btn" onClick={openKeyForm}>
530
+ Switch key
531
+ </button>
532
+ </section>
533
+ )}
534
+
535
+ {/* App-specific settings (optional) — same popout, one gear. */}
536
+ {children && (
537
+ <section className="settings-popout__row settings-popout__row--stack settings-popout__app">
538
+ {children}
539
+ </section>
540
+ )}
541
+ </div>
542
+ )}
543
+ </div>
544
+ )
545
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * ConnectedThemePicker - Stateful theme picker component
3
+ * A convenience wrapper around ThemePicker with built-in state management
4
+ */
5
+
6
+ import React from 'react'
7
+ import { ThemePicker } from './ThemePicker'
8
+ import type { ThemeFamily, DropdownPlacement } from '../types'
9
+
10
+ export interface ConnectedThemePickerProps {
11
+ /** Available theme families */
12
+ themeFamilies: ThemeFamily[]
13
+ /** Current active theme */
14
+ currentTheme: string
15
+ /** Callback when theme is changed */
16
+ onThemeChange: (theme: string) => void
17
+ /** Optional: Function to get icon for current theme (for toggle button) */
18
+ getThemeIcon?: (theme: string) => React.ReactNode
19
+ /** Optional: CSS class name for the container */
20
+ className?: string
21
+ /** Optional callback when settings icon is clicked */
22
+ onSettingsClick?: () => void
23
+ /** Optional: Direction the dropdown opens. Defaults to 'auto' (edge detection) */
24
+ dropdownPlacement?: DropdownPlacement
25
+ }
26
+
27
+ /**
28
+ * ConnectedThemePicker - A stateful theme picker with built-in open/close state management
29
+ *
30
+ * This component manages its own isOpen state, making it easier to use than the base ThemePicker.
31
+ *
32
+ * @example
33
+ * ```tsx
34
+ * import { ConnectedThemePicker } from '@wolffm/task-ui-components'
35
+ * import { THEME_FAMILIES, THEME_ICON_MAP } from '@wolffm/themes'
36
+ * import '@wolffm/task-ui-components/theme-picker.css'
37
+ *
38
+ * function App() {
39
+ * const { theme, setTheme } = useTheme()
40
+ *
41
+ * return (
42
+ * <ConnectedThemePicker
43
+ * themeFamilies={THEME_FAMILIES}
44
+ * currentTheme={theme}
45
+ * onThemeChange={setTheme}
46
+ * getThemeIcon={(theme) => {
47
+ * const Icon = THEME_ICON_MAP[theme]
48
+ * return Icon ? <Icon /> : null
49
+ * }}
50
+ * />
51
+ * )
52
+ * }
53
+ * ```
54
+ */
55
+ export function ConnectedThemePicker({
56
+ themeFamilies,
57
+ currentTheme,
58
+ onThemeChange,
59
+ getThemeIcon,
60
+ className,
61
+ onSettingsClick,
62
+ dropdownPlacement
63
+ }: ConnectedThemePickerProps) {
64
+ const [isOpen, setIsOpen] = React.useState(false)
65
+
66
+ return (
67
+ <ThemePicker
68
+ currentTheme={currentTheme}
69
+ isOpen={isOpen}
70
+ themeFamilies={themeFamilies}
71
+ onThemeChange={onThemeChange}
72
+ onToggle={() => setIsOpen(!isOpen)}
73
+ getThemeIcon={getThemeIcon}
74
+ className={className}
75
+ onSettingsClick={onSettingsClick}
76
+ dropdownPlacement={dropdownPlacement}
77
+ />
78
+ )
79
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * ContextMenu Component
3
+ * Generic context menu with position-based rendering
4
+ */
5
+
6
+ import React from 'react'
7
+ import '../context-menu.css'
8
+
9
+ export interface ContextMenuItem {
10
+ /** ReactNode, not string, so callers can put an <Icon> beside the text. */
11
+ label: React.ReactNode
12
+ onClick: () => void | Promise<void>
13
+ isDanger?: boolean
14
+ disabled?: boolean
15
+ }
16
+
17
+ export interface ContextMenuProps {
18
+ isOpen: boolean
19
+ x: number
20
+ y: number
21
+ items: ContextMenuItem[]
22
+ className?: string
23
+ }
24
+
25
+ export function ContextMenu({ isOpen, x, y, items, className = 'context-menu' }: ContextMenuProps) {
26
+ if (!isOpen) return null
27
+
28
+ // Use CSS custom properties for dynamic positioning
29
+ const style = {
30
+ '--context-menu-x': `${x}px`,
31
+ '--context-menu-y': `${y}px`
32
+ } as React.CSSProperties
33
+
34
+ return (
35
+ <div className={className} style={style}>
36
+ {items.map((item, index) => (
37
+ <button
38
+ key={index}
39
+ className={`context-menu-item ${item.isDanger ? 'context-menu-item--danger' : ''}`}
40
+ onClick={item.onClick}
41
+ disabled={item.disabled}
42
+ >
43
+ {item.label}
44
+ </button>
45
+ ))}
46
+ </div>
47
+ )
48
+ }