@fuzdev/fuz_app 0.114.0 → 0.116.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 (68) hide show
  1. package/dist/auth/CLAUDE.md +52 -8
  2. package/dist/auth/admin_actions.d.ts.map +1 -1
  3. package/dist/auth/admin_actions.js +4 -3
  4. package/dist/auth/audit_log_schema.d.ts +3 -1
  5. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  6. package/dist/auth/audit_log_schema.js +13 -3
  7. package/dist/auth/cell_action_specs.js +1 -1
  8. package/dist/auth/cell_actions.d.ts.map +1 -1
  9. package/dist/auth/cell_actions.js +33 -4
  10. package/dist/auth/cell_authorize.d.ts +3 -0
  11. package/dist/auth/cell_authorize.d.ts.map +1 -1
  12. package/dist/auth/cell_authorize.js +7 -4
  13. package/dist/auth/request_context.d.ts +12 -3
  14. package/dist/auth/request_context.d.ts.map +1 -1
  15. package/dist/auth/request_context.js +12 -3
  16. package/dist/auth/role_grant_offer_action_specs.d.ts +30 -5
  17. package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -1
  18. package/dist/auth/role_grant_offer_action_specs.js +53 -17
  19. package/dist/auth/role_grant_offer_actions.d.ts +23 -12
  20. package/dist/auth/role_grant_offer_actions.d.ts.map +1 -1
  21. package/dist/auth/role_grant_offer_actions.js +132 -38
  22. package/dist/auth/role_grant_offer_queries.d.ts +36 -0
  23. package/dist/auth/role_grant_offer_queries.d.ts.map +1 -1
  24. package/dist/auth/role_grant_offer_queries.js +76 -0
  25. package/dist/auth/role_schema.d.ts +12 -0
  26. package/dist/auth/role_schema.d.ts.map +1 -1
  27. package/dist/auth/role_schema.js +12 -0
  28. package/dist/auth/self_service_role_actions.d.ts +14 -0
  29. package/dist/auth/self_service_role_actions.d.ts.map +1 -1
  30. package/dist/auth/self_service_role_actions.js +25 -1
  31. package/dist/server/serve_fact_route.d.ts +3 -1
  32. package/dist/server/serve_fact_route.d.ts.map +1 -1
  33. package/dist/server/serve_fact_route.js +7 -4
  34. package/dist/testing/CLAUDE.md +12 -4
  35. package/dist/testing/cross_backend/cell_gated_create.d.ts.map +1 -1
  36. package/dist/testing/cross_backend/cell_gated_create.js +23 -1
  37. package/dist/testing/cross_backend/role_grant_participation.d.ts.map +1 -1
  38. package/dist/testing/cross_backend/role_grant_participation.js +34 -0
  39. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts +12 -1
  40. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  41. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +13 -1
  42. package/dist/testing/cross_backend/test_cell_gated_create_authorize.d.ts.map +1 -1
  43. package/dist/testing/cross_backend/test_cell_gated_create_authorize.js +9 -7
  44. package/dist/testing/entities.d.ts +6 -3
  45. package/dist/testing/entities.d.ts.map +1 -1
  46. package/dist/testing/entities.js +4 -1
  47. package/dist/ui/AppShell.svelte +303 -33
  48. package/dist/ui/AppShell.svelte.d.ts +80 -11
  49. package/dist/ui/AppShell.svelte.d.ts.map +1 -1
  50. package/dist/ui/BootstrapForm.svelte +14 -10
  51. package/dist/ui/BootstrapForm.svelte.d.ts.map +1 -1
  52. package/dist/ui/CLAUDE.md +152 -12
  53. package/dist/ui/LoginForm.svelte +8 -4
  54. package/dist/ui/LoginForm.svelte.d.ts.map +1 -1
  55. package/dist/ui/RoleGrantOfferForm.svelte +29 -14
  56. package/dist/ui/RoleGrantOfferForm.svelte.d.ts +2 -3
  57. package/dist/ui/RoleGrantOfferForm.svelte.d.ts.map +1 -1
  58. package/dist/ui/SignupForm.svelte +14 -13
  59. package/dist/ui/form_state.svelte.d.ts +15 -7
  60. package/dist/ui/form_state.svelte.d.ts.map +1 -1
  61. package/dist/ui/form_state.svelte.js +10 -2
  62. package/dist/ui/role_grant_offers_state.svelte.d.ts +25 -15
  63. package/dist/ui/role_grant_offers_state.svelte.d.ts.map +1 -1
  64. package/dist/ui/role_grant_offers_state.svelte.js +7 -1
  65. package/dist/ui/sidebar_state.svelte.d.ts +62 -2
  66. package/dist/ui/sidebar_state.svelte.d.ts.map +1 -1
  67. package/dist/ui/sidebar_state.svelte.js +78 -9
  68. package/package.json +1 -21
