@classic-homes/theme-svelte 0.1.56 → 1.0.1

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.
@@ -89,7 +89,7 @@
89
89
  {#snippet trigger()}
90
90
  <button
91
91
  type="button"
92
- class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
92
+ class="relative flex h-8 w-8 items-center justify-center rounded-md before:absolute before:-inset-1.5 before:content-[''] bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
93
93
  aria-label="Panel options"
94
94
  >
95
95
  <svg
@@ -141,7 +141,7 @@
141
141
  <!-- Collapse/Expand button -->
142
142
  <button
143
143
  type="button"
144
- class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
144
+ class="relative flex h-8 w-8 items-center justify-center rounded-md before:absolute before:-inset-1.5 before:content-[''] bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
145
145
  onclick={() => onExpandedChange?.(!isExpanded)}
146
146
  aria-label={isExpanded ? 'Collapse panel' : 'Expand panel'}
147
147
  aria-expanded={isExpanded}
@@ -168,7 +168,7 @@
168
168
  {#snippet trigger()}
169
169
  <button
170
170
  type="button"
171
- class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
171
+ class="relative flex h-8 w-8 items-center justify-center rounded-md before:absolute before:-inset-1.5 before:content-[''] bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
172
172
  aria-label="Panel options"
173
173
  >
174
174
  <svg
@@ -195,7 +195,7 @@
195
195
  {#if !disableClose}
196
196
  <button
197
197
  type="button"
198
- class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
198
+ class="relative flex h-8 w-8 items-center justify-center rounded-md before:absolute before:-inset-1.5 before:content-[''] bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
199
199
  onclick={() => onClose?.()}
200
200
  aria-label="Close panel"
201
201
  >
@@ -1,72 +1,240 @@
1
1
  <script lang="ts">
2
2
  import { cn } from '../utils.js';
3
3
  import type { PanelEdge } from '@classic-homes/data-panel-core';
4
+ import { constrainTabOffset } from '@classic-homes/data-panel-core';
4
5
 
5
6
  interface Props {
6
7
  /** Tab label/title */
7
8
  title?: string;
9
+ /** Secondary line under the title */
10
+ subtitle?: string;
8
11
  /** Current edge position */
9
12
  edge: PanelEdge;
10
- /** Click handler to expand panel */
13
+ /** Expand the panel (tap/click/keyboard on the chip body) */
11
14
  onclick?: () => void;
15
+ /** Close the panel (chip close button) */
16
+ onClose?: () => void;
17
+ disableClose?: boolean;
18
+ /** Offset (px) of the tab along its edge from the centered position */
19
+ offset?: number;
20
+ /** Called with the new offset after the tab is dragged along its edge */
21
+ onOffsetChange?: (offset: number) => void;
12
22
  /** Additional class */
13
23
  class?: string;
14
24
  }
15
25
 
16
- let { title = 'Panel', edge, onclick, class: className }: Props = $props();
26
+ let {
27
+ title = 'Panel',
28
+ subtitle,
29
+ edge,
30
+ onclick,
31
+ onClose,
32
+ disableClose = false,
33
+ offset = 0,
34
+ onOffsetChange,
35
+ class: className,
36
+ }: Props = $props();
37
+
38
+ const isVertical = $derived(edge === 'left' || edge === 'right');
17
39
 
18
- // Position classes based on edge
19
40
  const positionClasses = $derived<string>(
20
41
  {
21
- left: 'left-0 top-1/2 -translate-y-1/2 rounded-r-md border-l-0',
22
- right: 'right-0 top-1/2 -translate-y-1/2 rounded-l-md border-r-0',
23
- top: 'top-0 left-1/2 -translate-x-1/2 rounded-b-md border-t-0',
24
- bottom: 'bottom-0 left-1/2 -translate-x-1/2 rounded-t-md border-b-0',
42
+ // Side tabs are the whole chip rotated 90°, so they need a fixed thickness
43
+ // (h-14 → 56px) for a deterministic edge hug; see tabTransform below. Border
44
+ // classes are in the chip's pre-rotation frame: its bottom rotates to the
45
+ // attached (edge) side and its top corners to the viewport-facing corners,
46
+ // so both edges drop the bottom border and round the top.
47
+ left: 'left-0 top-1/2 h-14 rounded-t-lg border-b-0',
48
+ right: 'right-0 top-1/2 h-14 rounded-t-lg border-b-0',
49
+ top: 'top-0 left-1/2 rounded-b-lg border-t-0',
50
+ bottom: 'bottom-0 left-1/2 rounded-t-lg border-b-0',
25
51
  }[edge]
26
52
  );
27
53
 
28
- // Text direction for vertical edges
29
- const isVertical = $derived(edge === 'left' || edge === 'right');
54
+ const draggable = $derived(!!onOffsetChange);
55
+
56
+ let btn = $state<HTMLDivElement | null>(null);
57
+ let dragOffset = $state<number | null>(null);
58
+ const effectiveOffset = $derived(dragOffset ?? offset);
59
+
60
+ // Half the side-tab thickness (h-14 → 56px). Left/right tabs are the whole chip
61
+ // rotated 90°; we center the rotated footprint on the edge, then shift it back
62
+ // by half its thickness so the outer long edge sits flush.
63
+ const SIDE_HALF = 28;
64
+ const tabTransform = $derived<string>(
65
+ edge === 'left'
66
+ ? `translate(calc(-50% + ${SIDE_HALF}px), calc(-50% + ${effectiveOffset}px)) rotate(90deg)`
67
+ : edge === 'right'
68
+ ? `translate(calc(50% - ${SIDE_HALF}px), calc(-50% + ${effectiveOffset}px)) rotate(-90deg)`
69
+ : `translateX(calc(-50% + ${effectiveOffset}px))`
70
+ );
71
+
72
+ // Non-reactive drag bookkeeping.
73
+ let drag = { startX: 0, startY: 0, startOffset: 0, moved: false, pointerId: -1 };
74
+
75
+ function handlePointerDown(e: PointerEvent) {
76
+ if (!onOffsetChange) return;
77
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
78
+ // Let action buttons (close) handle their own press without starting a drag.
79
+ if ((e.target as HTMLElement).closest('[data-no-drag]')) return;
80
+ drag = {
81
+ startX: e.clientX,
82
+ startY: e.clientY,
83
+ startOffset: offset,
84
+ moved: false,
85
+ pointerId: e.pointerId,
86
+ };
87
+ dragOffset = offset;
88
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
89
+ }
90
+
91
+ function handlePointerMove(e: PointerEvent) {
92
+ const el = e.currentTarget as HTMLElement;
93
+ if (drag.pointerId !== e.pointerId || !el.hasPointerCapture(e.pointerId)) return;
94
+ const delta = isVertical ? e.clientY - drag.startY : e.clientX - drag.startX;
95
+ if (Math.abs(delta) > 3) drag.moved = true;
96
+ const rect = btn?.getBoundingClientRect();
97
+ const tabSize = { width: rect?.width ?? 160, height: rect?.height ?? 48 };
98
+ dragOffset = constrainTabOffset(
99
+ drag.startOffset + delta,
100
+ edge,
101
+ tabSize,
102
+ window.innerWidth,
103
+ window.innerHeight
104
+ );
105
+ }
106
+
107
+ function handlePointerUp(e: PointerEvent) {
108
+ const el = e.currentTarget as HTMLElement;
109
+ if (drag.pointerId !== e.pointerId) return;
110
+ if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
111
+ drag.pointerId = -1;
112
+ if (drag.moved) {
113
+ // A drag repositions.
114
+ onOffsetChange?.(dragOffset ?? offset);
115
+ } else {
116
+ // A tap anywhere on the chip expands. Handled here (not on the inner
117
+ // button's click) because the container holds pointer capture during a
118
+ // press, which would otherwise swallow the button click.
119
+ onclick?.();
120
+ }
121
+ dragOffset = null;
122
+ }
123
+
124
+ // A canceled pointer (OS gesture, scroll) must not count as a tap-to-expand;
125
+ // just discard the in-flight drag and revert to the persisted offset.
126
+ function handlePointerCancel(e: PointerEvent) {
127
+ if (drag.pointerId !== e.pointerId) return;
128
+ const el = e.currentTarget as HTMLElement;
129
+ if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
130
+ drag.pointerId = -1;
131
+ drag.moved = false;
132
+ dragOffset = null;
133
+ }
134
+
135
+ // Keyboard activation of the expand button (pointer taps expand via the
136
+ // container's pointerup above).
137
+ function handleExpandKeyDown(e: KeyboardEvent) {
138
+ if (e.key === 'Enter' || e.key === ' ') {
139
+ e.preventDefault();
140
+ onclick?.();
141
+ }
142
+ }
143
+
144
+ function handleClose(e: MouseEvent) {
145
+ e.stopPropagation();
146
+ onClose?.();
147
+ }
148
+
149
+ function clampOffset() {
150
+ if (!onOffsetChange) return;
151
+ const rect = btn?.getBoundingClientRect();
152
+ if (!rect) return;
153
+ const clamped = constrainTabOffset(
154
+ offset,
155
+ edge,
156
+ { width: rect.width, height: rect.height },
157
+ window.innerWidth,
158
+ window.innerHeight
159
+ );
160
+ if (clamped !== offset) onOffsetChange(clamped);
161
+ }
162
+
163
+ // Re-clamp the persisted offset to the current tab geometry. The tab's
164
+ // along-edge extent depends on its rendered size (and, for side edges, on the
165
+ // 90° rotation), so an offset saved under a different size/edge/viewport can
166
+ // land the tab off-screen. Runs on mount and whenever edge/offset/size change.
167
+ $effect(() => {
168
+ // Track the reactive inputs so this re-runs when they change.
169
+ void edge;
170
+ void offset;
171
+ void btn;
172
+ if (dragOffset !== null) return;
173
+ clampOffset();
174
+ });
175
+
176
+ $effect(() => {
177
+ const onResize = () => {
178
+ if (dragOffset === null) clampOffset();
179
+ };
180
+ window.addEventListener('resize', onResize);
181
+ return () => window.removeEventListener('resize', onResize);
182
+ });
30
183
  </script>
31
184
 
32
- <button
33
- type="button"
185
+ <!-- Presentational drag container: holds the expand + close buttons as siblings
186
+ so neither interactive control is nested inside another (valid a11y). The
187
+ container carries the position/transform and the pointer-drag handlers. -->
188
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
189
+ <div
190
+ bind:this={btn}
34
191
  class={cn(
35
- 'fixed z-40 flex items-center justify-center gap-2 border border-border bg-background px-3 py-2 text-sm font-medium shadow-md hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
192
+ 'fixed z-40 flex max-w-[220px] items-center gap-2 border border-border bg-background px-2.5 py-1.5 shadow-md focus-within:ring-2 focus-within:ring-ring',
193
+ draggable && 'cursor-grab touch-none active:cursor-grabbing',
36
194
  positionClasses,
37
- isVertical && 'writing-mode-vertical',
38
195
  className
39
196
  )}
40
- {onclick}
41
- aria-label={`Expand ${title} panel`}
197
+ style={`transform: ${tabTransform}`}
198
+ onpointerdown={handlePointerDown}
199
+ onpointermove={draggable ? handlePointerMove : undefined}
200
+ onpointerup={draggable ? handlePointerUp : undefined}
201
+ onpointercancel={draggable ? handlePointerCancel : undefined}
42
202
  >
43
- <!-- Expand icon -->
44
- <svg
45
- xmlns="http://www.w3.org/2000/svg"
46
- width="14"
47
- height="14"
48
- viewBox="0 0 24 24"
49
- fill="none"
50
- stroke="currentColor"
51
- stroke-width="2"
52
- stroke-linecap="round"
53
- stroke-linejoin="round"
54
- class={cn(
55
- 'shrink-0',
56
- edge === 'left' && 'rotate-90',
57
- edge === 'right' && '-rotate-90',
58
- edge === 'top' && 'rotate-180',
59
- edge === 'bottom' && ''
60
- )}
203
+ <button
204
+ type="button"
205
+ aria-label={`Expand ${title} panel`}
206
+ class="flex min-w-0 flex-1 flex-col text-left leading-tight hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
207
+ onclick={draggable ? undefined : onclick}
208
+ onkeydown={handleExpandKeyDown}
61
209
  >
62
- <path d="m18 15-6-6-6 6" />
63
- </svg>
64
- <span class={cn(isVertical && 'rotate-180')}>{title}</span>
65
- </button>
66
-
67
- <style>
68
- .writing-mode-vertical {
69
- writing-mode: vertical-rl;
70
- text-orientation: mixed;
71
- }
72
- </style>
210
+ <span class="truncate text-sm font-semibold">{title}</span>
211
+ {#if subtitle}
212
+ <span class="truncate text-xs text-muted-foreground">{subtitle}</span>
213
+ {/if}
214
+ </button>
215
+
216
+ {#if !disableClose && onClose}
217
+ <button
218
+ data-no-drag
219
+ type="button"
220
+ class="relative ml-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground before:absolute before:-inset-2.5 before:content-[''] hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
221
+ aria-label="Close panel"
222
+ onclick={handleClose}
223
+ >
224
+ <svg
225
+ xmlns="http://www.w3.org/2000/svg"
226
+ width="14"
227
+ height="14"
228
+ viewBox="0 0 24 24"
229
+ fill="none"
230
+ stroke="currentColor"
231
+ stroke-width="2"
232
+ stroke-linecap="round"
233
+ stroke-linejoin="round"
234
+ >
235
+ <path d="M18 6 6 18" />
236
+ <path d="m6 6 12 12" />
237
+ </svg>
238
+ </button>
239
+ {/if}
240
+ </div>
@@ -2,10 +2,19 @@ import type { PanelEdge } from '@classic-homes/data-panel-core';
2
2
  interface Props {
3
3
  /** Tab label/title */
4
4
  title?: string;
5
+ /** Secondary line under the title */
6
+ subtitle?: string;
5
7
  /** Current edge position */
6
8
  edge: PanelEdge;
7
- /** Click handler to expand panel */
9
+ /** Expand the panel (tap/click/keyboard on the chip body) */
8
10
  onclick?: () => void;
11
+ /** Close the panel (chip close button) */
12
+ onClose?: () => void;
13
+ disableClose?: boolean;
14
+ /** Offset (px) of the tab along its edge from the centered position */
15
+ offset?: number;
16
+ /** Called with the new offset after the tab is dragged along its edge */
17
+ onOffsetChange?: (offset: number) => void;
9
18
  /** Additional class */
10
19
  class?: string;
11
20
  }
@@ -229,6 +229,7 @@
229
229
  <Sidebar
230
230
  navigation={effectiveNavigation}
231
231
  userRoles={user?.roles}
232
+ userPermissions={user?.permissions}
232
233
  variant={sidebarVariant}
233
234
  collapsed={!sidebarStore.isOpen && !isMobile}
234
235
  {isMobile}
@@ -15,6 +15,7 @@
15
15
  import type { Snippet } from 'svelte';
16
16
  import type { NavSection, NavItem, QuickLink } from '../../types/layout.js';
17
17
  import { cn } from '../../utils.js';
18
+ import { filterNavItems } from '../../utils/nav-access.js';
18
19
  import { sidebarStore } from '../../stores/sidebar.svelte.js';
19
20
  import LogoMain from '../LogoMain.svelte';
20
21
  import QuickLinks from './QuickLinks.svelte';
@@ -25,8 +26,17 @@
25
26
  interface Props {
26
27
  /** Navigation sections */
27
28
  navigation: NavSection[];
28
- /** User roles for filtering navigation items */
29
+ /**
30
+ * Every role the current user holds. Items are matched against the whole
31
+ * set, so a user with several roles sees the union of what they allow.
32
+ */
29
33
  userRoles?: string[];
34
+ /** Every permission the current user holds, for permission-gated items. */
35
+ userPermissions?: string[];
36
+ /** Require ALL of an item's `roles` rather than any (default: any). */
37
+ requireAllRoles?: boolean;
38
+ /** Require ALL of an item's `permissions` rather than any (default: any). */
39
+ requireAllPermissions?: boolean;
30
40
  /** Whether sidebar is collapsed (desktop) */
31
41
  collapsed?: boolean;
32
42
  /** Whether currently on mobile */
@@ -80,6 +90,9 @@
80
90
  let {
81
91
  navigation,
82
92
  userRoles,
93
+ userPermissions,
94
+ requireAllRoles = false,
95
+ requireAllPermissions = false,
83
96
  collapsed = false,
84
97
  isMobile = false,
85
98
  mobileOpen = false,
@@ -106,27 +119,19 @@
106
119
  class: className,
107
120
  }: Props = $props();
108
121
 
109
- // Helper to check if item is visible based on roles
110
- function isItemVisible(item: NavItem): boolean {
111
- if (!item.roles || item.roles.length === 0) return true;
112
- if (!userRoles || userRoles.length === 0) return false;
113
- return item.roles.some((role) => userRoles.includes(role));
114
- }
115
-
116
- // Recursively filter items based on roles
117
- function filterItems(items: NavItem[]): NavItem[] {
118
- return items.filter(isItemVisible).map((item) => ({
119
- ...item,
120
- children: item.children ? filterItems(item.children) : undefined,
121
- }));
122
- }
122
+ const accessContext = $derived({
123
+ userRoles,
124
+ userPermissions,
125
+ requireAllRoles,
126
+ requireAllPermissions,
127
+ });
123
128
 
124
- // Filter navigation based on user roles
129
+ // Filter navigation by what the user holds, dropping sections left empty.
125
130
  const roleFilteredNavigation = $derived(
126
131
  navigation
127
132
  .map((section) => ({
128
133
  ...section,
129
- items: filterItems(section.items),
134
+ items: filterNavItems(section.items, accessContext),
130
135
  }))
131
136
  .filter((section) => section.items.length > 0)
132
137
  );
@@ -16,8 +16,17 @@ import type { NavSection, NavItem, QuickLink } from '../../types/layout.js';
16
16
  interface Props {
17
17
  /** Navigation sections */
18
18
  navigation: NavSection[];
19
- /** User roles for filtering navigation items */
19
+ /**
20
+ * Every role the current user holds. Items are matched against the whole
21
+ * set, so a user with several roles sees the union of what they allow.
22
+ */
20
23
  userRoles?: string[];
24
+ /** Every permission the current user holds, for permission-gated items. */
25
+ userPermissions?: string[];
26
+ /** Require ALL of an item's `roles` rather than any (default: any). */
27
+ requireAllRoles?: boolean;
28
+ /** Require ALL of an item's `permissions` rather than any (default: any). */
29
+ requireAllPermissions?: boolean;
21
30
  /** Whether sidebar is collapsed (desktop) */
22
31
  collapsed?: boolean;
23
32
  /** Whether currently on mobile */
@@ -243,7 +243,10 @@ export declare const emailChangeSchema: z.ZodObject<{
243
243
  }>;
244
244
  export type EmailChangeFormData = z.infer<typeof emailChangeSchema>;
245
245
  /**
246
- * User schema
246
+ * User schema — for validating a user payload from an API.
247
+ *
248
+ * Distinct from the layout `User` in `types/layout.ts`, which is a display
249
+ * view-model for the sidebar and avatar. This one describes data on the wire.
247
250
  */
248
251
  export declare const userSchema: z.ZodObject<{
249
252
  id: z.ZodString;
@@ -262,9 +265,9 @@ export declare const userSchema: z.ZodObject<{
262
265
  email: string;
263
266
  username?: string | undefined;
264
267
  roles?: string[] | undefined;
268
+ permissions?: string[] | undefined;
265
269
  fullName?: string | undefined;
266
270
  avatarUrl?: string | null | undefined;
267
- permissions?: string[] | undefined;
268
271
  mfaEnabled?: boolean | undefined;
269
272
  emailVerified?: boolean | undefined;
270
273
  createdAt?: string | undefined;
@@ -274,15 +277,22 @@ export declare const userSchema: z.ZodObject<{
274
277
  email: string;
275
278
  username?: string | undefined;
276
279
  roles?: string[] | undefined;
280
+ permissions?: string[] | undefined;
277
281
  fullName?: string | undefined;
278
282
  avatarUrl?: string | null | undefined;
279
- permissions?: string[] | undefined;
280
283
  mfaEnabled?: boolean | undefined;
281
284
  emailVerified?: boolean | undefined;
282
285
  createdAt?: string | undefined;
283
286
  updatedAt?: string | undefined;
284
287
  }>;
285
- export type User = z.infer<typeof userSchema>;
288
+ /**
289
+ * A user payload as validated by {@link userSchema}.
290
+ *
291
+ * Named `AuthUser` to keep the package's `User` unambiguous — that name belongs
292
+ * to the layout view-model. Previously exported as `User`, where it collided
293
+ * with the layout type and lost, making it unreachable from the package root.
294
+ */
295
+ export type AuthUser = z.infer<typeof userSchema>;
286
296
  /**
287
297
  * Auth tokens schema
288
298
  */
@@ -324,9 +334,9 @@ export declare const loginResponseSchema: z.ZodObject<{
324
334
  email: string;
325
335
  username?: string | undefined;
326
336
  roles?: string[] | undefined;
337
+ permissions?: string[] | undefined;
327
338
  fullName?: string | undefined;
328
339
  avatarUrl?: string | null | undefined;
329
- permissions?: string[] | undefined;
330
340
  mfaEnabled?: boolean | undefined;
331
341
  emailVerified?: boolean | undefined;
332
342
  createdAt?: string | undefined;
@@ -336,9 +346,9 @@ export declare const loginResponseSchema: z.ZodObject<{
336
346
  email: string;
337
347
  username?: string | undefined;
338
348
  roles?: string[] | undefined;
349
+ permissions?: string[] | undefined;
339
350
  fullName?: string | undefined;
340
351
  avatarUrl?: string | null | undefined;
341
- permissions?: string[] | undefined;
342
352
  mfaEnabled?: boolean | undefined;
343
353
  emailVerified?: boolean | undefined;
344
354
  createdAt?: string | undefined;
@@ -368,9 +378,9 @@ export declare const loginResponseSchema: z.ZodObject<{
368
378
  email: string;
369
379
  username?: string | undefined;
370
380
  roles?: string[] | undefined;
381
+ permissions?: string[] | undefined;
371
382
  fullName?: string | undefined;
372
383
  avatarUrl?: string | null | undefined;
373
- permissions?: string[] | undefined;
374
384
  mfaEnabled?: boolean | undefined;
375
385
  emailVerified?: boolean | undefined;
376
386
  createdAt?: string | undefined;
@@ -390,9 +400,9 @@ export declare const loginResponseSchema: z.ZodObject<{
390
400
  email: string;
391
401
  username?: string | undefined;
392
402
  roles?: string[] | undefined;
403
+ permissions?: string[] | undefined;
393
404
  fullName?: string | undefined;
394
405
  avatarUrl?: string | null | undefined;
395
- permissions?: string[] | undefined;
396
406
  mfaEnabled?: boolean | undefined;
397
407
  emailVerified?: boolean | undefined;
398
408
  createdAt?: string | undefined;
@@ -181,7 +181,10 @@ export const emailChangeSchema = z.object({
181
181
  // API Response Schemas (for validating API responses)
182
182
  // ============================================================================
183
183
  /**
184
- * User schema
184
+ * User schema — for validating a user payload from an API.
185
+ *
186
+ * Distinct from the layout `User` in `types/layout.ts`, which is a display
187
+ * view-model for the sidebar and avatar. This one describes data on the wire.
185
188
  */
186
189
  export const userSchema = z.object({
187
190
  id: z.string(),
@@ -9,7 +9,7 @@ export { emailSchema, passwordSchema, loginPasswordSchema, usernameSchema, fullN
9
9
  export { loginSchema, registerSchema, forgotPasswordSchema, resetPasswordSchema, changePasswordSchema, mfaVerifySchema, mfaSetupSchema, profileUpdateSchema, emailChangeSchema, } from './auth.js';
10
10
  export type { LoginFormData, RegisterFormData, ForgotPasswordFormData, ResetPasswordFormData, ChangePasswordFormData, MfaVerifyFormData, MfaSetupFormData, ProfileUpdateFormData, EmailChangeFormData, } from './auth.js';
11
11
  export { userSchema, authTokensSchema, loginResponseSchema } from './auth.js';
12
- export type { User, AuthTokens, LoginResponse } from './auth.js';
12
+ export type { AuthUser, AuthTokens, LoginResponse } from './auth.js';
13
13
  export { nonEmptyString, trimmedString, optionalString, urlSchema, optionalUrlSchema, slugSchema, } from './common.js';
14
14
  export { positiveInt, nonNegativeInt, percentageSchema, priceSchema, numericString, } from './common.js';
15
15
  export { isoDateSchema, dateStringSchema, timeStringSchema, futureDateSchema, pastDateSchema, } from './common.js';
@@ -22,6 +22,7 @@ export declare function createDataPanelStore(options?: DataPanelStoreOptions): {
22
22
  readonly detachedSize: Size;
23
23
  readonly pinnedSize: number;
24
24
  readonly cardSnapIndex: number;
25
+ readonly tabOffset: number;
25
26
  setMode: (newMode: PanelMode) => void;
26
27
  setVariant: (newVariant: PanelVariant) => void;
27
28
  setEdge: (newEdge: PanelEdge) => void;
@@ -31,6 +32,7 @@ export declare function createDataPanelStore(options?: DataPanelStoreOptions): {
31
32
  setDetachedSize: (size: Size) => void;
32
33
  setPinnedSize: (size: number) => void;
33
34
  setCardSnapIndex: (index: number) => void;
35
+ setTabOffset: (offset: number) => void;
34
36
  reset: () => void;
35
37
  getState: () => DataPanelState;
36
38
  };
@@ -22,6 +22,7 @@ export function createDataPanelStore(options = {}) {
22
22
  let detachedSize = $state(merged.detachedSize);
23
23
  let pinnedSize = $state(merged.pinnedSize);
24
24
  let cardSnapIndex = $state(merged.cardSnapIndex);
25
+ let tabOffset = $state(merged.tabOffset);
25
26
  // Resolved constraints
26
27
  const resolvedConstraints = { ...DEFAULT_CONSTRAINTS, ...constraints };
27
28
  // Debounced save for persistence
@@ -42,6 +43,7 @@ export function createDataPanelStore(options = {}) {
42
43
  detachedSize,
43
44
  pinnedSize,
44
45
  cardSnapIndex,
46
+ tabOffset,
45
47
  });
46
48
  saveTimeout = null;
47
49
  }, 300);
@@ -116,6 +118,12 @@ export function createDataPanelStore(options = {}) {
116
118
  cardSnapIndex = index;
117
119
  debouncedSave();
118
120
  }
121
+ function setTabOffset(offset) {
122
+ if (tabOffset === offset)
123
+ return;
124
+ tabOffset = offset;
125
+ debouncedSave();
126
+ }
119
127
  function reset() {
120
128
  mode = DEFAULT_PANEL_STATE.mode;
121
129
  variant = DEFAULT_PANEL_STATE.variant;
@@ -125,6 +133,7 @@ export function createDataPanelStore(options = {}) {
125
133
  detachedSize = DEFAULT_PANEL_STATE.detachedSize;
126
134
  pinnedSize = DEFAULT_PANEL_STATE.pinnedSize;
127
135
  cardSnapIndex = DEFAULT_PANEL_STATE.cardSnapIndex;
136
+ tabOffset = DEFAULT_PANEL_STATE.tabOffset;
128
137
  debouncedSave();
129
138
  }
130
139
  // Getters for current state (readonly)
@@ -138,6 +147,7 @@ export function createDataPanelStore(options = {}) {
138
147
  detachedSize,
139
148
  pinnedSize,
140
149
  cardSnapIndex,
150
+ tabOffset,
141
151
  };
142
152
  }
143
153
  return {
@@ -166,6 +176,9 @@ export function createDataPanelStore(options = {}) {
166
176
  get cardSnapIndex() {
167
177
  return cardSnapIndex;
168
178
  },
179
+ get tabOffset() {
180
+ return tabOffset;
181
+ },
169
182
  // Actions
170
183
  setMode,
171
184
  setVariant,
@@ -176,6 +189,7 @@ export function createDataPanelStore(options = {}) {
176
189
  setDetachedSize,
177
190
  setPinnedSize,
178
191
  setCardSnapIndex,
192
+ setTabOffset,
179
193
  reset,
180
194
  getState,
181
195
  };
@@ -122,8 +122,13 @@ declare class SidebarStore {
122
122
  getPinnedIds(): string[];
123
123
  /**
124
124
  * Compute which sections should be initially expanded based on user roles.
125
- * Sections with expandForRoles that match user roles will be auto-expanded.
126
- * Falls back to section.expanded or default behavior.
125
+ *
126
+ * A section listing `expandForRoles` expands for a user holding ANY of them.
127
+ * Sections without it fall back to the default: expanded unless explicitly
128
+ * collapsed.
129
+ *
130
+ * This is a UX affordance, not access control — it decides what starts open,
131
+ * not what is visible. Filtering is `filterNavItems`.
127
132
  */
128
133
  computeInitialExpansions(sections: NavSection[], userRoles?: string[]): string[];
129
134
  /**