@fuzdev/fuz_app 0.60.0 → 0.62.0

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 (63) hide show
  1. package/dist/actions/CLAUDE.md +28 -22
  2. package/dist/auth/CLAUDE.md +4 -4
  3. package/dist/server/app_server.d.ts +54 -6
  4. package/dist/server/app_server.d.ts.map +1 -1
  5. package/dist/server/app_server.js +32 -4
  6. package/dist/testing/CLAUDE.md +8 -8
  7. package/dist/ui/AccountSessions.svelte +21 -6
  8. package/dist/ui/AccountSessions.svelte.d.ts.map +1 -1
  9. package/dist/ui/AdminAccounts.svelte +32 -25
  10. package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
  11. package/dist/ui/AdminAuditLog.svelte +3 -3
  12. package/dist/ui/AdminInvites.svelte +20 -15
  13. package/dist/ui/AdminOverview.svelte +19 -21
  14. package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
  15. package/dist/ui/AdminRoleGrantHistory.svelte +3 -3
  16. package/dist/ui/AdminSessions.svelte +19 -21
  17. package/dist/ui/AdminSessions.svelte.d.ts.map +1 -1
  18. package/dist/ui/AdminSettings.svelte +1 -3
  19. package/dist/ui/AdminSettings.svelte.d.ts.map +1 -1
  20. package/dist/ui/CLAUDE.md +123 -69
  21. package/dist/ui/ConfirmButton.svelte +82 -24
  22. package/dist/ui/ConfirmButton.svelte.d.ts +8 -34
  23. package/dist/ui/ConfirmButton.svelte.d.ts.map +1 -1
  24. package/dist/ui/OpenSignupToggle.svelte +6 -4
  25. package/dist/ui/OpenSignupToggle.svelte.d.ts.map +1 -1
  26. package/dist/ui/RoleGrantOfferForm.svelte +4 -4
  27. package/dist/ui/RoleGrantOfferHistory.svelte +3 -3
  28. package/dist/ui/RoleGrantOfferInbox.svelte +10 -6
  29. package/dist/ui/RoleGrantOfferInbox.svelte.d.ts.map +1 -1
  30. package/dist/ui/account_sessions_state.svelte.d.ts +17 -7
  31. package/dist/ui/account_sessions_state.svelte.d.ts.map +1 -1
  32. package/dist/ui/account_sessions_state.svelte.js +32 -33
  33. package/dist/ui/admin_accounts_state.svelte.d.ts +48 -17
  34. package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
  35. package/dist/ui/admin_accounts_state.svelte.js +58 -76
  36. package/dist/ui/admin_invites_state.svelte.d.ts +14 -7
  37. package/dist/ui/admin_invites_state.svelte.d.ts.map +1 -1
  38. package/dist/ui/admin_invites_state.svelte.js +32 -48
  39. package/dist/ui/admin_sessions_state.svelte.d.ts +15 -8
  40. package/dist/ui/admin_sessions_state.svelte.d.ts.map +1 -1
  41. package/dist/ui/admin_sessions_state.svelte.js +30 -47
  42. package/dist/ui/app_settings_state.svelte.d.ts +8 -3
  43. package/dist/ui/app_settings_state.svelte.d.ts.map +1 -1
  44. package/dist/ui/app_settings_state.svelte.js +19 -27
  45. package/dist/ui/async_slot.svelte.d.ts +173 -0
  46. package/dist/ui/async_slot.svelte.d.ts.map +1 -0
  47. package/dist/ui/async_slot.svelte.js +241 -0
  48. package/dist/ui/audit_log_state.svelte.d.ts +8 -2
  49. package/dist/ui/audit_log_state.svelte.d.ts.map +1 -1
  50. package/dist/ui/audit_log_state.svelte.js +19 -18
  51. package/dist/ui/keyed_async_slot.svelte.d.ts +139 -0
  52. package/dist/ui/keyed_async_slot.svelte.d.ts.map +1 -0
  53. package/dist/ui/keyed_async_slot.svelte.js +177 -0
  54. package/dist/ui/role_grant_offers_state.svelte.d.ts +39 -7
  55. package/dist/ui/role_grant_offers_state.svelte.d.ts.map +1 -1
  56. package/dist/ui/role_grant_offers_state.svelte.js +34 -15
  57. package/dist/ui/table_state.svelte.d.ts +10 -7
  58. package/dist/ui/table_state.svelte.d.ts.map +1 -1
  59. package/dist/ui/table_state.svelte.js +11 -8
  60. package/package.json +1 -1
  61. package/dist/ui/loadable.svelte.d.ts +0 -60
  62. package/dist/ui/loadable.svelte.d.ts.map +0 -1
  63. package/dist/ui/loadable.svelte.js +0 -80
@@ -89,10 +89,10 @@
89
89
  <h3>accounts</h3>
90
90
  <a href={resolve('/admin/accounts' as any)} class="text_50 font_size_sm">view all &rarr;</a>
91
91
  </div>