@@ -4,13 +4,67 @@
4
4
  * a fresh `SidebarState` if `sidebar_state` is not supplied) so descendants
5
5
  * can read sidebar visibility and toggle it. Optionally binds a global
6
6
  * keyboard shortcut and renders a built-in toggle button (or a custom one
7
- * via the `toggle_button` snippet).
7
+ * via the `toggle_button` snippet) while the sidebar is enabled. Requires
8
+ * the SvelteKit runtime — it closes the overlay from `afterNavigate`.
9
+ *
10
+ * Two modes, split by the state's `narrow_query`. Outside it the sidebar
11
+ * sits beside the content, padded by `--sidebar_offset`, following
12
+ * `show_sidebar_wide`. Inside it the sidebar starts closed and opens as an
13
+ * overlay over a scrim, following `show_sidebar_narrow`; the content is
14
+ * `inert` while it's open, and navigation, Escape, a scrim click, or
15
+ * widening out of narrow mode closes it. Escape never prevents the
16
+ * default, so the browser's close requests stay intact; it does nothing
17
+ * while a modal (`:modal`) is open, or when a window `keydown` listener
18
+ * registered before the overlay opened swallows it (a contextmenu, say).
19
+ * Closing with Escape or the keyboard shortcut returns focus to what
20
+ * opened the overlay, or else to the toggle, when focus was inside the
21
+ * sidebar or had fallen to the body (as it does when the content goes
22
+ * inert).
23
+ *
24
+ * The shell's rules sit in two cascade layers nested under `fuz_app`.
25
+ * `fuz_app.app_shell_base`, in the scoped style, holds the base and state
26
+ * rules, which apply in both modes; `fuz_app.app_shell_narrow` overrides
27
+ * some of them from a style element in `svelte:head` carrying
28
+ * `media={narrow_query}`, so the server render paints the right mode
29
+ * before hydration and the markup's classes never depend on the viewport.
30
+ * The narrow layer is ordered after the base layer, so it wins whatever
31
+ * the selectors' specificity or the sheets' order. Its rules are global —
32
+ * they match every `.app-shell` on the page — so shells mounted at the
33
+ * same time must share a `narrow_query`. Pages served under a strict CSP
34
+ * need `style-src-elem 'unsafe-inline'` or the hash of that constant style.
35
+ *
36
+ * Public class hooks: `.app-shell` (root, with state classes `.wide-open` /
37
+ * `.narrow-open`), `.app-shell-content`, `.app-shell-sidebar`,
38
+ * `.app-shell-scrim`, `.app-shell-toggle`, `.app-shell-toggle-glyph`. Any
39
+ * unlayered rule beats both layers at any specificity, so overrides need
40
+ * no specificity games; to layer them instead, order `fuz_app` first
41
+ * (`@layer fuz_app, overrides;`) in a sheet that loads before the shell.
42
+ * The same rule means an unlayered reset overrides the shell too — the
43
+ * fuz_css reset zeroes `padding`, `margin`, and `border` on every element —
44
+ * so the layers set none of those. The one they need, the content's
45
+ * wide offset, goes through `--sidebar_offset`: the layers set it on
46
+ * `.app-shell-content` (`--sidebar_width` while the wide sidebar is open,
47
+ * else `0px`), and one unlayered rule at (0,0,1) reads it into the
48
+ * content's `padding-left`. Descendants of the content can read it too.
49
+ * Override the offset by setting `--sidebar_offset` on
50
+ * `.app-shell-content` or with a `padding-left` rule there; a `padding`
51
+ * shorthand there overrides it as well. Like any unlayered override,
52
+ * these apply in both modes, open or closed; to change only the wide open
53
+ * offset, target `.app-shell.wide-open > .app-shell-content` inside the
54
+ * narrow query's negation — the state classes don't track the viewport,
55
+ * so `.wide-open` stays set in narrow mode too. CSS variables:
56
+ * `--sidebar_bg`, `--sidebar_z_index` (default `200` in both modes; the toggle sits one
57
+ * above), `--sidebar_scrim_bg`, `--sidebar_width` (set from the
58
+ * `sidebar_width` prop; always the configured width, not the offset), and
59
+ * `--sidebar_offset`.
8
60
  *
9
61
  * @module
10
62
  */
11
63
 
12
- import type { Snippet } from 'svelte';
64
+ import { flushSync, type Snippet } from 'svelte';
13
65
  import type { SvelteHTMLElements } from 'svelte/elements';
66
+ import { on } from 'svelte/events';
67
+ import { afterNavigate } from '$app/navigation';
14
68
  import { is_editable, swallow } from '@fuzdev/fuz_util/dom.ts';
15
69
 
16
70
  import { SidebarState, sidebar_state_context } from './sidebar_state.svelte.js';
@@ -23,95 +77,311 @@
23
77
  keyboard_shortcut = false,
