@hadoku/task-ui-components 4.22.3 → 4.22.5

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.
@@ -8,6 +8,10 @@
8
8
  * - global content-visibility level (tiered pill → PUT /prefs/api/v1/content-level)
9
9
  * - switch key (an inline sign-in form, on the site's own sign-in client)
10
10
  *
11
+ * Those are grouped into tabs (Profile, Content, Account), followed by the
12
+ * app's own: `children` as one "App" tab, `appTabs` as one tab each. The open
13
+ * tab is remembered per user and per app (useSettingsTab, @wolffm/themes).
14
+ *
11
15
  * "Connected" (parallel to ConnectedThemePicker): it self-wires to the
12
16
  * hadoku.me edge-router via same-origin fetches (see lib/settingsClient), so
13
17
  * dropping `<ConnectedSettings />` into any app under hadoku.me needs zero
@@ -25,7 +29,8 @@
25
29
  * maxLevel comes from the server so the tier ceiling is never trusted from the
26
30
  * client.
27
31
  */
28
- import React, { useCallback, useEffect, useRef, useState } from 'react'
32
+ import React, { useCallback, useEffect, useId, useRef, useState } from 'react'
33
+ import { useSettingsTab } from '@wolffm/themes'
29
34
  import { SettingsIcon } from './ThemeIcons'
30
35
  import { AvatarPicker } from './AvatarPicker'
31
36
  import {
@@ -56,14 +61,24 @@ export interface ConnectedSettingsProps {
56
61
  name?: string | null
57
62
  /** Notify the host (e.g. a "Signed in as …" label) of a rename. */
58
63
  onNameChange?: (name: string | null) => void
59
- /** App-specific settings rendered as a final section in the same popout, so
60
- * an app with its own preferences keeps ONE unified gear instead of a second
61
- * control. Shown under an "App preferences" divider.
64
+ /** App-specific settings as ONE tab, labelled "App", after the site's own
65
+ * tabs — so an app with its own preferences keeps one unified gear instead
66
+ * of a second control. For more than one section, use `appTabs`.
62
67
  *
63
68
  * This slot is deliberately unconstrained — put whatever the app needs in
64
- * it. The constraint is only that it lands AFTER the four canonical rows and
65
- * cannot change them. */
69
+ * it. The constraint is only that it cannot change the site's tabs. */
66
70
  children?: React.ReactNode
71
+ /** App-specific settings as tabs of their own, after `children`'s "App" tab
72
+ * if both are given. `id` is stable and unique within the app: the last-open
73
+ * tab is remembered by it. `render` runs only while its tab is open. */
74
+ appTabs?: AppSettingsTab[]
75
+ }
76
+
77
+ /** One app-contributed tab in the settings popout. */
78
+ export interface AppSettingsTab {
79
+ id: string
80
+ label: string
81
+ render: () => React.ReactNode
67
82
  }
68
83
 
69
84
  /** Rejects once `signal` aborts, so a call that ignores the signal (an older
@@ -105,7 +120,6 @@ const PASSKEY_DOT: React.CSSProperties = {
105
120
  * inline, so the rule is injected once into the document instead, for the
106
121
  * same reason PASSKEY_DOT is inline. */
107
122
  const PASSKEY_PULSE_CLASS = 'settings-popout__btn--pulse'
108
- const PASSKEY_PULSE_STYLE_ID = 'settings-passkey-pulse'
109
123
  const PASSKEY_PULSE_CSS = `
110
124
  @keyframes settings-passkey-pulse {
111
125
  0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-primary) 70%, transparent); }
@@ -119,14 +133,72 @@ const PASSKEY_PULSE_CSS = `
119
133
  }
120
134
  `
121
135
 
122
- function usePasskeyPulseStyle(active: boolean) {
136
+ /** The tab strip and its panels. Injected for the same reason as the pulse:
137
+ * an app's copy of app-header.css predates the tabs until it rebuilds. The
138
+ * strip stays pinned while a long tab scrolls, and scrolls sideways itself
139
+ * when the tabs outgrow a phone-width panel. */
140
+ const TABS_CSS = `
141
+ .settings-popout__top {
142
+ position: sticky;
143
+ top: -16px;
144
+ z-index: 1;
145
+ display: flex;
146
+ flex-direction: column;
147
+ gap: 10px;
148
+ margin: -16px -16px 0;
149
+ padding: 16px 16px 0;
150
+ background: var(--color-bg-card);
151
+ border-bottom: 1px solid var(--color-border);
152
+ }
153
+ .settings-popout__tabs {
154
+ display: flex;
155
+ gap: 2px;
156
+ margin: 0 -16px -1px;
157
+ padding: 0 16px;
158
+ overflow-x: auto;
159
+ scrollbar-width: thin;
160
+ }
161
+ .settings-popout__tab {
162
+ position: relative;
163
+ flex: none;
164
+ padding: 6px 10px;
165
+ border: 0;
166
+ border-bottom: 2px solid transparent;
167
+ background: transparent;
168
+ color: var(--color-text-secondary);
169
+ font: inherit;
170
+ font-size: 0.8rem;
171
+ font-weight: 600;
172
+ white-space: nowrap;
173
+ cursor: pointer;
174
+ }
175
+ .settings-popout__tab:hover {
176
+ color: var(--color-text);
177
+ }
178
+ .settings-popout__tab[aria-selected='true'] {
179
+ color: var(--color-text);
180
+ border-bottom-color: var(--color-primary);
181
+ }
182
+ .settings-popout__tab:focus-visible {
183
+ outline: 2px solid var(--color-primary);
184
+ outline-offset: -2px;
185
+ border-radius: 6px 6px 0 0;
186
+ }
187
+ .settings-popout__tabpanel {
188
+ display: flex;
189
+ flex-direction: column;
190
+ gap: 14px;
191
+ }
192
+ `
193
+
194
+ function useInjectedStyle(id: string, css: string, active: boolean) {
123
195
  useEffect(() => {
124
- if (!active || document.getElementById(PASSKEY_PULSE_STYLE_ID)) return
196
+ if (!active || document.getElementById(id)) return
125
197
  const el = document.createElement('style')
126
- el.id = PASSKEY_PULSE_STYLE_ID
127
- el.textContent = PASSKEY_PULSE_CSS
198
+ el.id = id
199
+ el.textContent = css
128
200
  document.head.appendChild(el)
129
- }, [active])
201
+ }, [id, css, active])
130
202
  }
131
203
 
132
204
  const TIER_LABEL: Record<Tier, string> = {
@@ -142,10 +214,16 @@ export function ConnectedSettings({
142
214
  userType: userTypeProp,
143
215
  name: nameProp,
144
216
  onNameChange,
145
- children
217
+ children,
218
+ appTabs
146
219
  }: ConnectedSettingsProps) {
147
220
  const [open, setOpen] = useState(false)
148
221
  const rootRef = useRef<HTMLDivElement>(null)
222
+ const tabIdBase = useId()
223
+ const tabRefs = useRef(new Map<string, HTMLButtonElement>())
224
+ const [savedTab, saveTab] = useSettingsTab()
225
+ // This visit's pick; wins over the saved one while it is still offered.
226
+ const [pickedTab, setPickedTab] = useState<string | null>(null)
149
227
 
150
228
  // Identity — from props if supplied, else resolved lazily via whoami().
151
229
  const [userType, setUserType] = useState<Tier>(userTypeProp ?? 'public')
@@ -210,7 +288,66 @@ export function ConnectedSettings({
210
288
  passkeyCount === 0 &&
211
289
  !passkeySaved &&
212
290
  passkeysSupported()
213
- usePasskeyPulseStyle(needsPasskey)
291
+ useInjectedStyle('settings-passkey-pulse', PASSKEY_PULSE_CSS, needsPasskey)
292
+ useInjectedStyle('settings-tabs', TABS_CSS, open)
293
+
294
+ // The site's own groups first, then the app's. App ids are namespaced so an
295
+ // app tab called "account" can never collide with the site's.
296
+ const tabs: { id: string; label: string; dot?: boolean }[] = [
297
+ { id: 'profile', label: 'Profile' },
298
+ ...(!resolving && showContentPill ? [{ id: 'content', label: 'Content' }] : []),
299
+ // The gear's dot asks for a passkey; this one says where Save passkey is.
300
+ { id: 'account', label: 'Account', dot: needsPasskey },
301
+ ...(children ? [{ id: 'app:', label: 'App' }] : []),
302
+ ...(appTabs ?? []).map(t => ({ id: `app:${t.id}`, label: t.label }))
303
+ ]
304
+ const tabIds = tabs.map(t => t.id)
305
+ // Nothing remembered: open on the app's first tab, which is usually why the
306
+ // gear was clicked inside an app.
307
+ const defaultTab = tabIds.find(id => id.startsWith('app:')) ?? 'profile'
308
+ const activeTab =
309
+ [pickedTab, savedTab].find((id): id is string => id !== null && tabIds.includes(id)) ??
310
+ defaultTab
311
+ const appTab = appTabs?.find(t => `app:${t.id}` === activeTab)
312
+ const tabDomId = (id: string) => `${tabIdBase}tab-${id}`
313
+ const panelDomId = `${tabIdBase}panel`
314
+
315
+ const selectTab = useCallback(
316
+ (id: string) => {
317
+ setPickedTab(id)
318
+ saveTab(id)
319
+ },
320
+ [saveTab]
321
+ )
322
+
323
+ // Arrow keys, Home and End move between tabs and open the one they land on
324
+ // (the ARIA tablist pattern with automatic activation).
325
+ const onTabKey = (e: React.KeyboardEvent) => {
326
+ const i = tabIds.indexOf(activeTab)
327
+ const n = tabIds.length
328
+ const next =
329
+ e.key === 'ArrowRight'
330
+ ? tabIds[(i + 1) % n]
331
+ : e.key === 'ArrowLeft'
332
+ ? tabIds[(i - 1 + n) % n]
333
+ : e.key === 'Home'
334
+ ? tabIds[0]
335
+ : e.key === 'End'
336
+ ? tabIds[n - 1]
337
+ : undefined
338
+ if (next === undefined) return
339
+ e.preventDefault()
340
+ selectTab(next)
341
+ tabRefs.current.get(next)?.focus()
342
+ }
343
+
344
+ // A remembered tab past the edge of a narrow strip is scrolled into sight.
345
+ // jsdom implements no scrollIntoView, and every consumer's unit test that
346
+ // renders the popout runs there, so the call is optional.
347
+ useEffect(() => {
348
+ if (open)
349
+ tabRefs.current.get(activeTab)?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })
350
+ }, [open, activeTab])
214
351
 
215
352
  // Keep local identity in sync when the host controls it via props.
216
353
  useEffect(() => {
@@ -419,7 +556,7 @@ export function ConnectedSettings({
419
556
  // No caller-supplied class on the root. The four rows below are the
420
557
  // platform's, not the app's, and a className here is enough to restyle or
421
558
  // hide any of them — which is the drift this component exists to prevent.
422
- // Apps extend via `children`, never by reaching into this markup.
559
+ // Apps extend via `children` and `appTabs`, never by reaching into this markup.
423
560
  return (
424
561
  <div className="settings-popout" ref={rootRef}>
425
562
  <button
@@ -440,364 +577,417 @@ export function ConnectedSettings({
440
577
 
441
578
  {open && (
442
579
  <div className="settings-popout__panel" role="dialog" aria-label="User settings">
443
- <div className="settings-popout__header">Settings</div>
444
-
445
- {/* Access tier. While resolving, the state still says 'public' —
446
- rendering "Guest" there would be a wrong answer that corrects
447
- itself a moment later, which reads exactly like the lag this
448
- placeholder exists to remove. */}
449
- <section className="settings-popout__row">
450
- <span className="settings-popout__label">Access tier</span>
451
- {resolving ? (
452
- <span className="settings-popout__value">…</span>
453
- ) : (
454
- <span className={`settings-popout__tier settings-popout__tier--${userType}`}>
455
- {TIER_LABEL[userType]}
456
- </span>
457
- )}
458
- </section>
459
-
460
- {/* Display name */}
461
- <section className="settings-popout__row">
462
- <span className="settings-popout__label">Display name</span>
463
- {resolving ? (
464
- <span className="settings-popout__value">…</span>
465
- ) : editingName ? (
466
- <span className="settings-popout__inline">
467
- <input
468
- className="settings-popout__input"
469
- value={nameDraft}
470
- maxLength={64}
471
- autoFocus
472
- disabled={nameSaving}
473
- aria-invalid={nameError !== null}
474
- aria-describedby={nameError ? 'settings-popout-name-error' : undefined}
475
- onChange={e => {
476
- setNameDraft(e.target.value)
477
- setNameError(null)
478
- }}
479
- onKeyDown={e => {
480
- if (e.key === 'Enter') {
481
- saveName().catch(reportErr)
482
- }
483
- if (e.key === 'Escape') {
484
- // The edit's Escape, not the popout's (window listener).
485
- e.stopPropagation()
486
- setEditingName(false)
487
- setNameError(null)
488
- setNameDraft(displayName ?? '')
489
- }
490
- }}
491
- />
580
+ <div className="settings-popout__top">
581
+ <div className="settings-popout__header">Settings</div>
582
+ <div
583
+ className="settings-popout__tabs"
584
+ role="tablist"
585
+ aria-label="Settings sections"
586
+ onKeyDown={onTabKey}
587
+ >
588
+ {tabs.map(t => (
492
589
  <button
493
- type="button"
494
- className="settings-popout__btn settings-popout__btn--primary"
495
- disabled={nameSaving}
496
- onClick={() => {
497
- saveName().catch(reportErr)
590
+ key={t.id}
591
+ ref={el => {
592
+ if (el) tabRefs.current.set(t.id, el)
593
+ else tabRefs.current.delete(t.id)
498
594
  }}
499
- >
500
- {nameSaving ? '…' : 'Save'}
501
- </button>
502
- {nameError && (
503
- <span
504
- id="settings-popout-name-error"
505
- className="settings-popout__error"
506
- role="alert"
507
- >
508
- {nameError}
509
- </span>
510
- )}
511
- </span>
512
- ) : (
513
- <button
514
- type="button"
515
- className="settings-popout__value settings-popout__value--editable"
516
- aria-label="Edit display name"
517
- onClick={() => {
518
- setNameDraft(displayName ?? '')
519
- setEditingName(true)
520
- }}
521
- >
522
- {displayName || 'Unnamed'}
523
- </button>
524
- )}
525
- </section>
526
-
527
- {/* Avatar: picture and character, chosen in place (AvatarPicker).
528
- Collapsed until Change, so opening the gear costs no request and no
529
- page load reads the avatar. Hidden for public callers, like the
530
- content pill: an avatar needs a session. */}
531
- {!resolving && userType !== 'public' && (
532
- <section className="settings-popout__row settings-popout__row--stack">
533
- <span className="settings-popout__inline">
534
- <span className="settings-popout__label settings-popout__label--grow">Avatar</span>
535
- <button
536
595
  type="button"
537
- className="settings-popout__btn"
538
- aria-expanded={avatarOpen}
539
- onClick={() => setAvatarOpen(v => !v)}
596
+ role="tab"
597
+ id={tabDomId(t.id)}
598
+ className="settings-popout__tab"
599
+ aria-selected={t.id === activeTab}
600
+ aria-controls={panelDomId}
601
+ tabIndex={t.id === activeTab ? 0 : -1}
602
+ onClick={() => selectTab(t.id)}
540
603
  >
541
- {avatarOpen ? 'Done' : 'Change'}
604
+ {t.label}
605
+ {t.dot && <span style={PASSKEY_DOT} aria-hidden="true" />}
542
606
  </button>
543
- </span>
544
- {avatarOpen && <AvatarPicker />}
545
- </section>
546
- )}
547
-
548
- {/* Content visibility level. Held back until the first-open fetch
549
- settles, so it lands in the same paint as the tier and name
550
- rather than a round trip behind them. `content` is fetched
551
- concurrently with whoami(), so by then it is already here. */}
552
- {!resolving && showContentPill && (
553
- <section className="settings-popout__row settings-popout__row--stack">
554
- <span className="settings-popout__label">Content visibility</span>
555
- {content ? (
556
- <>
557
- <div
558
- className="settings-popout__pill"
559
- role="radiogroup"
560
- aria-label="Content visibility level"
561
- >
562
- {Array.from({ length: content.maxLevel }, (_, i) => i + 1).map(lvl => (
607
+ ))}
608
+ </div>
609
+ </div>
610
+
611
+ <div
612
+ className="settings-popout__tabpanel"
613
+ role="tabpanel"
614
+ id={panelDomId}
615
+ aria-labelledby={tabDomId(activeTab)}
616
+ >
617
+ {activeTab === 'profile' && (
618
+ <>
619
+ {/* Access tier. While resolving, the state still says 'public' —
620
+ rendering "Guest" there would be a wrong answer that corrects
621
+ itself a moment later, which reads exactly like the lag this
622
+ placeholder exists to remove. */}
623
+ <section className="settings-popout__row">
624
+ <span className="settings-popout__label">Access tier</span>
625
+ {resolving ? (
626
+ <span className="settings-popout__value">…</span>
627
+ ) : (
628
+ <span className={`settings-popout__tier settings-popout__tier--${userType}`}>
629
+ {TIER_LABEL[userType]}
630
+ </span>
631
+ )}
632
+ </section>
633
+
634
+ {/* Display name */}
635
+ <section className="settings-popout__row">
636
+ <span className="settings-popout__label">Display name</span>
637
+ {resolving ? (
638
+ <span className="settings-popout__value">…</span>
639
+ ) : editingName ? (
640
+ <span className="settings-popout__inline">
641
+ <input
642
+ className="settings-popout__input"
643
+ value={nameDraft}
644
+ maxLength={64}
645
+ autoFocus
646
+ disabled={nameSaving}
647
+ aria-invalid={nameError !== null}
648
+ aria-describedby={nameError ? 'settings-popout-name-error' : undefined}
649
+ onChange={e => {
650
+ setNameDraft(e.target.value)
651
+ setNameError(null)
652
+ }}
653
+ onKeyDown={e => {
654
+ if (e.key === 'Enter') {
655
+ saveName().catch(reportErr)
656
+ }
657
+ if (e.key === 'Escape') {
658
+ // The edit's Escape, not the popout's (window listener).
659
+ e.stopPropagation()
660
+ setEditingName(false)
661
+ setNameError(null)
662
+ setNameDraft(displayName ?? '')
663
+ }
664
+ }}
665
+ />
563
666
  <button
564
- key={lvl}
565
667
  type="button"
566
- role="radio"
567
- aria-checked={lvl === content.level}
568
- aria-label={`Level ${lvl}`}
569
- className={`settings-popout__seg${
570
- lvl <= content.level ? ' settings-popout__seg--filled' : ''
571
- }`}
572
- disabled={levelSaving}
668
+ className="settings-popout__btn settings-popout__btn--primary"
669
+ disabled={nameSaving}
573
670
  onClick={() => {
574
- pickLevel(lvl).catch(reportErr)
671
+ saveName().catch(reportErr)
575
672
  }}
576
673
  >
577
- {lvl}
674
+ {nameSaving ? '…' : 'Save'}
578
675
  </button>
579
- ))}
580
- </div>
581
- <span className="settings-popout__hint">
582
- Higher levels reveal more mature content across apps. Level 1 is
583
- safe-for-everyone.
584
- </span>
585
- </>
586
- ) : (
587
- // Not "Loading…" — the fetch has already settled by the time
588
- // this section renders at all, so a null here means it failed.
589
- <span className="settings-popout__hint">Currently unavailable.</span>
590
- )}
591
- </section>
592
- )}
593
-
594
- {/* Switch key. Signs in inline through the SITE's sign-in client
595
- (the /auth page's own code path), never a post of ours. A real
596
- <form> with a username + current-password pair, so password
597
- managers offer and save the key. */}
598
- {keyFormOpen ? (
599
- <section className="settings-popout__row settings-popout__row--stack">
600
- <form
601
- className="settings-popout__key-form"
602
- onSubmit={e => {
603
- e.preventDefault()
604
- submitKey().catch(reportErr)
605
- }}
606
- >
607
- <input
608
- type="text"
609
- name="username"
610
- autoComplete="username"
611
- value="hadoku"
612
- readOnly
613
- hidden
614
- />
615
- <label className="settings-popout__label" htmlFor="settings-popout-key">
616
- Access key
617
- </label>
618
- <span className="settings-popout__inline">
619
- <input
620
- id="settings-popout-key"
621
- className="settings-popout__input"
622
- type="password"
623
- name="password"
624
- autoComplete="current-password"
625
- autoCapitalize="none"
626
- spellCheck={false}
627
- autoFocus
628
- value={keyDraft}
629
- disabled={keyBusy}
630
- aria-invalid={keyError !== null}
631
- aria-describedby={keyError ? 'settings-popout-key-error' : undefined}
632
- onChange={e => setKeyDraft(e.target.value)}
633
- />
634
- <button
635
- type="submit"
636
- className="settings-popout__btn settings-popout__btn--primary"
637
- disabled={keyBusy}
638
- >
639
- {keyBusy ? 'Signing in…' : 'Sign in'}
640
- </button>
641
- </span>
642
- {passkeysSupported() && (
643
- <span className="settings-popout__inline">
676
+ {nameError && (
677
+ <span
678
+ id="settings-popout-name-error"
679
+ className="settings-popout__error"
680
+ role="alert"
681
+ >
682
+ {nameError}
683
+ </span>
684
+ )}
685
+ </span>
686
+ ) : (
644
687
  <button
645
688
  type="button"
646
- className="settings-popout__btn"
647
- disabled={keyBusy}
689
+ className="settings-popout__value settings-popout__value--editable"
690
+ aria-label="Edit display name"
648
691
  onClick={() => {
649
- signInWithPasskey().catch(reportErr)
692
+ setNameDraft(displayName ?? '')
693
+ setEditingName(true)
650
694
  }}
651
695
  >
652
- {passkeyMode === 'signin' ? 'Waiting for passkey…' : 'Use a passkey'}
696
+ {displayName || 'Unnamed'}
653
697
  </button>
654
- </span>
698
+ )}
699
+ </section>
700
+
701
+ {/* Avatar: picture and character, chosen in place (AvatarPicker).
702
+ Collapsed until Change, so opening the gear costs no request and no
703
+ page load reads the avatar. Hidden for public callers, like the
704
+ content pill: an avatar needs a session. */}
705
+ {!resolving && userType !== 'public' && (
706
+ <section className="settings-popout__row settings-popout__row--stack">
707
+ <span className="settings-popout__inline">
708
+ <span className="settings-popout__label settings-popout__label--grow">
709
+ Avatar
710
+ </span>
711
+ <button
712
+ type="button"
713
+ className="settings-popout__btn"
714
+ aria-expanded={avatarOpen}
715
+ onClick={() => setAvatarOpen(v => !v)}
716
+ >
717
+ {avatarOpen ? 'Done' : 'Change'}
718
+ </button>
719
+ </span>
720
+ {avatarOpen && <AvatarPicker />}
721
+ </section>
655
722
  )}
656
- {/* Cancel on a line of its own: in the row it would slide under
657
- the pointer, and a double click would cancel what it started. */}
658
- {passkeyMode === 'signin' && (
659
- <span className="settings-popout__inline">
660
- <button
661
- type="button"
662
- className="settings-popout__btn"
663
- onClick={() => cancelPasskey()}
723
+ </>
724
+ )}
725
+
726
+ {/* Content visibility level. Held back until the first-open fetch
727
+ settles, so it lands in the same paint as the tier and name
728
+ rather than a round trip behind them. `content` is fetched
729
+ concurrently with whoami(), so by then it is already here. */}
730
+ {activeTab === 'content' && (
731
+ <section className="settings-popout__row settings-popout__row--stack">
732
+ <span className="settings-popout__label">Content visibility</span>
733
+ {content ? (
734
+ <>
735
+ <div
736
+ className="settings-popout__pill"
737
+ role="radiogroup"
738
+ aria-label="Content visibility level"
664
739
  >
665
- Cancel
666
- </button>
667
- </span>
668
- )}
669
- {keyError && (
670
- <span
671
- id="settings-popout-key-error"
672
- className="settings-popout__error"
673
- role="alert"
674
- >
675
- {keyError}
676
- </span>
740
+ {Array.from({ length: content.maxLevel }, (_, i) => i + 1).map(lvl => (
741
+ <button
742
+ key={lvl}
743
+ type="button"
744
+ role="radio"
745
+ aria-checked={lvl === content.level}
746
+ aria-label={`Level ${lvl}`}
747
+ className={`settings-popout__seg${
748
+ lvl <= content.level ? ' settings-popout__seg--filled' : ''
749
+ }`}
750
+ disabled={levelSaving}
751
+ onClick={() => {
752
+ pickLevel(lvl).catch(reportErr)
753
+ }}
754
+ >
755
+ {lvl}
756
+ </button>
757
+ ))}
758
+ </div>
759
+ <span className="settings-popout__hint">
760
+ Higher levels reveal more mature content across apps. Level 1 is
761
+ safe-for-everyone.
762
+ </span>
763
+ </>
764
+ ) : (
765
+ // Not "Loading…" — the fetch has already settled by the time
766
+ // this section renders at all, so a null here means it failed.
767
+ <span className="settings-popout__hint">Currently unavailable.</span>
677
768
  )}
678
- </form>
679
- </section>
680
- ) : (
681
- <section className="settings-popout__row settings-popout__row--stack">
682
- <span className="settings-popout__inline">
683
- <span className="settings-popout__label settings-popout__label--grow">
684
- Access key
685
- </span>
686
- {/* Signed-in people only: the site refuses to save one for anyone else. */}
687
- {!resolving && userType !== 'public' && passkeysSupported() && (
688
- <button
689
- type="button"
690
- className={`settings-popout__btn${needsPasskey ? ` settings-popout__btn--primary ${PASSKEY_PULSE_CLASS}` : ''}`}
691
- disabled={passkeyMode !== null || passkeySaved}
692
- onClick={() => {
693
- savePasskey().catch(reportErr)
694
- }}
695
- >
696
- {passkeySaved
697
- ? 'Passkey saved'
698
- : passkeyMode === 'save'
699
- ? 'Saving…'
700
- : 'Save passkey'}
701
- </button>
769
+ </section>
770
+ )}
771
+
772
+ {/* Switch key. Signs in inline through the SITE's sign-in client
773
+ (the /auth page's own code path), never a post of ours. A real
774
+ <form> with a username + current-password pair, so password
775
+ managers offer and save the key. */}
776
+ {activeTab === 'account' && (
777
+ <>
778
+ {keyFormOpen ? (
779
+ <section className="settings-popout__row settings-popout__row--stack">
780
+ <form
781
+ className="settings-popout__key-form"
782
+ onSubmit={e => {
783
+ e.preventDefault()
784
+ submitKey().catch(reportErr)
785
+ }}
786
+ >
787
+ <input
788
+ type="text"
789
+ name="username"
790
+ autoComplete="username"
791
+ value="hadoku"
792
+ readOnly
793
+ hidden
794
+ />
795
+ <label className="settings-popout__label" htmlFor="settings-popout-key">
796
+ Access key
797
+ </label>
798
+ <span className="settings-popout__inline">
799
+ <input
800
+ id="settings-popout-key"
801
+ className="settings-popout__input"
802
+ type="password"
803
+ name="password"
804
+ autoComplete="current-password"
805
+ autoCapitalize="none"
806
+ spellCheck={false}
807
+ autoFocus
808
+ value={keyDraft}
809
+ disabled={keyBusy}
810
+ aria-invalid={keyError !== null}
811
+ aria-describedby={keyError ? 'settings-popout-key-error' : undefined}
812
+ onChange={e => setKeyDraft(e.target.value)}
813
+ />
814
+ <button
815
+ type="submit"
816
+ className="settings-popout__btn settings-popout__btn--primary"
817
+ disabled={keyBusy}
818
+ >
819
+ {keyBusy ? 'Signing in…' : 'Sign in'}
820
+ </button>
821
+ </span>
822
+ {passkeysSupported() && (
823
+ <span className="settings-popout__inline">
824
+ <button
825
+ type="button"
826
+ className="settings-popout__btn"
827
+ disabled={keyBusy}
828
+ onClick={() => {
829
+ signInWithPasskey().catch(reportErr)
830
+ }}
831
+ >
832
+ {passkeyMode === 'signin' ? 'Waiting for passkey…' : 'Use a passkey'}
833
+ </button>
834
+ </span>
835
+ )}
836
+ {/* Cancel on a line of its own: in the row it would slide under
837
+ the pointer, and a double click would cancel what it started. */}
838
+ {passkeyMode === 'signin' && (
839
+ <span className="settings-popout__inline">
840
+ <button
841
+ type="button"
842
+ className="settings-popout__btn"
843
+ onClick={() => cancelPasskey()}
844
+ >
845
+ Cancel
846
+ </button>
847
+ </span>
848
+ )}
849
+ {keyError && (
850
+ <span
851
+ id="settings-popout-key-error"
852
+ className="settings-popout__error"
853
+ role="alert"
854
+ >
855
+ {keyError}
856
+ </span>
857
+ )}
858
+ </form>
859
+ </section>
860
+ ) : (
861
+ <section className="settings-popout__row settings-popout__row--stack">
862
+ <span className="settings-popout__inline">
863
+ <span className="settings-popout__label settings-popout__label--grow">
864
+ Access key
865
+ </span>
866
+ {/* Signed-in people only: the site refuses to save one for anyone else. */}
867
+ {!resolving && userType !== 'public' && passkeysSupported() && (
868
+ <button
869
+ type="button"
870
+ className={`settings-popout__btn${needsPasskey ? ` settings-popout__btn--primary ${PASSKEY_PULSE_CLASS}` : ''}`}
871
+ disabled={passkeyMode !== null || passkeySaved}
872
+ onClick={() => {
873
+ savePasskey().catch(reportErr)
874
+ }}
875
+ >
876
+ {passkeySaved
877
+ ? 'Passkey saved'
878
+ : passkeyMode === 'save'
879
+ ? 'Saving…'
880
+ : 'Save passkey'}
881
+ </button>
882
+ )}
883
+ <button
884
+ type="button"
885
+ className="settings-popout__btn"
886
+ disabled={passkeyMode === 'save'}
887
+ onClick={openKeyForm}
888
+ >
889
+ Switch key
890
+ </button>
891
+ </span>
892
+ {/* Its own line, so nothing moves under a double click. */}
893
+ {passkeyMode === 'save' && (
894
+ <span className="settings-popout__inline">
895
+ <button
896
+ type="button"
897
+ className="settings-popout__btn"
898
+ onClick={() => cancelPasskey()}
899
+ >
900
+ Cancel
901
+ </button>
902
+ </span>
903
+ )}
904
+ {passkeyError && (
905
+ <span className="settings-popout__error" role="alert">
906
+ {passkeyError}
907
+ </span>
908
+ )}
909
+ </section>
702
910
  )}
703
- <button
704
- type="button"
705
- className="settings-popout__btn"
706
- disabled={passkeyMode === 'save'}
707
- onClick={openKeyForm}
708
- >
709
- Switch key
710
- </button>
711
- </span>
712
- {/* Its own line, so nothing moves under a double click. */}
713
- {passkeyMode === 'save' && (
714
- <span className="settings-popout__inline">
715
- <button
716
- type="button"
717
- className="settings-popout__btn"
718
- onClick={() => cancelPasskey()}
719
- >
720
- Cancel
721
- </button>
722
- </span>
723
- )}
724
- {passkeyError && (
725
- <span className="settings-popout__error" role="alert">
726
- {passkeyError}
727
- </span>
728
- )}
729
- </section>
730
- )}
731
-
732
- {/* A self-made account (member) can delete itself. The confirm is a
911
+
912
+ {/* A self-made account (member) can delete itself. The confirm is a
733
913
  separate button on its own line, so a double click on Delete
734
914
  never confirms. */}
735
- {!resolving && userType === 'member' && (
736
- <section className="settings-popout__row settings-popout__row--stack">
737
- <span className="settings-popout__inline">
738
- <span className="settings-popout__label settings-popout__label--grow">Account</span>
739
- {deleteStage === 'idle' ? (
740
- <button
741
- type="button"
742
- className="settings-popout__btn"
743
- onClick={() => {
744
- setDeleteError(null)
745
- setDeleteStage('confirm')
746
- }}
747
- >
748
- Delete account
749
- </button>
750
- ) : (
751
- <button
752
- type="button"
753
- className="settings-popout__btn"
754
- disabled={deleteStage === 'busy'}
755
- onClick={() => setDeleteStage('idle')}
756
- >
757
- Keep
758
- </button>
759
- )}
760
- </span>
761
- {deleteStage !== 'idle' && (
762
- <span className="settings-popout__inline">
763
- <button
764
- type="button"
765
- className="settings-popout__btn settings-popout__btn--primary"
766
- disabled={deleteStage === 'busy'}
767
- onClick={() => {
768
- setDeleteStage('busy')
769
- deleteOwnAccount()
770
- .then(deleted => {
771
- if (!deleted) {
772
- setDeleteError('Account not deleted')
915
+ {!resolving && userType === 'member' && (
916
+ <section className="settings-popout__row settings-popout__row--stack">
917
+ <span className="settings-popout__inline">
918
+ <span className="settings-popout__label settings-popout__label--grow">
919
+ Account
920
+ </span>
921
+ {deleteStage === 'idle' ? (
922
+ <button
923
+ type="button"
924
+ className="settings-popout__btn"
925
+ onClick={() => {
926
+ setDeleteError(null)
773
927
  setDeleteStage('confirm')
774
- return
775
- }
776
- localStorage.removeItem('hadoku_session_id')
777
- localStorage.removeItem('hadoku_user_type')
778
- window.location.assign('/')
779
- })
780
- .catch(reportErr)
781
- }}
782
- >
783
- {deleteStage === 'busy' ? 'Deleting…' : 'Delete for good'}
784
- </button>
785
- </span>
786
- )}
787
- {deleteError && (
788
- <span className="settings-popout__error" role="alert">
789
- {deleteError}
790
- </span>
791
- )}
792
- </section>
793
- )}
794
-
795
- {/* App-specific settings (optional) — same popout, one gear. */}
796
- {children && (
797
- <section className="settings-popout__row settings-popout__row--stack settings-popout__app">
798
- {children}
799
- </section>
800
- )}
928
+ }}
929
+ >
930
+ Delete account
931
+ </button>
932
+ ) : (
933
+ <button
934
+ type="button"
935
+ className="settings-popout__btn"
936
+ disabled={deleteStage === 'busy'}
937
+ onClick={() => setDeleteStage('idle')}
938
+ >
939
+ Keep
940
+ </button>
941
+ )}
942
+ </span>
943
+ {deleteStage !== 'idle' && (
944
+ <span className="settings-popout__inline">
945
+ <button
946
+ type="button"
947
+ className="settings-popout__btn settings-popout__btn--primary"
948
+ disabled={deleteStage === 'busy'}
949
+ onClick={() => {
950
+ setDeleteStage('busy')
951
+ deleteOwnAccount()
952
+ .then(deleted => {
953
+ if (!deleted) {
954
+ setDeleteError('Account not deleted')
955
+ setDeleteStage('confirm')
956
+ return
957
+ }
958
+ localStorage.removeItem('hadoku_session_id')
959
+ localStorage.removeItem('hadoku_user_type')
960
+ window.location.assign('/')
961
+ })
962
+ .catch(reportErr)
963
+ }}
964
+ >
965
+ {deleteStage === 'busy' ? 'Deleting…' : 'Delete for good'}
966
+ </button>
967
+ </span>
968
+ )}
969
+ {deleteError && (
970
+ <span className="settings-popout__error" role="alert">
971
+ {deleteError}
972
+ </span>
973
+ )}
974
+ </section>
975
+ )}
976
+ </>
977
+ )}
978
+
979
+ {/* App-specific settings (optional) — same popout, one gear. */}
980
+ {activeTab === 'app:' && (
981
+ <section className="settings-popout__row settings-popout__row--stack">
982
+ {children}
983
+ </section>
984
+ )}
985
+ {appTab && (
986
+ <section className="settings-popout__row settings-popout__row--stack">
987
+ {appTab.render()}
988
+ </section>
989
+ )}
990
+ </div>
801
991
  </div>
802
992
  )}
803
993
  </div>