92
- {#if accounts.loading}
92
+ {#if accounts.list.loading}
93
93
  <p class="text_50">loading...</p>
94
- {:else if accounts.error}
95
- <p class="color_c_50">{accounts.error}</p>
94
+ {:else if accounts.list.error}
95
+ <p class="color_c_50">{accounts.list.error}</p>
96
96
  {:else}
97
97
  <div class="baseline-row gap_xs">
98
98
  <strong class="font_size_lg">{accounts.account_count}</strong>
@@ -131,10 +131,10 @@
131
131
  <h3>sessions</h3>
132
132
  <a href={resolve('/admin/sessions' as any)} class="text_50 font_size_sm">view all &rarr;</a>
133
133
  </div>
134
- {#if sessions.loading}
134
+ {#if sessions.list.loading}
135
135
  <p class="text_50">loading...</p>
136
- {:else if sessions.error}
137
- <p class="color_c_50">{sessions.error}</p>
136
+ {:else if sessions.list.error}
137
+ <p class="color_c_50">{sessions.list.error}</p>
138
138
  {:else}
139
139
  <div class="baseline-row gap_xs">
140
140
  <strong class="font_size_lg">{sessions.active_count}</strong>
@@ -161,10 +161,10 @@
161
161
  <h3>invites</h3>
162
162
  <a href={resolve('/admin/invites' as any)} class="text_50 font_size_sm">view all &rarr;</a>
163
163
  </div>
164
- {#if invites.loading}
164
+ {#if invites.list.loading}
165
165
  <p class="text_50">loading...</p>
166
- {:else if invites.error}
167
- <p class="color_c_50">{invites.error}</p>
166
+ {:else if invites.list.error}
167
+ <p class="color_c_50">{invites.list.error}</p>
168
168
  {:else}
169
169
  <div class="baseline-row gap_sm">
170
170
  <span class="text_50">public signup</span>
@@ -206,10 +206,10 @@
206
206
  <h3>recent activity</h3>
207
207
  <a href={resolve('/admin/audit-log' as any)} class="text_50 font_size_sm">view all &rarr;</a>
208
208
  </div>
209
- {#if audit_log.loading}
209
+ {#if audit_log.list.loading}
210
210
  <p class="text_50">loading...</p>
211
- {:else if audit_log.error}
212
- <p class="color_c_50">{audit_log.error}</p>
211
+ {:else if audit_log.list.error}
212
+ <p class="color_c_50">{audit_log.list.error}</p>
213
213
  {:else if recent_events.length === 0}
214
214
  <p class="text_50">no events</p>
215
215
  {:else}
@@ -234,10 +234,10 @@
234
234
  <h3>security</h3>
235
235
  <a href={resolve('/admin/audit-log' as any)} class="text_50 font_size_sm">audit log &rarr;</a>
236
236
  </div>
237
- {#if audit_log.loading}
237
+ {#if audit_log.list.loading}
238
238
  <p class="text_50">loading...</p>
239
- {:else if audit_log.error}
240
- <p class="color_c_50">{audit_log.error}</p>
239
+ {:else if audit_log.list.error}
240
+ <p class="color_c_50">{audit_log.list.error}</p>
241
241
  {:else}
242
242
  <div class="baseline-row gap_xs">
243
243
  <strong class="font_size_lg" class:color_c_50={failed_logins.length > 0}>
@@ -271,10 +271,10 @@
271
271
  <div class="panel-header">
272
272
  <h3>system</h3>
273
273
  </div>
274
- {#if app_settings.loading}
274
+ {#if app_settings.list.loading}
275
275
  <p class="text_50">loading...</p>
276
- {:else if app_settings.error}
277
- <p class="color_c_50">{app_settings.error}</p>
276
+ {:else if app_settings.list.error}
277
+ <p class="color_c_50">{app_settings.list.error}</p>
278
278
  {:else}
279
279
  <div class="baseline-row gap_sm">
280
280
  <span class="text_50">public signup</span>
@@ -308,10 +308,8 @@
308
308
  await auth_state.logout();
309
309
  }}
310
310
  title="log out"
311
+ label="log out"
311
312
  >
312
- {#snippet children(_popover, _confirm)}
313
- log out
314
- {/snippet}
315
313
  {#snippet popover_button_content()}
316
314
  <span class="p_md"> log out </span>
317
315
  {/snippet}
@@ -1 +1 @@
1
- {"version":3,"file":"AdminOverview.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/AdminOverview.svelte"],"names":[],"mappings":"AA4UA,QAAA,MAAM,aAAa,2DAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"AdminOverview.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/AdminOverview.svelte"],"names":[],"mappings":"AA2UA,QAAA,MAAM,aAAa,2DAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
@@ -40,10 +40,10 @@
40
40
  <section>
41
41
  <h1>role_grant history</h1>
42
42
 
43
- {#if audit_log.loading}
43
+ {#if audit_log.role_grant_history.loading}
44
44
  <p class="text_50">loading role_grant history...</p>
45
- {:else if audit_log.error}
46
- <p class="color_c_50">{audit_log.error}</p>
45
+ {:else if audit_log.role_grant_history.error}
46
+ <p class="color_c_50">{audit_log.role_grant_history.error}</p>
47
47
  {:else}
48
48
  <Datatable {columns} rows={audit_log.role_grant_history_events} height="400px" row_key="id">
49
49
  {#snippet cell(column, row)}
@@ -40,10 +40,10 @@
40
40
  </p>
41
41
  {/if}
42
42
 
43
- {#if admin_sessions.loading}
43
+ {#if admin_sessions.list.loading}
44
44
  <p class="text_50">loading sessions...</p>
45
- {:else if admin_sessions.error}
46
- <p class="color_c_50">{admin_sessions.error}</p>
45
+ {:else if admin_sessions.list.error}
46
+ <p class="color_c_50">{admin_sessions.list.error}</p>
47
47
  {:else}
48
48
  <Datatable {columns} rows={admin_sessions.sessions} height="400px">
49
49
  {#snippet cell(column, row)}
@@ -63,30 +63,28 @@
63
63
  </span>
64
64
  {:else if column.key === 'account_id'}
65
65
  {#if admin_sessions.has_rpc}
66
+ {@const revoke_sessions_error = admin_sessions.revoke_sessions.error(row.account_id)}
67
+ {@const revoke_tokens_error = admin_sessions.revoke_tokens.error(row.account_id)}
66
68
  <ConfirmButton
67
- onconfirm={() => admin_sessions.revoke_all_for_account(row.account_id)}
69
+ onconfirm={() => admin_sessions.submit_revoke_sessions(row.account_id)}
68
70
  title="revoke all sessions for {row.username}"
69
71
  class="sm"
70
- disabled={admin_sessions.revoking_account_ids.has(row.account_id)}
71
- >
72
- {#snippet children(_popover, _confirm)}
73
- {admin_sessions.revoking_account_ids.has(row.account_id)
74
- ? 'revoking…'
75
- : 'revoke sessions'}
76
- {/snippet}
77
- </ConfirmButton>
72
+ label="revoke sessions"
73
+ pending={admin_sessions.revoke_sessions.loading(row.account_id)}
74
+ />
75
+ {#if revoke_sessions_error}
76
+ <span class="color_c_50 font_size_sm">{revoke_sessions_error}</span>
77
+ {/if}
78
78
  <ConfirmButton
79
- onconfirm={() => admin_sessions.revoke_all_tokens_for_account(row.account_id)}
79
+ onconfirm={() => admin_sessions.submit_revoke_tokens(row.account_id)}
80
80
  title="revoke all tokens for {row.username}"
81
81
  class="sm"
82
- disabled={admin_sessions.revoking_token_account_ids.has(row.account_id)}
83
- >
84
- {#snippet children(_popover, _confirm)}
85
- {admin_sessions.revoking_token_account_ids.has(row.account_id)
86
- ? 'revoking…'
87
- : 'revoke tokens'}
88
- {/snippet}
89
- </ConfirmButton>
82
+ label="revoke tokens"
83
+ pending={admin_sessions.revoke_tokens.loading(row.account_id)}
84
+ />
85
+ {#if revoke_tokens_error}
86
+ <span class="color_c_50 font_size_sm">{revoke_tokens_error}</span>
87
+ {/if}
90
88
  {/if}
91
89
  {:else if column.format}
92
90
  {column.format(row[column.key], row)}
@@ -1 +1 @@
1
- {"version":3,"file":"AdminSessions.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/AdminSessions.svelte"],"names":[],"mappings":"AAwGA,UAAU,kCAAkC,CAAC,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,OAAO,GAAG,EAAE,EAAE,QAAQ,GAAG,MAAM;IACpM,KAAK,OAAO,EAAE,OAAO,QAAQ,EAAE,2BAA2B,CAAC,KAAK,CAAC,GAAG,OAAO,QAAQ,EAAE,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,GAAG;QAAE,UAAU,CAAC,EAAE,QAAQ,CAAA;KAAE,GAAG,OAAO,CAAC;IACjK,CAAC,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,KAAK,CAAA;KAAC,GAAG,OAAO,GAAG;QAAE,IAAI,CAAC,EAAE,GAAG,CAAC;QAAC,GAAG,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC;IACtG,YAAY,CAAC,EAAE,QAAQ,CAAC;CAC3B;AAKD,QAAA,MAAM,aAAa;;kBAA+E,CAAC;AACjF,KAAK,aAAa,GAAG,YAAY,CAAC,OAAO,aAAa,CAAC,CAAC;AAC1D,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"AdminSessions.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/AdminSessions.svelte"],"names":[],"mappings":"AAoGA,UAAU,kCAAkC,CAAC,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,OAAO,GAAG,EAAE,EAAE,QAAQ,GAAG,MAAM;IACpM,KAAK,OAAO,EAAE,OAAO,QAAQ,EAAE,2BAA2B,CAAC,KAAK,CAAC,GAAG,OAAO,QAAQ,EAAE,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,GAAG;QAAE,UAAU,CAAC,EAAE,QAAQ,CAAA;KAAE,GAAG,OAAO,CAAC;IACjK,CAAC,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,KAAK,CAAA;KAAC,GAAG,OAAO,GAAG;QAAE,IAAI,CAAC,EAAE,GAAG,CAAC;QAAC,GAAG,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC;IACtG,YAAY,CAAC,EAAE,QAAQ,CAAC;CAC3B;AAKD,QAAA,MAAM,aAAa;;kBAA+E,CAAC;AACjF,KAAK,aAAa,GAAG,YAAY,CAAC,OAAO,aAAa,CAAC,CAAC;AAC1D,eAAe,aAAa,CAAC"}
@@ -30,10 +30,8 @@
30
30
  await auth_state.logout();
31
31
  }}
32
32
  title="log out"
33
+ label="log out"
33
34
  >
34
- {#snippet children(_popover, _confirm)}
35
- log out
36
- {/snippet}
37
35
  {#snippet popover_button_content()}
38
36
  <span class="p_md"> log out </span>
39
37
  {/snippet}
@@ -1 +1 @@
1
- {"version":3,"file":"AdminSettings.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/AdminSettings.svelte"],"names":[],"mappings":"AA+CA,UAAU,kCAAkC,CAAC,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,OAAO,GAAG,EAAE,EAAE,QAAQ,GAAG,MAAM;IACpM,KAAK,OAAO,EAAE,OAAO,QAAQ,EAAE,2BAA2B,CAAC,KAAK,CAAC,GAAG,OAAO,QAAQ,EAAE,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,GAAG;QAAE,UAAU,CAAC,EAAE,QAAQ,CAAA;KAAE,GAAG,OAAO,CAAC;IACjK,CAAC,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,KAAK,CAAA;KAAC,GAAG,OAAO,GAAG;QAAE,IAAI,CAAC,EAAE,GAAG,CAAC;QAAC,GAAG,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC;IACtG,YAAY,CAAC,EAAE,QAAQ,CAAC;CAC3B;AAKD,QAAA,MAAM,aAAa;;kBAA+E,CAAC;AACjF,KAAK,aAAa,GAAG,YAAY,CAAC,OAAO,aAAa,CAAC,CAAC;AAC1D,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"AdminSettings.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/AdminSettings.svelte"],"names":[],"mappings":"AA8CA,UAAU,kCAAkC,CAAC,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,OAAO,GAAG,EAAE,EAAE,QAAQ,GAAG,MAAM;IACpM,KAAK,OAAO,EAAE,OAAO,QAAQ,EAAE,2BAA2B,CAAC,KAAK,CAAC,GAAG,OAAO,QAAQ,EAAE,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,GAAG;QAAE,UAAU,CAAC,EAAE,QAAQ,CAAA;KAAE,GAAG,OAAO,CAAC;IACjK,CAAC,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,KAAK,CAAA;KAAC,GAAG,OAAO,GAAG;QAAE,IAAI,CAAC,EAAE,GAAG,CAAC;QAAC,GAAG,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC;IACtG,YAAY,CAAC,EAAE,QAAQ,CAAC;CAC3B;AAKD,QAAA,MAAM,aAAa;;kBAA+E,CAAC;AACjF,KAAK,aAAa,GAAG,YAAY,CAAC,OAAO,aAAa,CAAC,CAAC;AAC1D,eAAe,aAAa,CAAC"}
package/dist/ui/CLAUDE.md CHANGED
@@ -2,8 +2,12 @@
2
2
 
3
3
  Frontend subsystem — Svelte 5 components, reactive state classes, and DOM
4
4
  utilities. Cookie-based SPA auth; prerendered static HTML served by Hono
5
- (no SvelteKit SSR for sessions). State classes extend `Loadable` and
6
- hold `$state` fields exclusively via runes. Shared dependencies flow
5
+ (no SvelteKit SSR for sessions). State classes hold one or more `AsyncSlot`s
6
+ via composition (one per distinct async operation — e.g. `list` + `create` +
7
+ `revoke`); per-row write ops use `KeyedAsyncSlot<K, T = void, E = string>`
8
+ (supersedes the old `AsyncSlot` + `SvelteSet<id>` pair) so concurrent rows
9
+ don't abort each other and failures surface per-row via `slot.error(key)`.
10
+ Payload lives as `$state.raw` fields on the class. Shared dependencies flow
7
11
  through Svelte context, never through props — RPC adapters in particular
8
12
  are provisioned once at the admin shell and read by every `Admin*.svelte`.
9
13
 
@@ -128,14 +132,18 @@ destructive actions.
128
132
  - `AdminAccounts.svelte` — accounts + role_grants + pending offers.
129
133
  Consumes `admin_accounts_rpc_context`. Per-row actions: grant (+role
130
134
  chip with `ConfirmButton`), revoke (`actor_id` + `role_grant_id`),
131
- retract pending offer. Tracks `granting_keys` / `revoking_ids` /
132
- `retracting_ids` for per-action spinners.
135
+ retract pending offer. Reads per-row spinner + error state via
136
+ `state.grant.loading(key)` / `state.revoke.loading(role_grant_id)` /
137
+ `state.retract.loading(offer_id)` and their `.error(key)` siblings —
138
+ per-row error displays inline next to the failing button (no
139
+ top-level rollup).
133
140
  - `AdminAuditLog.svelte` — audit event stream. Consumes
134
141
  `audit_log_rpc_context`. Filter by `event_type`, manual refresh,
135
142
  toggle SSE streaming (via `EventSource` — not RPC).
136
143
  - `AdminInvites.svelte` — invite CRUD + embeds `OpenSignupToggle`.
137
- Consumes `admin_invites_rpc_context`. Tracks `creating` +
138
- `deleting_ids`.
144
+ Consumes `admin_invites_rpc_context`. Per-row delete reads
145
+ `state.remove.loading(invite_id)` / `state.remove.error(invite_id)`
146
+ with inline per-row error display.
139
147
  - `AdminOverview.svelte` — dashboard panels (accounts / sessions /
140
148
  invites / recent activity / security / system). Consumes all four
141
149
  RPC contexts plus `auth_state_context`; fetches in parallel on mount.
@@ -185,27 +193,56 @@ destructive actions.
185
193
  `format_scope?`, `format_role?`. Consumes
186
194
  `role_grant_offers_state_context`; caller seeds via
187
195
  `RoleGrantOffersState.fetch_history()`.
188
- - `role_grant_offers_state.svelte.ts` — `RoleGrantOffersState` (extends
189
- `Loadable`) + `role_grant_offers_state_context`. Options:
190
- `rpc: RoleGrantOffersRpc`, `account_id: () => string | null`,
191
- `actor_id: () => string | null`. The narrow `RoleGrantOffersRpc`
192
- interface has six methods: `list`, `history`, `create`, `accept`,
193
- `decline`, `retract`. `$state.raw` Map keyed by offer id;
194
- `$derived.by` views: `incoming` (recipient-side pending, soonest-
195
- expiry first), `outgoing` (grantor-side pending, newest-created
196
- first), `history` (all known, newest-created first). Reducer
197
- `apply_notification` handles the six role-grant-offer notification
198
- methods; `role_grant_revoke` is deliberately ignored here (auth/role_grants
199
- concern). `reset()` clears the Map.
196
+ - `role_grant_offers_state.svelte.ts` — `RoleGrantOffersState` +
197
+ `role_grant_offers_state_context`. Options: `rpc: RoleGrantOffersRpc`,
198
+ `account_id: () => string | null`, `actor_id: () => string | null`.
199
+ The narrow `RoleGrantOffersRpc` interface has six methods: `list`,
200
+ `history`, `create`, `accept`, `decline`, `retract`. Holds six
201
+ `AsyncSlot`s five `AsyncSlot<void>` for status/error tracking
202
+ (`list` / `list_history` / `accept` / `decline` / `retract`) plus
203
+ one `AsyncSlot<RoleGrantOfferJson>` (`create`) that owns the
204
+ created offer so `submit_create` returns it via the slot's
205
+ supersession-safe `data` path. The `$state.raw` Map cache keyed by
206
+ offer id stays on the class (multiple ops + WS notifications merge
207
+ into it). Methods use the `submit_*` prefix to avoid slot-name
208
+ collisions (`submit_create` / `submit_accept` / `submit_decline` /
209
+ `submit_retract`); the fetch slot is named `list_history` so the
210
+ derived view stays natural as `history`. `$derived.by` views:
211
+ `incoming` (recipient-side pending, soonest-expiry first),
212
+ `outgoing` (grantor-side pending, newest-created first), `history`
213
+ (all known, newest-created first). Reducer `apply_notification`
214
+ handles the six role-grant-offer notification methods;
215
+ `role_grant_revoke` is deliberately ignored here (auth/role_grants
216
+ concern). `reset()` clears every slot + the Map.
200
217
 
201
218
  ## State primitives
202
219
 
203
- - `loadable.svelte.ts` — `Loadable<TError = string>` base class.
204
- `loading`, `error`, `error_data` (raw caught value for programmatic
205
- inspection). Protected `run(fn, map_error?)` wraps async operations
206
- with loading + error handling; subclasses add `$state` fields and
207
- call `run`. `reset()` clears state; subclasses override to clear
208
- domain data.
220
+ - `async_slot.svelte.ts` — `AsyncSlot<T = void, E = string>`. Composable
221
+ reactive container for one async operation. Surface: explicit
222
+ four-value `status` (`'initial' | 'pending' | 'success' | 'failure'`),
223
+ derived `initial` / `loading` / `succeeded` / `failed`, supersession
224
+ via internal `AbortController` (a second `run()` aborts the first
225
+ and silently drops its commit), `AbortSignal` threaded to the
226
+ callback + external-signal hookup via `RunOptions`, per-slot
227
+ `map_error` set once in the constructor, opt-in
228
+ `preserve_error_on_retry`, public `run()` / `abort()` / `set()` /
229
+ `reset()`. Slots are HELD by state classes via composition (one per
230
+ distinct async op), not subclassed. Payload typically lives on the
231
+ state class as `$state.raw` fields; `slot.data` is reserved for
232
+ cases where the slot owns the result.
233
+ - `keyed_async_slot.svelte.ts` — `KeyedAsyncSlot<K, T = void, E = string>`.
234
+ Keyed sibling of `AsyncSlot` — lazily creates a child slot per key
235
+ in a `SvelteMap`, propagating `map_error` / `preserve_error_on_retry`
236
+ to each child. Replaces the `AsyncSlot` + `SvelteSet<id>` pair: each
237
+ key has its own `AbortController`, so a `run(b, ...)` does NOT abort
238
+ an in-flight `run(a, ...)`, and `error(key)` surfaces per-row.
239
+ Reactive sugar: `loading(key)`, `error(key)`, `failed(key)`,
240
+ `succeeded(key)`, `has(key)`, `size`, plus `get(key)` for full slot
241
+ access. Resolved entries persist (no auto-cleanup) so components can
242
+ render per-row error indicators after the run completes; call
243
+ `delete(key)` to dismiss an entry or `reset()` to wipe everything.
244
+ `abort(key)` / `abort_all()` cancel without removing entries.
245
+ `entries()` / `keys()` / `values()` iterate for cross-key views.
209
246
  - `auth_state.svelte.ts` — `AuthState`, `auth_state_context`.
210
247
  Fields: `verifying`, `verified`, `verify_error`, `account`, `actor`
211
248
  (the caller's own `ActorSummaryJson` — surfaced directly so consumers
@@ -214,11 +251,14 @@ destructive actions.
214
251
  `needs_bootstrap`. Methods: `check_session()`
215
252
  (GET `/api/account/status`), `login`, `bootstrap`, `signup`,
216
253
  `logout`. Handles 401/403/409/429 translations inline.
217
- - `table_state.svelte.ts` — `TableState` extends `Loadable`.
218
- Paginated DB browser state: `table_name`, `columns`, `rows`,
219
- `total`, `offset`, `limit` (capped by `TABLE_LIMIT_MAX = 1000`),
220
- `primary_key`. Derived `showing_start`/`showing_end`/`has_prev`/
221
- `has_next`. Methods: `fetch`, `go_prev`/`go_next`, `delete_row`.
254
+ - `table_state.svelte.ts` — `TableState`. Paginated DB browser state.
255
+ Holds one `AsyncSlot` (`list`) + payload fields (`table_name`,
256
+ `columns`, `rows`, `total`, `offset`, `limit` capped by
257
+ `TABLE_LIMIT_MAX = 1000`, `primary_key`). Derived
258
+ `showing_start`/`showing_end`/`has_prev`/`has_next`. Methods:
259
+ `fetch`, `go_prev`/`go_next`, `delete_row`. `delete_row` uses
260
+ plain try/catch + scalar `deleting` / `delete_error` fields (no
261
+ slot — error must survive past `list.run()` retries).
222
262
  - `form_state.svelte.ts` — `FormState`. Enter-advance between
223
263
  focusable elements via `keydown`; per-field `touched` set via
224
264
  delegated `focusout`; form-level `attempted` set on submit attempt.
@@ -231,47 +271,61 @@ destructive actions.
231
271
 
232
272
  ## Per-domain state modules
233
273
 
234
- - `account_sessions_state.svelte.ts` `AccountSessionsState` extends
235
- `Loadable` + `account_sessions_rpc_context` + narrow
236
- `AccountSessionsRpc` (`list`, `revoke`, `revoke_all`). Wraps the
237
- `account_session_list` / `account_session_revoke` /
238
- `account_session_revoke_all` RPC actions. Derived `active_count`.
239
- - `audit_log_state.svelte.ts` — `AuditLogState` extends `Loadable`
240
- - `audit_log_rpc_context` + narrow `AuditLogRpc` (`list` +
241
- `role_grant_history`). Fields: `events`, `role_grant_history_events`,
242
- `connected`. Internal `#last_seq` for SSE gap fill on reconnect.
243
- Methods: `fetch(options?)` (RPC), `fetch_role_grant_history`,
244
- `subscribe()` (opens `EventSource` at `#stream_url`, default
245
- `/api/admin/audit/stream`; prepends new events; refills gap
246
- via `since_seq`), `disconnect()`. SSE stays on `EventSource`
247
- streaming is not an RPC concern.
248
- - `admin_accounts_state.svelte.ts` `AdminAccountsState` extends
249
- `Loadable` + `admin_accounts_rpc_context` + narrow
250
- `AdminAccountsRpc` (six methods: `list_accounts`, `create_role_grant`,
274
+ All state classes hold per-op `AsyncSlot`s for the fetch + singular
275
+ write verbs, and `KeyedAsyncSlot`s for per-row write verbs (the
276
+ `SvelteSet<id>` pattern is retired per-row tracking lives on the
277
+ keyed slot's `loading(key)` / `error(key)` accessors). Method names use
278
+ the `submit_*` prefix where the verb collides with a slot name.
279
+
280
+ - `account_sessions_state.svelte.ts` `AccountSessionsState` +
281
+ `account_sessions_rpc_context` + narrow `AccountSessionsRpc`
282
+ (`list`, `revoke`, `revoke_all`). Slots: `list` (AsyncSlot),
283
+ `revoke` (`KeyedAsyncSlot<string, void>` keyed by `session_id` for
284
+ per-row independence), `revoke_all` (AsyncSlot). Methods: `fetch`,
285
+ `submit_revoke(id)`, `submit_revoke_all`. Derived `active_count`.
286
+ - `audit_log_state.svelte.ts` `AuditLogState` +
287
+ `audit_log_rpc_context` + narrow `AuditLogRpc` (`list` +
288
+ `role_grant_history`). Slots: `list`, `role_grant_history`. Fields:
289
+ `events`, `role_grant_history_events`, `connected`. Internal
290
+ `#last_seq` for SSE gap fill on reconnect. Methods:
291
+ `fetch(options?)` (RPC), `fetch_role_grant_history`, `subscribe()`
292
+ (opens `EventSource` at `#stream_url`, default
293
+ `/api/admin/audit/stream`; prepends new events to `events`; refills
294
+ gap via `since_seq`), `disconnect()`. SSE stays on `EventSource` —
295
+ streaming is not an RPC concern.
296
+ - `admin_accounts_state.svelte.ts` — `AdminAccountsState` +
297
+ `admin_accounts_rpc_context` + narrow `AdminAccountsRpc` (seven
298
+ methods: `list_accounts`, `list_sessions`, `create_role_grant`,
251
299
  `revoke_role_grant`, `retract_offer`, `session_revoke_all`,
252
- `token_revoke_all` — the last two are also reused by
253
- `AdminSessionsState`). `SvelteSet`s for in-flight tracking:
254
- `granting_keys` (`${account_id}:${role}` for the account-grain
255
- default; `${account_id}:${role}:${to_actor_id}` when `create_role_grant`
256
- is called with an actor-targeted offer), `revoking_ids` (role_grant id),
257
- `retracting_ids` (offer id). `revoke_role_grant` keys on `actor_id`
258
- (role_grants are actor-scoped matches `row.actor.id` straight from the
259
- listing) with optional `reason`.
260
- - `admin_invites_state.svelte.ts` `AdminInvitesState` extends
261
- `Loadable` + `admin_invites_rpc_context` + narrow
262
- `AdminInvitesRpc` (`list`, `create`, `delete`). Fields:
263
- `invites`, `creating`, `deleting_ids`; derived `invite_count`,
264
- `unclaimed_count`.
265
- - `admin_sessions_state.svelte.ts` `AdminSessionsState` extends
266
- `Loadable`. **Reuses** `admin_accounts_rpc_context` /
267
- `AdminAccountsRpc` for the listing (`list_sessions` wraps
268
- `admin_session_list`) and the two revoke-all mutations. `SvelteSet`s:
269
- `revoking_account_ids`, `revoking_token_account_ids`. `has_rpc`
270
- gates the listing + both revoke controls.
271
- - `app_settings_state.svelte.ts` `AppSettingsState` extends
272
- `Loadable` + `app_settings_rpc_context` + narrow `AppSettingsRpc`
273
- (`get`, `update`). Fields: `settings`, `updating`. Single mutation
274
- `update_open_signup(boolean)`.
300
+ `token_revoke_all` — the last three are also reused by
301
+ `AdminSessionsState`). Slots: `list` (AsyncSlot), `grant`
302
+ (`KeyedAsyncSlot<string, RoleGrantOfferJson>` — slot owns the
303
+ created offer; key composed by exported
304
+ `grant_key(account_id, role, to_actor_id?)`, 2-segment for
305
+ account-grain, 3-segment when actor-targeted), `revoke`
306
+ (`KeyedAsyncSlot<Uuid, void>` keyed by `role_grant_id`), `retract`
307
+ (`KeyedAsyncSlot<Uuid, void>` keyed by `offer_id`). `submit_revoke`
308
+ takes `actor_id` as the first arg (role_grants are actor-scoped —
309
+ matches `row.actor.id` straight from the listing) with optional
310
+ `reason`.
311
+ - `admin_invites_state.svelte.ts` `AdminInvitesState` +
312
+ `admin_invites_rpc_context` + narrow `AdminInvitesRpc` (`list`,
313
+ `create`, `delete`). Slots: `list`, `create` (both AsyncSlot),
314
+ `remove` (`KeyedAsyncSlot<Uuid, void>` keyed by `invite_id`).
315
+ Field: `invites`; derived `invite_count`, `unclaimed_count`.
316
+ Methods: `fetch`, `submit_create`, `submit_delete`. (Slot `remove`
317
+ instead of `delete` to avoid keyword shadowing.)
318
+ - `admin_sessions_state.svelte.ts` `AdminSessionsState`. **Reuses**
319
+ `admin_accounts_rpc_context` / `AdminAccountsRpc` for the listing
320
+ (`list_sessions` wraps `admin_session_list`) and the two revoke-all
321
+ mutations. Slots: `list` (AsyncSlot), `revoke_sessions` /
322
+ `revoke_tokens` (`KeyedAsyncSlot<Uuid, void>` keyed by
323
+ `account_id`). `has_rpc` gates the listing + both revoke controls.
324
+ Methods: `fetch`, `submit_revoke_sessions`, `submit_revoke_tokens`.
325
+ - `app_settings_state.svelte.ts` — `AppSettingsState` +
326
+ `app_settings_rpc_context` + narrow `AppSettingsRpc` (`get`,
327
+ `update`). Slots: `list`, `update`. Field: `settings`. Single
328
+ mutation `update_open_signup(boolean)`.
275
329
  - `admin_rpc_adapters.ts` (plain `.ts`, no reactive state) — bundled
276
330
  wiring for the four admin RPC contexts. `create_admin_rpc_adapters(api)`
277
331
  takes the typed throwing Proxy from `create_frontend_rpc_client` (or
@@ -6,19 +6,31 @@
6
6
  * On confirm, calls `onconfirm` and hides the popover (controlled
7
7
  * by `hide_on_confirm`). Defaults to `position="left"`.
8
8
  *
9
- * Snippets (`children`, `popover_content`, `popover_button_content`)
10
- * receive both the `Popover` instance and a `confirm` callback.
9
+ * Trigger content: pass `label` for a simple string, or a `children`
10
+ * snippet for custom content (the two are mutually exclusive DEV
11
+ * errors when both are set). `pending: boolean` overlays a spinner
12
+ * and disables the trigger, mirroring `PendingButton` semantics so
13
+ * the label stays put while an async operation runs.
11
14
  *
12
15
  * @example
13
16
  * ```svelte
14
17
  * <ConfirmButton
15
18
  * onconfirm={() => delete_item(item.id)}
16
19
  * title="delete item"
17
- * disabled={deleting}
20
+ * label="delete"
21
+ * pending={state.remove.loading(item.id)}
22
+ * />
23
+ * ```
24
+ *
25
+ * @example
26
+ * ```svelte
27
+ * <!-- custom trigger content via the children snippet -->
28
+ * <ConfirmButton
29
+ * onconfirm={() => grant(item.id, role)}
30
+ * title="offer {role}"
31
+ * pending={state.grant.loading(key)}
18
32
  * >
19
- * {#snippet children(_popover, _confirm)}
20
- * {deleting ? 'deleting…' : 'delete'}
21
- * {/snippet}
33
+ * {#snippet children(_popover, _confirm)}+ {role}{/snippet}
22
34
  * </ConfirmButton>
23
35
  * ```
24
36
  *
@@ -34,10 +46,12 @@
34
46
  * @module
35
47
  */
36
48
 
49
+ import {DEV} from 'esm-env';
37
50
  import type {SvelteHTMLElements} from 'svelte/elements';
38
51
  import type {ComponentProps, Snippet} from 'svelte';
39
52
  import type {OmitStrict} from '@fuzdev/fuz_util/types.js';
40
53
  import Glyph from '@fuzdev/fuz_ui/Glyph.svelte';
54
+ import PendingAnimation from '@fuzdev/fuz_ui/PendingAnimation.svelte';
41
55
 
42
56
  import PopoverButton from './PopoverButton.svelte';
43
57
  import type {Popover} from './popover.svelte.js';
@@ -53,6 +67,9 @@
53
67
  popover_button_content,
54
68
  button,
55
69
  children,
70
+ label,
71
+ pending = false,
72
+ disabled: disabled_prop,
56
73
  ...rest
57
74
  }: OmitStrict<ComponentProps<typeof PopoverButton>, 'popover_content' | 'children'> &
58
75
  OmitStrict<SvelteHTMLElements['button'], 'children'> & {
@@ -65,21 +82,34 @@
65
82
  popover_button_content?: Snippet<[popover: Popover, confirm: () => void]> | undefined;
66
83
  /** Unlike on `PopoverButton` this has a `confirm` arg */
67
84
  children?: Snippet<[popover: Popover, confirm: () => void]> | undefined;
85
+ /** Simple string content for the trigger. Mutually exclusive with `children`. */
86
+ label?: string | undefined;
87
+ /**
88
+ * When `true`, the trigger is disabled and a spinner overlays the
89
+ * content (mirrors `PendingButton`). The label / children stay
90
+ * rendered underneath so the button keeps its size.
91
+ */
92
+ pending?: boolean | undefined;
68
93
  } = $props();
69
94
 
70
95
  // TODO @many type union instead of this pattern?
71
- $effect(() => {
72
- if (popover_content_prop && popover_button_attrs) {
73
- console.error(
74
- 'ConfirmButton has both popover_content and popover_attrs defined - popover_content takes precedence',
75
- );
76
- }
77
- if (popover_content_prop && popover_button_content) {
78
- console.error(
79
- 'ConfirmButton has both popover_content and popover_button_content defined - popover_content takes precedence',
80
- );
81
- }
82
- });
96
+ if (DEV) {
97
+ $effect(() => {
98
+ if (popover_content_prop && popover_button_attrs) {
99
+ console.error(
100
+ 'ConfirmButton has both popover_content and popover_button_attrs defined - popover_content takes precedence',
101
+ );
102
+ }
103
+ if (popover_content_prop && popover_button_content) {
104
+ console.error(
105
+ 'ConfirmButton has both popover_content and popover_button_content defined - popover_content takes precedence',
106
+ );
107
+ }
108
+ if (label !== undefined && children) {
109
+ console.error('ConfirmButton has both label and children defined - pick one');
110
+ }
111
+ });
112
+ }
83
113
 
84
114
  const confirm = (popover: Popover): void => {
85
115
  if (hide_on_confirm) popover.hide();
@@ -92,6 +122,7 @@
92
122
  {position}
93
123
  {button}
94
124
  {...rest as any}
125
+ disabled={disabled_prop ?? pending}
95
126
  children={button ? undefined : children_default}
96
127
  >
97
128
  {#snippet popover_content(popover)}
@@ -103,7 +134,7 @@
103
134
  class="color_c bg_100"
104
135
  class:icon_button={!popover_button_content}
105
136
  onclick={() => confirm(popover)}
106
- title="confirm {rest.title || ''}"
137
+ title={rest.title ? `confirm ${rest.title}` : 'confirm'}
107
138
  {...popover_button_attrs}
108
139
  >
109
140
  {#if popover_button_content}
@@ -117,9 +148,36 @@
117
148
  </PopoverButton>
118
149
 
119
150
  {#snippet children_default(popover: Popover)}
120
- {#if children}
121
- {@render children(popover, () => confirm(popover))}
122
- {:else}
123
- <Glyph glyph={GLYPH_REMOVE} />
124
- {/if}
151
+ <span class="trigger" class:pending>
152
+ <span class="content">
153
+ {#if children}
154
+ {@render children(popover, () => confirm(popover))}
155
+ {:else if label !== undefined}
156
+ {label}
157
+ {:else}
158
+ <Glyph glyph={GLYPH_REMOVE} />
159
+ {/if}
160
+ </span>
161
+ {#if pending}
162
+ <span class="animation">
163
+ <PendingAnimation inline />
164
+ </span>
165
+ {/if}
166
+ </span>
125
167
  {/snippet}
168
+
169
+ <style>
170
+ .trigger {
171
+ position: relative;
172
+ }
173
+ .pending .content {
174
+ visibility: hidden;
175
+ }
176
+ .animation {
177
+ position: absolute;
178
+ inset: 0;
179
+ display: flex;
180
+ justify-content: center;
181
+ align-items: center;
182
+ }
183
+ </style>