24
78
  show_toggle = true,
25
79
  toggle_button,
80
+ class: class_prop,
26
81
  ...rest
27
82
  }: SvelteHTMLElements['div'] & {
28
83
  children: Snippet;
29
84
  sidebar: Snippet;
30
85
  /**
31
- * Sidebar width in pixels when shown.
86
+ * Sidebar width in pixels when shown; in narrow mode capped at `85vw`.
32
87
  * @default 180
33
88
  */
34
89
  sidebar_width?: number;
35
90
  /** Optional pre-built `SidebarState` for sharing visibility across shells. */
36
91
  sidebar_state?: SidebarState;
37
92
  /**
38
- * Single-key shortcut that toggles the sidebar (e.g. `'b'`). `false` disables.
93
+ * Single-key shortcut that toggles the sidebar (e.g. `'b'`) while it's
94
+ * enabled and no modal is open — the persistent sidebar in wide mode,
95
+ * the overlay in narrow mode. `false` disables.
39
96
  * @default false
40
97
  */
41
98
  keyboard_shortcut?: string | false;
42
99
  /**
43
- * Whether to render the built-in (or custom) toggle button.
100
+ * Whether to render the built-in (or custom) toggle button. It renders
101
+ * only while the sidebar is enabled either way.
44
102
  * @default true
45
103
  */
46
104
  show_toggle?: boolean;
47
- /** Custom toggle-button renderer; receives the title, visibility, and toggle callback. */
48
- toggle_button?: Snippet<[{ title: string; show_sidebar: boolean; toggle: () => void }]>;
105
+ /**
106
+ * Custom toggle-button renderer; receives the title, visibility, toggle
107
+ * callback, and the sidebar element's id. Give the button
108
+ * `class="app-shell-toggle"` so it stacks above the sidebar and scrim
109
+ * (it renders as a direct child of the root) — the z-index applies only
110
+ * to a positioned button, so position it (e.g. `position: fixed`) — and
111
+ * `aria-controls={sidebar_id}` so Escape can return focus to it.
112
+ * `show_sidebar` is viewport-dependent: the server renders it as in wide
113
+ * mode, so rendering it as visible content changes on hydration at
114
+ * narrow widths — attributes are repaired, but key visible content on
115
+ * CSS or the viewport-free fields instead.
116
+ */
117
+ toggle_button?: Snippet<
118
+ [{ title: string; show_sidebar: boolean; toggle: () => void; sidebar_id: string }]
119
+ >;
49
120
  } = $props();
50
121
 
122
+ const uid = $props.id();
123
+ const sidebar_id = `${uid}-sidebar`;
124
+
125
+ let fallback_sidebar_state: SidebarState | undefined;
51
126
  const get_sidebar_state = sidebar_state_context.set(
52
- () => sidebar_state_prop ?? new SidebarState()
127
+ () => sidebar_state_prop ?? (fallback_sidebar_state ??= new SidebarState())
53
128
  );
54
129
  const sidebar_state = $derived(get_sidebar_state());
55
130
 
56
- const sidebar_width_px = $derived(sidebar_state.show_sidebar ? sidebar_width : 0);
131
+ // viewport-free, so the server and hydration renders agree
132
+ const wide_open = $derived(sidebar_state.enabled && sidebar_state.show_sidebar_wide);
133
+ const narrow_open = $derived(sidebar_state.enabled && sidebar_state.show_sidebar_narrow);
134
+ // viewport-dependent, only for `inert` and input handling (always `false` in SSR)
135
+ const overlay_open = $derived(sidebar_state.narrow && narrow_open);
136
+
137
+ const toggle_selector = `[aria-controls="${sidebar_id}"]`;
57
138
 
58
139
  const button_title = $derived(
59
140
  (sidebar_state.show_sidebar ? 'hide sidebar' : 'show sidebar') +
60
141
  (keyboard_shortcut ? ` [${keyboard_shortcut}]` : '')
61
142
  );
143
+
144
+ let sidebar_el: HTMLElement | undefined = $state();
145
+ let opener: HTMLElement | null = null;
146
+
147
+ afterNavigate(() => sidebar_state.close_narrow());
148
+
149
+ // widening out of narrow mode closes the overlay; the wide preference is untouched
150
+ $effect(() => {
151
+ if (!sidebar_state.narrow) sidebar_state.close_narrow();
152
+ });
153
+
154
+ const on_escape = (e: KeyboardEvent): void => {
155
+ if (e.key !== 'Escape' || e.defaultPrevented || !overlay_open) return;
156
+ // an open modal dialog owns Escape; not preventing the default leaves the
157
+ // browser's close requests (dialog `cancel`, `popover="auto"`) intact
158
+ if (document.querySelector(':modal')) return;
159
+ close_overlay();
160
+ };
161
+
162
+ // closes the overlay, returning focus when it was inside the sidebar or had
163
+ // fallen to the body — the inert content drops focus opened from within it
164
+ const close_overlay = (): void => {
165
+ const active = document.activeElement;
166
+ const should_return_focus =
167
+ !active || active === document.body || !!sidebar_el?.contains(active);
168
+ // flush so the content is no longer `inert` when focus returns into it
169
+ flushSync(() => sidebar_state.close_narrow());
170
+ if (should_return_focus) return_focus();
171
+ };
172
+
173
+ // the opener if it's still there and takes focus, else the first toggle that
174
+ // does — Safari doesn't focus a clicked button, so the opener can be `null`
175
+ const return_focus = (): void => {
176
+ if (opener?.isConnected) {
177
+ opener.focus();
178
+ if (document.activeElement === opener) return;
179
+ }
180
+ for (const toggle of document.querySelectorAll<HTMLElement>(toggle_selector)) {
181
+ toggle.focus();
182
+ if (document.activeElement === toggle) return;
183
+ }
184
+ };
185
+
186
+ // while the overlay is open, remember what had focus (to return it on Escape)
187
+ // and listen for Escape — registered now, not at mount, so window listeners
188
+ // mounted earlier (a contextmenu's, say) see Escape first and can swallow it
189
+ $effect(() => {
190
+ if (!overlay_open) return;
191
+ const active = document.activeElement;
192
+ opener = active instanceof HTMLElement && active !== document.body ? active : null;
193
+ return on(window, 'keydown', on_escape);
194
+ });
62
195
  </script>
63
196
 
197
+ <svelte:head>
198
+ {#if sidebar_state.narrow_query !== null}
199
+ <style media={sidebar_state.narrow_query}>
200
+ @layer fuz_app.app_shell_base, fuz_app.app_shell_narrow;
201
+ @layer fuz_app.app_shell_narrow {
202
+ .app-shell > .app-shell-content {
203
+ --sidebar_offset: 0px;
204
+ }
205
+ .app-shell > .app-shell-sidebar {
206
+ visibility: hidden;
207
+ translate: -100% 0;
208
+ width: min(var(--sidebar_width), 85vw);
209
+ }
210
+ .app-shell.narrow-open > .app-shell-sidebar {
211
+ visibility: visible;
212
+ translate: none;
213
+ }
214
+ .app-shell.narrow-open > .app-shell-scrim {
215
+ display: block;
216
+ }
217
+ .app-shell > .app-shell-toggle .app-shell-toggle-glyph {
218
+ scale: -1 1;
219
+ }
220
+ .app-shell.narrow-open > .app-shell-toggle .app-shell-toggle-glyph {
221
+ scale: none;
222
+ }
223
+ @media (prefers-reduced-motion: no-preference) {
224
+ .app-shell > .app-shell-sidebar {
225
+ transition:
226
+ translate 150ms ease-out,
227
+ visibility 0s 150ms;
228
+ }
229
+ .app-shell.narrow-open > .app-shell-sidebar {
230
+ transition:
231
+ translate 150ms ease-out,
232
+ visibility 0s;
233
+ }
234
+ }
235
+ }
236
+ </style>
237
+ {/if}
238
+ </svelte:head>
239
+
64
240
  <svelte:window
65
241
  onkeydowncapture={(e) => {
66
- if (keyboard_shortcut && e.key === keyboard_shortcut && !is_editable(e.target)) {
67
- sidebar_state.toggle_sidebar();
242
+ if (
243
+ keyboard_shortcut &&
244
+ sidebar_state.enabled &&
245
+ e.key === keyboard_shortcut &&
246
+ !is_editable(e.target) &&
247
+ // a modal owns the keyboard, even one opened from the overlay
248
+ !document.querySelector(':modal')
249
+ ) {
250
+ // closing the overlay returns focus like Escape; the rest just toggles
251
+ if (overlay_open) {
252
+ close_overlay();
253
+ } else {
254
+ sidebar_state.toggle_sidebar();
255
+ }
68
256
  swallow(e);
69
257
  }
70
258
  }}
71
259
  />
72
260
 
73
- <div {...rest} style:--sidebar_width="{sidebar_width_px}px">
74
- <div class="content" style:padding-left="var(--sidebar_width)">
261
+ <div
262
+ {...rest}
263
+ class={['app-shell', class_prop, { 'wide-open': wide_open, 'narrow-open': narrow_open }]}
264
+ style:--sidebar_width="{sidebar_width}px"
265
+ >
266
+ <div class="app-shell-content" inert={overlay_open}>
75
267
  {@render children()}
76
268
  </div>
77
- <div class="sidebar" style:width="var(--sidebar_width)">
78
- {@render sidebar()}
79
- </div>
80
- {#if show_toggle}
269
+ <!-- pointer-only dismissal; Escape is the keyboard path -->
270
+ <div
271
+ class="app-shell-scrim"
272
+ aria-hidden="true"
273
+ onclick={() => sidebar_state.close_narrow()}
274
+ ></div>
275
+ {#if show_toggle && sidebar_state.enabled}
81
276
  {#if toggle_button}
82
277
  {@render toggle_button({
83
278
  title: button_title,
84
279
  show_sidebar: sidebar_state.show_sidebar,
85
- toggle: () => sidebar_state.toggle_sidebar()
280
+ toggle: () => sidebar_state.toggle_sidebar(),
281
+ sidebar_id
86
282
  })}
87
283
  {:else}
88
284
  <button
89
285
  type="button"
90
- class="position:fixed bottom:0 left:0 icon_button plain border-radius:0 border_top_right_radius_sm"
91
- aria-label={button_title}
286
+ class="app-shell-toggle position:fixed bottom:0 left:0 icon_button plain border-radius:0 border_top_right_radius_sm"
287
+ aria-label="sidebar"
288
+ aria-keyshortcuts={keyboard_shortcut || undefined}
289
+ aria-expanded={sidebar_state.show_sidebar}
290
+ aria-controls={sidebar_id}
92
291
  title={button_title}
93
292
  onclick={() => sidebar_state.toggle_sidebar()}
94
293
  >
95
- {sidebar_state.show_sidebar ? '\u2190' : '\u2192'}
294
+ <span class="app-shell-toggle-glyph" aria-hidden="true">←</span>
96
295
  </button>
97
296
  {/if}
98
297
  {/if}
298
+ <div class="app-shell-sidebar" id={sidebar_id} bind:this={sidebar_el}>
299
+ {@render sidebar()}
300
+ </div>
99
301
  </div>
100
302
 
101
303
  <style>
102
- .sidebar {
103
- position: fixed;
104
- top: 0;
105
- left: 0;
106
- height: 100%;
107
- overflow: auto;
108
- scrollbar-width: thin;
109
- background: var(--sidebar_bg, var(--shade_05));
304
+ /*
305
+ * The shell's rules sit in `fuz_app.app_shell_base`. The narrow overrides live
306
+ * in the `media` style in `svelte:head`, in `fuz_app.app_shell_narrow`, which
307
+ * this order statement puts after the base layer — so they win regardless of
308
+ * specificity (these selectors carry the scope hash) or of which sheet loads
309
+ * first. The head style opens with the same statement; repeating it here pins
310
+ * the order whichever sheet the browser meets first.
311
+ */
312
+ @layer fuz_app.app_shell_base, fuz_app.app_shell_narrow;
313
+
314
+ /*
315
+ * Never put a property the fuz_css reset sets (`padding`, `margin`, `border`,
316
+ * `box-sizing`, and their longhands) in a layer: the reset is unlayered, so it
317
+ * beats every layered declaration and the layered value computes to the
318
+ * reset's. Route such a value through a custom property the layers set, read
319
+ * by an unlayered rule. The content offset is the one case: the layers set
320
+ * `--sidebar_offset`, and this rule reads it. It's unscoped at (0,0,1) — above
321
+ * the reset's (0,0,0), below any class selector — so a consumer rule on
322
+ * `.app-shell-content` overrides it without matching specificity.
323
+ */
324
+ :global(div:where(.app-shell > .app-shell-content)) {
325
+ padding-left: var(--sidebar_offset, 0px);
110
326
  }
111
327
 
112
- .content {
113
- display: flex;
114
- flex-direction: column;
115
- min-height: 100vh;
328
+ @layer fuz_app.app_shell_base {
329
+ .app-shell-content {
330
+ --sidebar_offset: 0px;
331
+ display: flex;
332
+ flex-direction: column;
333
+ min-height: 100vh;
334
+ }
335
+ .app-shell.wide-open > .app-shell-content {
336
+ --sidebar_offset: var(--sidebar_width);
337
+ }
338
+
339
+ .app-shell-sidebar {
340
+ position: fixed;
341
+ top: 0;
342
+ left: 0;
343
+ width: var(--sidebar_width);
344
+ height: 100%;
345
+ overflow: auto;
346
+ overscroll-behavior: contain;
347
+ scrollbar-width: thin;
348
+ background: var(--sidebar_bg, var(--shade_05));
349
+ z-index: var(--sidebar_z_index, 200);
350
+ visibility: hidden;
351
+ }
352
+ .app-shell.wide-open > .app-shell-sidebar {
353
+ visibility: visible;
354
+ }
355
+
356
+ .app-shell-scrim {
357
+ display: none;
358
+ position: fixed;
359
+ inset: 0;
360
+ z-index: var(--sidebar_z_index, 200);
361
+ background: var(--sidebar_scrim_bg, var(--darken_40));
362
+ }
363
+
364
+ /*
365
+ * The toggle and its glyph may be consumer markup from the `toggle_button`
366
+ * snippet, which never carries this component's scope hash, so they're
367
+ * matched by their public class names under the scoped root. The glyph
368
+ * rules go through the root's own toggle so an outer shell's state never
369
+ * reaches a nested shell's glyph.
370
+ */
371
+ .app-shell > :global(.app-shell-toggle) {
372
+ z-index: calc(var(--sidebar_z_index, 200) + 1);
373
+ }
374
+ .app-shell > :global(.app-shell-toggle) :global(.app-shell-toggle-glyph) {
375
+ display: inline-block;
376
+ scale: -1 1;
377
+ }
378
+ .app-shell.wide-open > :global(.app-shell-toggle) :global(.app-shell-toggle-glyph) {
379
+ scale: none;
380
+ }
381
+
382
+ /* a modal in the sidebar is in the top layer but inherits its visibility */
383
+ .app-shell-sidebar :global(:modal) {
384
+ visibility: visible;
385
+ }
116
386
  }
117
387
  </style>
@@ -3,39 +3,108 @@
3
3
  * a fresh `SidebarState` if `sidebar_state` is not supplied) so descendants
4
4
  * can read sidebar visibility and toggle it. Optionally binds a global
5
5
  * keyboard shortcut and renders a built-in toggle button (or a custom one
6
- * via the `toggle_button` snippet).
6
+ * via the `toggle_button` snippet) while the sidebar is enabled. Requires
7
+ * the SvelteKit runtime — it closes the overlay from `afterNavigate`.
8
+ *
9
+ * Two modes, split by the state's `narrow_query`. Outside it the sidebar
10
+ * sits beside the content, padded by `--sidebar_offset`, following
11
+ * `show_sidebar_wide`. Inside it the sidebar starts closed and opens as an
12
+ * overlay over a scrim, following `show_sidebar_narrow`; the content is
13
+ * `inert` while it's open, and navigation, Escape, a scrim click, or
14
+ * widening out of narrow mode closes it. Escape never prevents the
15
+ * default, so the browser's close requests stay intact; it does nothing
16
+ * while a modal (`:modal`) is open, or when a window `keydown` listener
17
+ * registered before the overlay opened swallows it (a contextmenu, say).
18
+ * Closing with Escape or the keyboard shortcut returns focus to what
19
+ * opened the overlay, or else to the toggle, when focus was inside the
20
+ * sidebar or had fallen to the body (as it does when the content goes
21
+ * inert).
22
+ *
23
+ * The shell's rules sit in two cascade layers nested under `fuz_app`.
24
+ * `fuz_app.app_shell_base`, in the scoped style, holds the base and state
25
+ * rules, which apply in both modes; `fuz_app.app_shell_narrow` overrides
26
+ * some of them from a style element in `svelte:head` carrying
27
+ * `media={narrow_query}`, so the server render paints the right mode
28
+ * before hydration and the markup's classes never depend on the viewport.
29
+ * The narrow layer is ordered after the base layer, so it wins whatever
30
+ * the selectors' specificity or the sheets' order. Its rules are global —
31
+ * they match every `.app-shell` on the page — so shells mounted at the
32
+ * same time must share a `narrow_query`. Pages served under a strict CSP
33
+ * need `style-src-elem 'unsafe-inline'` or the hash of that constant style.
34
+ *
35
+ * Public class hooks: `.app-shell` (root, with state classes `.wide-open` /
36
+ * `.narrow-open`), `.app-shell-content`, `.app-shell-sidebar`,
37
+ * `.app-shell-scrim`, `.app-shell-toggle`, `.app-shell-toggle-glyph`. Any
38
+ * unlayered rule beats both layers at any specificity, so overrides need
39
+ * no specificity games; to layer them instead, order `fuz_app` first
40
+ * (`@layer fuz_app, overrides;`) in a sheet that loads before the shell.
41
+ * The same rule means an unlayered reset overrides the shell too — the
42
+ * fuz_css reset zeroes `padding`, `margin`, and `border` on every element —
43
+ * so the layers set none of those. The one they need, the content's
44
+ * wide offset, goes through `--sidebar_offset`: the layers set it on
45
+ * `.app-shell-content` (`--sidebar_width` while the wide sidebar is open,
46
+ * else `0px`), and one unlayered rule at (0,0,1) reads it into the
47
+ * content's `padding-left`. Descendants of the content can read it too.
48
+ * Override the offset by setting `--sidebar_offset` on
49
+ * `.app-shell-content` or with a `padding-left` rule there; a `padding`
50
+ * shorthand there overrides it as well. Like any unlayered override,
51
+ * these apply in both modes, open or closed; to change only the wide open
52
+ * offset, target `.app-shell.wide-open > .app-shell-content` inside the
53
+ * narrow query's negation — the state classes don't track the viewport,
54
+ * so `.wide-open` stays set in narrow mode too. CSS variables:
55
+ * `--sidebar_bg`, `--sidebar_z_index` (default `200` in both modes; the toggle sits one
56
+ * above), `--sidebar_scrim_bg`, `--sidebar_width` (set from the
57
+ * `sidebar_width` prop; always the configured width, not the offset), and
58
+ * `--sidebar_offset`.
7
59
  *
8
60
  * @module
9
61
  */
10
- import type { Snippet } from 'svelte';
62
+ import { type Snippet } from 'svelte';
11
63
  import type { SvelteHTMLElements } from 'svelte/elements';
12
64
  import { SidebarState } from './sidebar_state.svelte.js';
13
65
  type $$ComponentProps = SvelteHTMLElements['div'] & {
14
66
  children: Snippet;
15
67
  sidebar: Snippet;
16
68
  /**
17
- * Sidebar width in pixels when shown.
69
+ * Sidebar width in pixels when shown; in narrow mode capped at `85vw`.
18
70
  * @default 180
19
71
  */
20
72
  sidebar_width?: number;
21
73
  /** Optional pre-built `SidebarState` for sharing visibility across shells. */
22
74
  sidebar_state?: SidebarState;
23
75
  /**
24
- * Single-key shortcut that toggles the sidebar (e.g. `'b'`). `false` disables.
76
+ * Single-key shortcut that toggles the sidebar (e.g. `'b'`) while it's
77
+ * enabled and no modal is open — the persistent sidebar in wide mode,
78
+ * the overlay in narrow mode. `false` disables.
25
79
  * @default false
26
80
  */
27
81
  keyboard_shortcut?: string | false;
28
82
  /**
29
- * Whether to render the built-in (or custom) toggle button.
83
+ * Whether to render the built-in (or custom) toggle button. It renders
84
+ * only while the sidebar is enabled either way.
30
85
  * @default true
31
86
  */
32
87
  show_toggle?: boolean;
33
- /** Custom toggle-button renderer; receives the title, visibility, and toggle callback. */
34
- toggle_button?: Snippet<[{
35
- title: string;
36
- show_sidebar: boolean;
37
- toggle: () => void;
38
- }]>;
88
+ /**
89
+ * Custom toggle-button renderer; receives the title, visibility, toggle
90
+ * callback, and the sidebar element's id. Give the button
91
+ * `class="app-shell-toggle"` so it stacks above the sidebar and scrim
92
+ * (it renders as a direct child of the root) — the z-index applies only
93
+ * to a positioned button, so position it (e.g. `position: fixed`) — and
94
+ * `aria-controls={sidebar_id}` so Escape can return focus to it.
95
+ * `show_sidebar` is viewport-dependent: the server renders it as in wide
96
+ * mode, so rendering it as visible content changes on hydration at
97
+ * narrow widths — attributes are repaired, but key visible content on
98
+ * CSS or the viewport-free fields instead.
99
+ */
100
+ toggle_button?: Snippet<[
101
+ {
102
+ title: string;
103
+ show_sidebar: boolean;
104
+ toggle: () => void;
105
+ sidebar_id: string;
106
+ }
107
+ ]>;
39
108
  };
40
109
  declare const AppShell: import("svelte").Component<$$ComponentProps, {}, "">;
41
110
  type AppShell = ReturnType<typeof AppShell>;
@@ -1 +1 @@
1
- {"version":3,"file":"AppShell.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/AppShell.svelte"],"names":[],"mappings":"AAGA;;;;;;;;OAQI;AACJ,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AAG1D,OAAO,EAAE,YAAY,EAAyB,MAAM,2BAA2B,CAAC;AAE/E,KAAK,gBAAgB,GAAI,kBAAkB,CAAC,KAAK,CAAC,GAAG;IACpD,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,8EAA8E;IAC9E,aAAa,CAAC,EAAE,YAAY,CAAC;IAC7B;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IACnC;;;OAGG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,0FAA0F;IAC1F,aAAa,CAAC,EAAE,OAAO,CAAC,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,IAAI,CAAA;KAAE,CAAC,CAAC,CAAC;CACxF,CAAC;AAmEH,QAAA,MAAM,QAAQ,sDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"AppShell.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/AppShell.svelte"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4DI;AACJ,OAAO,EAAa,KAAK,OAAO,EAAE,MAAM,QAAQ,CAAC;AACjD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AAK1D,OAAO,EAAE,YAAY,EAAyB,MAAM,2BAA2B,CAAC;AAE/E,KAAK,gBAAgB,GAAI,kBAAkB,CAAC,KAAK,CAAC,GAAG;IACpD,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,8EAA8E;IAC9E,aAAa,CAAC,EAAE,YAAY,CAAC;IAC7B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IACnC;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;;;;;;;;;OAWG;IACH,aAAa,CAAC,EAAE,OAAO,CACtB;QAAC;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,YAAY,EAAE,OAAO,CAAC;YAAC,MAAM,EAAE,MAAM,IAAI,CAAC;YAAC,UAAU,EAAE,MAAM,CAAA;SAAE;KAAC,CAClF,CAAC;CACF,CAAC;AAyJH,QAAA,MAAM,QAAQ,sDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -17,7 +17,7 @@
17
17
  import PendingButton from '@fuzdev/fuz_ui/PendingButton.svelte';
18
18
  import { autofocus } from '@fuzdev/fuz_ui/autofocus.svelte.ts';
19
19
 
20
- import { Username } from '../primitive_schemas.js';
20
+ import { Username, USERNAME_LENGTH_MAX, USERNAME_LENGTH_MIN } from '../primitive_schemas.js';
21
21
  import { PASSWORD_LENGTH_MIN } from '../auth/password.js';
22
22
  import { auth_state_context } from './auth_state.svelte.js';
23
23
  import { FormState } from './form_state.svelte.js';
@@ -33,7 +33,7 @@
33
33
  } = $props();
34
34
 
35
35
  const auth_state = auth_state_context.get();
36
- const form_state = new FormState();
36
+ const form_state = new FormState<'token' | 'username' | 'password' | 'password_confirm'>();
37
37
 
38
38
  let token = $state.raw('');
39
39
  let username = $state.raw('');
@@ -47,11 +47,13 @@
47
47
  username.trim() &&
48
48
  username_valid &&
49
49
  password.length >= PASSWORD_LENGTH_MIN &&
50
- passwords_match &&
51
- !auth_state.verifying
50
+ passwords_match
52
51
  );
53
52
 
54
53
  const handle_bootstrap = async (): Promise<void> => {
54
+ // while verifying the fields are `readonly` and the button `aria-disabled` —
55
+ // `disabled` would drop focus to `<body>` — so Enter and clicks still land here
56
+ if (auth_state.verifying) return;
55
57
  form_state.attempt();
56
58
  if (!can_submit) {
57
59
  if (!token.trim()) form_state.focus('token');
@@ -86,7 +88,7 @@
86
88
  type="password"
87
89
  bind:value={token}
88
90
  placeholder="paste token"
89
- disabled={auth_state.verifying}
91
+ readonly={auth_state.verifying}
90
92
  {@attach autofocus()}
91
93
  />
92
94
  </label>
@@ -98,12 +100,13 @@
98
100
  bind:value={username}
99
101
  placeholder="admin"
100
102
  autocomplete="username"
101
- disabled={auth_state.verifying}
103
+ readonly={auth_state.verifying}
102
104
  />
103
105
  </label>
104
106
  {#if form_state.show('username') && username && !username_valid}
105
107
  <p class="color_c_50 font_size_sm mt_0 mb_xs">
106
- 3-39 chars, starts with a letter, ends with letter/number, middle allows dash/underscore
108
+ {USERNAME_LENGTH_MIN}-{USERNAME_LENGTH_MAX} chars, starts with a letter, ends with
109
+ letter/number, middle allows dash/underscore
107
110
  </p>
108
111
  {/if}
109
112
  <fieldset>
@@ -115,7 +118,7 @@
115
118
  bind:value={password}
116
119
  placeholder="password"
117
120
  autocomplete="new-password"
118
- disabled={auth_state.verifying}
121
+ readonly={auth_state.verifying}
119
122
  />
120
123
  </label>
121
124
  {#if form_state.show('password') && password && password.length < PASSWORD_LENGTH_MIN}
@@ -131,7 +134,7 @@
131
134
  bind:value={password_confirm}
132
135
  placeholder="confirm password"
133
136
  autocomplete="new-password"
134
- disabled={auth_state.verifying}
137
+ readonly={auth_state.verifying}
135
138
  />
136
139
  </label>
137
140
  {#if form_state.show('password_confirm') && password && password_confirm && !passwords_match}
@@ -141,7 +144,8 @@
141
144
  <div class="row gap_sm">
142
145
  <PendingButton
143
146
  pending={auth_state.verifying}
144
- disabled={auth_state.verifying}
147
+ disabled={false}
148
+ aria-disabled={auth_state.verifying}
145
149
  onclick={handle_bootstrap}
146
150
  class={auth_state.verify_error ? 'color_c' : ''}
147
151
  >
@@ -1 +1 @@
1
- {"version":3,"file":"BootstrapForm.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/BootstrapForm.svelte"],"names":[],"mappings":"AAyBC,KAAK,gBAAgB,GAAI;IACxB;;;OAGG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAC/B,CAAC;AAuGH,QAAA,MAAM,aAAa,sDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"BootstrapForm.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/BootstrapForm.svelte"],"names":[],"mappings":"AAyBC,KAAK,gBAAgB,GAAI;IACxB;;;OAGG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAC/B,CAAC;AA0GH,QAAA,MAAM,aAAa,sDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}