@lyeve-labs/ui-kit 0.13.1 → 0.15.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.
@@ -0,0 +1,131 @@
1
+ <script lang="ts">
2
+ /**
3
+ * The signed-in identity and the actions that belong to it, at the end of the
4
+ * app header.
5
+ *
6
+ * Where it lives is the point. The admin and the customer portal both put it
7
+ * in the bottom left corner of the sidebar and the ops console put it in the
8
+ * header, so the same account block was in two places depending on which of
9
+ * our own products you were looking at. The sidebar is also the worst of the
10
+ * two: it is already full height, so opening a menu in its last row pushes
11
+ * the last entry - Sign out, every time - past the bottom edge of the window.
12
+ * The header has room below it by construction.
13
+ *
14
+ * A native `details`, not a menu assembled from buttons. The panel holds
15
+ * links and a form post, and both have to keep working when hydration fails
16
+ * or never runs, which is exactly the moment somebody most needs to be able
17
+ * to sign out.
18
+ */
19
+ import type { Snippet } from 'svelte';
20
+ import { ChevronDown } from '@lucide/svelte';
21
+ import type { AccentTone } from '../internal/tone.js';
22
+
23
+ interface Props {
24
+ /** The line people recognise themselves by: a display name, or the email. */
25
+ name: string;
26
+ /** Under it. The email when the name is a name, otherwise a role or a plan. */
27
+ secondary?: string;
28
+ /**
29
+ * The avatar's letters. Derived from `name` when absent, which is right far
30
+ * more often than it is wrong.
31
+ */
32
+ initials?: string;
33
+ /** The avatar's accent. Follows the app, not the person. */
34
+ tone?: AccentTone;
35
+ class?: string;
36
+ /** The menu itself: links, and the form that ends the session. */
37
+ children: Snippet;
38
+ }
39
+
40
+ let {
41
+ name,
42
+ secondary = undefined,
43
+ initials = undefined,
44
+ tone = 'brand',
45
+ class: klass = '',
46
+ children,
47
+ }: Props = $props();
48
+
49
+ // Splitting on the @ as well as on spaces: an account with no display name
50
+ // shows its email here, and "ka" reads as a person where "k@" reads as a
51
+ // rendering bug.
52
+ const letters = $derived(
53
+ initials ??
54
+ (name || '?')
55
+ .split(/[\s@._-]+/)
56
+ .filter(Boolean)
57
+ .slice(0, 2)
58
+ .map((part) => part[0]?.toUpperCase() ?? '')
59
+ .join(''),
60
+ );
61
+
62
+ const AVATAR_TONE: Record<AccentTone, string> = {
63
+ neutral: 'bg-surface-2 text-fg',
64
+ brand: 'bg-brand/15 text-brand',
65
+ success: 'bg-success/15 text-success',
66
+ warn: 'bg-warn/15 text-warn',
67
+ danger: 'bg-danger/15 text-danger',
68
+ violet: 'bg-violet/15 text-violet',
69
+ };
70
+
71
+ let root = $state<HTMLDetailsElement | null>(null);
72
+
73
+ /**
74
+ * A `details` closes on its own summary and on nothing else, so without this
75
+ * the panel stays open behind whatever the reader does next. Escape and an
76
+ * outside click are both what a menu owes; they are enhancement, and the
77
+ * disclosure still works without either.
78
+ */
79
+ $effect(() => {
80
+ function onPointerDown(event: MouseEvent) {
81
+ if (!root?.open) return;
82
+ if (root.contains(event.target as Node)) return;
83
+ root.open = false;
84
+ }
85
+ function onKeydown(event: KeyboardEvent) {
86
+ if (event.key !== 'Escape' || !root?.open) return;
87
+ root.open = false;
88
+ root.querySelector('summary')?.focus();
89
+ }
90
+ document.addEventListener('click', onPointerDown, { capture: true });
91
+ document.addEventListener('keydown', onKeydown);
92
+ return () => {
93
+ document.removeEventListener('click', onPointerDown, { capture: true });
94
+ document.removeEventListener('keydown', onKeydown);
95
+ };
96
+ });
97
+ </script>
98
+
99
+ <details bind:this={root} class="group relative {klass}">
100
+ <summary
101
+ class="flex cursor-pointer list-none items-center gap-2 rounded-lg px-2 py-1.5 outline-none transition-colors duration-150 hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
102
+ >
103
+ <span
104
+ class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-semibold {AVATAR_TONE[
105
+ tone
106
+ ]}"
107
+ aria-hidden="true"
108
+ >
109
+ {letters}
110
+ </span>
111
+ <span class="hidden min-w-0 max-w-[10rem] truncate text-sm text-fg sm:block">{name}</span>
112
+ <ChevronDown
113
+ size={14}
114
+ class="shrink-0 text-faint transition-transform duration-150 group-open:rotate-180"
115
+ />
116
+ </summary>
117
+
118
+ <!-- end-0: the panel hangs from the header's trailing edge, which is the one
119
+ edge it can grow from without leaving the window. The surface and the
120
+ rule are the card treatment, because lifted off the header it is a
121
+ surface of its own. -->
122
+ <div
123
+ class="absolute end-0 top-full z-50 mt-2 flex w-64 flex-col gap-1 rounded-xl border border-line bg-surface p-2 shadow-2xl"
124
+ >
125
+ <div class="border-b border-line px-2 pb-2">
126
+ <p class="truncate text-sm font-medium text-fg">{name}</p>
127
+ {#if secondary}<p class="truncate text-xs text-muted">{secondary}</p>{/if}
128
+ </div>
129
+ {@render children()}
130
+ </div>
131
+ </details>
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The signed-in identity and the actions that belong to it, at the end of the
3
+ * app header.
4
+ *
5
+ * Where it lives is the point. The admin and the customer portal both put it
6
+ * in the bottom left corner of the sidebar and the ops console put it in the
7
+ * header, so the same account block was in two places depending on which of
8
+ * our own products you were looking at. The sidebar is also the worst of the
9
+ * two: it is already full height, so opening a menu in its last row pushes
10
+ * the last entry - Sign out, every time - past the bottom edge of the window.
11
+ * The header has room below it by construction.
12
+ *
13
+ * A native `details`, not a menu assembled from buttons. The panel holds
14
+ * links and a form post, and both have to keep working when hydration fails
15
+ * or never runs, which is exactly the moment somebody most needs to be able
16
+ * to sign out.
17
+ */
18
+ import type { Snippet } from 'svelte';
19
+ import type { AccentTone } from '../internal/tone.js';
20
+ interface Props {
21
+ /** The line people recognise themselves by: a display name, or the email. */
22
+ name: string;
23
+ /** Under it. The email when the name is a name, otherwise a role or a plan. */
24
+ secondary?: string;
25
+ /**
26
+ * The avatar's letters. Derived from `name` when absent, which is right far
27
+ * more often than it is wrong.
28
+ */
29
+ initials?: string;
30
+ /** The avatar's accent. Follows the app, not the person. */
31
+ tone?: AccentTone;
32
+ class?: string;
33
+ /** The menu itself: links, and the form that ends the session. */
34
+ children: Snippet;
35
+ }
36
+ declare const AccountMenu: import("svelte").Component<Props, {}, "">;
37
+ type AccountMenu = ReturnType<typeof AccountMenu>;
38
+ export default AccountMenu;
@@ -0,0 +1,190 @@
1
+ <script lang="ts">
2
+ /**
3
+ * The authed application frame: the sidebar, the header bar and the content
4
+ * column, owned once so three apps cannot each invent their own.
5
+ *
6
+ * They did. The admin, the customer portal and the ops console each hand
7
+ * rolled this shell, and no two agreed: the sidebar was 224px in one and
8
+ * 240px in the other two, opaque in two and 30% translucent in the third,
9
+ * built from the kit's SidebarNav in one and from inline anchors in the
10
+ * others. Two of the three had no header at all above md:, so the current
11
+ * page had no name on screen and the account block sat in the bottom left
12
+ * corner of the sidebar in one shape and in a header dropdown in another.
13
+ * Every one of those was a defensible local choice and together they read as
14
+ * three products.
15
+ *
16
+ * The frame renders `main` and the drawer's dialog role. It renders no `nav`
17
+ * and no heading: the nav comes from the caller's SidebarNav, which is the
18
+ * only navigation landmark in the page, and the page's own PageShell owns the
19
+ * one `h1`. The section name here is a span for that reason - a second `h1`
20
+ * in the header makes a heading query answer with two elements and a screen
21
+ * reader announce the page name twice.
22
+ */
23
+ import type { Snippet } from 'svelte';
24
+ import { Menu } from '@lucide/svelte';
25
+ import { overlay } from '../internal/overlay.js';
26
+ import { APP_BRAND, APP_HEADER, APP_SIDEBAR, APP_SIDEBAR_BAND } from '../internal/layout.js';
27
+
28
+ interface Props {
29
+ /** The current section, shown in the header. Not a heading: the page owns its h1. */
30
+ section?: string;
31
+ /** Bindable so the app router can close the drawer after a navigation. */
32
+ navOpen?: boolean;
33
+ /** The sidebar landmark's accessible name. */
34
+ sidebarLabel?: string;
35
+ /** The drawer's accessible name, below md: where the sidebar is a dialog. */
36
+ drawerLabel?: string;
37
+ /** The product mark, in the sidebar's brand row. */
38
+ brand?: Snippet;
39
+ /** The navigation itself. A SidebarNav, given `class="min-h-0 flex-1"`. */
40
+ nav?: Snippet;
41
+ /** A status band at the foot of the sidebar. Not the account: that is header work. */
42
+ sidebarFooter?: Snippet;
43
+ /** The right end of the header bar. The theme toggle and the account menu live here. */
44
+ headerActions?: Snippet;
45
+ class?: string;
46
+ children: Snippet;
47
+ }
48
+
49
+ let {
50
+ section = undefined,
51
+ navOpen = $bindable(false),
52
+ sidebarLabel = 'Sidebar',
53
+ drawerLabel = 'Navigation',
54
+ brand,
55
+ nav,
56
+ sidebarFooter,
57
+ headerActions,
58
+ class: klass = '',
59
+ children,
60
+ }: Props = $props();
61
+
62
+ /**
63
+ * Desktop until the browser says otherwise. The server cannot know the
64
+ * viewport, and a sidebar rendered inert on the server is unreachable to
65
+ * anyone whose JavaScript never arrives.
66
+ */
67
+ let isMobile = $state(false);
68
+ const drawerOpen = $derived(isMobile && navOpen);
69
+
70
+ $effect(() => {
71
+ const mq = window.matchMedia('(max-width: 767px)');
72
+ const sync = () => {
73
+ isMobile = mq.matches;
74
+ if (!mq.matches) navOpen = false;
75
+ };
76
+ sync();
77
+ mq.addEventListener('change', sync);
78
+ return () => mq.removeEventListener('change', sync);
79
+ });
80
+
81
+ /** The backdrop is the pointer's way out of the drawer. A keyboard has none. */
82
+ function onWindowKeydown(event: KeyboardEvent) {
83
+ if (event.key === 'Escape' && navOpen) navOpen = false;
84
+ }
85
+ </script>
86
+
87
+ <svelte:window onkeydown={onWindowKeydown} />
88
+
89
+ <div class="flex h-screen {klass}">
90
+ <!-- First focusable thing in the document. Without it a keyboard reader tabs
91
+ the whole sidebar again on every page. -->
92
+ <a
93
+ href="#content"
94
+ class="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-50 focus:rounded-lg focus:bg-surface focus:px-3 focus:py-2 focus:text-sm focus:text-fg focus:outline-none focus:ring-2 focus:ring-brand"
95
+ >
96
+ Skip to content
97
+ </a>
98
+
99
+ {#if drawerOpen}
100
+ <!-- Follows Drawer and Modal: no palette token reads as a dimmer in both
101
+ themes. Closing on it is the gesture people expect, and it stops a tap
102
+ reaching the page under the open drawer. -->
103
+ <div
104
+ class="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
105
+ role="presentation"
106
+ onclick={() => (navOpen = false)}
107
+ ></div>
108
+ {/if}
109
+
110
+ <!--
111
+ One aside at every width. Below md: it sits inside a positioned dialog; above
112
+ it, that wrapper is the column it has always been. Rendering a second copy
113
+ for the drawer would put every nav link in the page twice, which is what a
114
+ strict-mode locator trips on and what a screen reader reads out.
115
+
116
+ The dialog role is on the wrapper and not on the aside: an aside is a
117
+ complementary landmark, and the a11y gate rejects a non-interactive element
118
+ taking an interactive role. use:overlay is what makes aria-modal true rather
119
+ than merely claimed - it moves focus in, keeps Tab inside, and hands focus
120
+ back to the hamburger on close.
121
+ -->
122
+ {#if drawerOpen}
123
+ <div
124
+ class="fixed inset-y-0 start-0 z-50 flex shadow-2xl"
125
+ role="dialog"
126
+ aria-modal="true"
127
+ aria-label={drawerLabel}
128
+ use:overlay
129
+ >
130
+ {@render sidebar(false)}
131
+ </div>
132
+ {:else}
133
+ <div class="hidden md:flex">
134
+ {@render sidebar(isMobile)}
135
+ </div>
136
+ {/if}
137
+
138
+ <div class="flex min-w-0 flex-1 flex-col">
139
+ <header class={APP_HEADER}>
140
+ <div class="flex min-w-0 flex-1 items-center gap-2">
141
+ {#if isMobile}
142
+ <!-- 44px square, the smallest tap target SC 2.5.5 accepts. The -ms-2
143
+ pulls it back to the header's own gutter so the row it starts
144
+ still lines up with the page content below. -->
145
+ <button
146
+ type="button"
147
+ class="-ms-2 flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-muted outline-none transition-colors duration-150 hover:bg-surface-2 hover:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
148
+ aria-label={navOpen ? 'Close navigation' : 'Open navigation'}
149
+ aria-expanded={navOpen}
150
+ onclick={() => (navOpen = !navOpen)}
151
+ >
152
+ <Menu size={20} />
153
+ </button>
154
+ {/if}
155
+ {#if section}
156
+ <span data-testid="app-section" class="truncate text-sm font-semibold text-fg">
157
+ {section}
158
+ </span>
159
+ {/if}
160
+ </div>
161
+
162
+ {#if headerActions}
163
+ <div class="flex shrink-0 items-center gap-1">{@render headerActions()}</div>
164
+ {/if}
165
+ </header>
166
+
167
+ <main id="content" class="min-w-0 flex-1 overflow-auto bg-ink">
168
+ {@render children()}
169
+ </main>
170
+ </div>
171
+ </div>
172
+
173
+ {#snippet sidebar(hidden: boolean)}
174
+ <aside
175
+ aria-label={sidebarLabel}
176
+ class="{APP_SIDEBAR} flex"
177
+ inert={hidden}
178
+ aria-hidden={hidden ? 'true' : undefined}
179
+ >
180
+ {#if brand}
181
+ <div class={APP_BRAND}>{@render brand()}</div>
182
+ {/if}
183
+ {#if nav}
184
+ {@render nav()}
185
+ {/if}
186
+ {#if sidebarFooter}
187
+ <div class={APP_SIDEBAR_BAND}>{@render sidebarFooter()}</div>
188
+ {/if}
189
+ </aside>
190
+ {/snippet}
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The authed application frame: the sidebar, the header bar and the content
3
+ * column, owned once so three apps cannot each invent their own.
4
+ *
5
+ * They did. The admin, the customer portal and the ops console each hand
6
+ * rolled this shell, and no two agreed: the sidebar was 224px in one and
7
+ * 240px in the other two, opaque in two and 30% translucent in the third,
8
+ * built from the kit's SidebarNav in one and from inline anchors in the
9
+ * others. Two of the three had no header at all above md:, so the current
10
+ * page had no name on screen and the account block sat in the bottom left
11
+ * corner of the sidebar in one shape and in a header dropdown in another.
12
+ * Every one of those was a defensible local choice and together they read as
13
+ * three products.
14
+ *
15
+ * The frame renders `main` and the drawer's dialog role. It renders no `nav`
16
+ * and no heading: the nav comes from the caller's SidebarNav, which is the
17
+ * only navigation landmark in the page, and the page's own PageShell owns the
18
+ * one `h1`. The section name here is a span for that reason - a second `h1`
19
+ * in the header makes a heading query answer with two elements and a screen
20
+ * reader announce the page name twice.
21
+ */
22
+ import type { Snippet } from 'svelte';
23
+ interface Props {
24
+ /** The current section, shown in the header. Not a heading: the page owns its h1. */
25
+ section?: string;
26
+ /** Bindable so the app router can close the drawer after a navigation. */
27
+ navOpen?: boolean;
28
+ /** The sidebar landmark's accessible name. */
29
+ sidebarLabel?: string;
30
+ /** The drawer's accessible name, below md: where the sidebar is a dialog. */
31
+ drawerLabel?: string;
32
+ /** The product mark, in the sidebar's brand row. */
33
+ brand?: Snippet;
34
+ /** The navigation itself. A SidebarNav, given `class="min-h-0 flex-1"`. */
35
+ nav?: Snippet;
36
+ /** A status band at the foot of the sidebar. Not the account: that is header work. */
37
+ sidebarFooter?: Snippet;
38
+ /** The right end of the header bar. The theme toggle and the account menu live here. */
39
+ headerActions?: Snippet;
40
+ class?: string;
41
+ children: Snippet;
42
+ }
43
+ declare const AppShell: import("svelte").Component<Props, {}, "navOpen">;
44
+ type AppShell = ReturnType<typeof AppShell>;
45
+ export default AppShell;
@@ -26,9 +26,21 @@
26
26
  width?: PageWidth;
27
27
  /**
28
28
  * A full-height page that manages its own scrolling, for instance a split
29
- * pane or a canvas. No gutter, no cap, and the title renders compact.
29
+ * pane or a canvas. The content loses the gutter and the cap so the pane
30
+ * can reach the edges; the title row keeps both.
30
31
  */
31
32
  fill?: boolean;
33
+ /**
34
+ * Drop the title to body size and hide the description, for a page with
35
+ * genuinely no room for a heading.
36
+ *
37
+ * It used to be implied by `fill`, which made the two pages that own the
38
+ * viewport the only two in the app whose name rendered at body size against
39
+ * the window edge. Owning the viewport is a statement about the content
40
+ * pane, not about the heading, so a caller that wants the smaller title now
41
+ * asks for it.
42
+ */
43
+ compact?: boolean;
32
44
  /** Rendered above the title at one fixed distance. */
33
45
  breadcrumb?: Snippet;
34
46
  /** Right-aligned controls in the title row. */
@@ -42,6 +54,7 @@
42
54
  description = undefined,
43
55
  width = 'default',
44
56
  fill = false,
57
+ compact = false,
45
58
  breadcrumb,
46
59
  actions,
47
60
  class: klass = '',
@@ -49,11 +62,11 @@
49
62
  }: Props = $props();
50
63
 
51
64
  /**
52
- * A fill page owns the viewport instead of sitting in it: no gutter, no cap,
53
- * and the height the title row leaves goes to the content, so a split pane
54
- * scrolls inside the page rather than scrolling the page. Two pages carry a
55
- * documented waiver against their app's own layout lint for exactly this
56
- * shape, which is the argument for the shell supporting it.
65
+ * A fill page owns the viewport instead of sitting in it: the content takes
66
+ * no cap and reaches the edges, and the height the title row leaves goes to
67
+ * it, so a split pane scrolls inside the page rather than scrolling the page.
68
+ * Two pages carry a documented waiver against their app's own layout lint for
69
+ * exactly this shape, which is the argument for the shell supporting it.
57
70
  *
58
71
  * `min-h-0` is load bearing on the column: a flex item refuses to shrink
59
72
  * below its content by default, so without it the pane runs past the bottom
@@ -65,6 +78,17 @@
65
78
  : `${PAGE_PAD} ${PAGE_WIDTH[width]} ${PAGE_STACK}`,
66
79
  );
67
80
 
81
+ /**
82
+ * The gutter the title row keeps when the content gives it up.
83
+ *
84
+ * The frame supplies it on an ordinary page, so only a fill page states it
85
+ * here, and it composes from the same tokens rather than a second spelling:
86
+ * a fill page's title lines up with every other page's to the pixel. Only
87
+ * the top edge is padded, because the frame's own gap owns the distance down
88
+ * to the content.
89
+ */
90
+ const headerPad = $derived(fill ? 'px-page-x pt-page-y' : '');
91
+
68
92
  /** The content stack. On a fill page it also takes the leftover height. */
69
93
  const content = $derived(fill ? `${PAGE_STACK} min-h-0 flex-1` : PAGE_STACK);
70
94
  </script>
@@ -72,11 +96,11 @@
72
96
  <div class="{frame} {klass}">
73
97
  <!-- The breadcrumb and the title are one group, so the distance between them
74
98
  is fixed here and does not change with whether a description is set. -->
75
- <div class="flex flex-col gap-2">
99
+ <div class="flex flex-col gap-2 {headerPad}">
76
100
  {#if breadcrumb}{@render breadcrumb()}{/if}
77
101
  <!-- flush: the shell's own section stack supplies the gap below the title,
78
102
  so the header must not add a second one. -->
79
- <PageHeader {title} {description} {actions} compact={fill} flush />
103
+ <PageHeader {title} {description} {actions} {compact} flush />
80
104
  </div>
81
105
 
82
106
  <div class={content}>
@@ -23,9 +23,21 @@ interface Props {
23
23
  width?: PageWidth;
24
24
  /**
25
25
  * A full-height page that manages its own scrolling, for instance a split
26
- * pane or a canvas. No gutter, no cap, and the title renders compact.
26
+ * pane or a canvas. The content loses the gutter and the cap so the pane
27
+ * can reach the edges; the title row keeps both.
27
28
  */
28
29
  fill?: boolean;
30
+ /**
31
+ * Drop the title to body size and hide the description, for a page with
32
+ * genuinely no room for a heading.
33
+ *
34
+ * It used to be implied by `fill`, which made the two pages that own the
35
+ * viewport the only two in the app whose name rendered at body size against
36
+ * the window edge. Owning the viewport is a statement about the content
37
+ * pane, not about the heading, so a caller that wants the smaller title now
38
+ * asks for it.
39
+ */
40
+ compact?: boolean;
29
41
  /** Rendered above the title at one fixed distance. */
30
42
  breadcrumb?: Snippet;
31
43
  /** Right-aligned controls in the title row. */
package/dist/index.d.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
  export { default as Card } from './components/Card.svelte';
10
10
  export { default as Panel } from './components/Panel.svelte';
11
+ export { default as AppShell } from './components/AppShell.svelte';
11
12
  export { default as PageShell } from './components/PageShell.svelte';
12
13
  export { default as PageHeader } from './components/PageHeader.svelte';
13
14
  export { default as SectionHeading } from './components/SectionHeading.svelte';
@@ -55,6 +56,7 @@ export { default as Pagination } from './components/Pagination.svelte';
55
56
  export { default as StepIndicator } from './components/StepIndicator.svelte';
56
57
  export { default as Dropdown } from './components/Dropdown.svelte';
57
58
  export { default as SidebarNav } from './components/SidebarNav.svelte';
59
+ export { default as AccountMenu } from './components/AccountMenu.svelte';
58
60
  export type { NavNode, NavTree } from './internal/nav-tree.js';
59
61
  export { default as Modal } from './components/Modal.svelte';
60
62
  export { default as Drawer } from './components/Drawer.svelte';
@@ -84,4 +86,4 @@ export { openDialog, closeDialog, dismissDialog, dismissAllDialogs, confirm, set
84
86
  export type { DialogOptions, DialogEntry, DialogSize } from './components/dialog/types.js';
85
87
  export { cn, type ClassValue } from './utils/cn.js';
86
88
  export { getTheme, setTheme, toggleTheme, themeBootScript, type Theme } from './utils/theme.js';
87
- export declare const VERSION = "0.13.1";
89
+ export declare const VERSION = "0.15.0";
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@
9
9
  // ── Layout & structure ─────────────────────────────────────────────────────
10
10
  export { default as Card } from './components/Card.svelte';
11
11
  export { default as Panel } from './components/Panel.svelte';
12
+ export { default as AppShell } from './components/AppShell.svelte';
12
13
  export { default as PageShell } from './components/PageShell.svelte';
13
14
  export { default as PageHeader } from './components/PageHeader.svelte';
14
15
  export { default as SectionHeading } from './components/SectionHeading.svelte';
@@ -54,6 +55,7 @@ export { default as Pagination } from './components/Pagination.svelte';
54
55
  export { default as StepIndicator } from './components/StepIndicator.svelte';
55
56
  export { default as Dropdown } from './components/Dropdown.svelte';
56
57
  export { default as SidebarNav } from './components/SidebarNav.svelte';
58
+ export { default as AccountMenu } from './components/AccountMenu.svelte';
57
59
  // ── Overlays ───────────────────────────────────────────────────────────────
58
60
  export { default as Modal } from './components/Modal.svelte';
59
61
  export { default as Drawer } from './components/Drawer.svelte';
@@ -90,4 +92,4 @@ export { getTheme, setTheme, toggleTheme, themeBootScript } from './utils/theme.
90
92
  // ── Version ────────────────────────────────────────────────────────────────
91
93
  // Generated from package.json by `pnpm version:sync`. Bump package.json, never
92
94
  // this line; the build and the test suite fail when the two disagree.
93
- export const VERSION = '0.13.1';
95
+ export const VERSION = '0.15.0';
@@ -117,3 +117,30 @@ export declare const MODAL_PAD = "px-card py-card-sm";
117
117
  * the caller is already writing.
118
118
  */
119
119
  export declare function sectionHeading(level: 2 | 3): string;
120
+ /**
121
+ * The authed app frame, stated once for every app that has one.
122
+ *
123
+ * Three apps hand rolled this and none of the three agreed. The sidebar was
124
+ * `w-56` in the admin and `w-60` in the other two, `bg-surface` in two and
125
+ * `bg-surface/30` in the third; the header existed in one and not in the other
126
+ * two. None of that was visible from inside any one app, which is why it went
127
+ * on being three shells for as long as it did.
128
+ *
129
+ * `border-e` and not `border-r`: the shell is the one place a right-to-left
130
+ * locale flips, and a physical border leaves the rule on the wrong edge.
131
+ */
132
+ export declare const APP_SIDEBAR = "h-full w-sidebar shrink-0 flex-col border-e border-line bg-surface";
133
+ /**
134
+ * The header bar. Its gutter is the page gutter, so the section name in the
135
+ * header sits directly above the page title under it rather than 8px off.
136
+ */
137
+ export declare const APP_HEADER = "flex h-header shrink-0 items-center gap-2 border-b border-line bg-surface px-page-x";
138
+ /**
139
+ * The sidebar's brand row. Exactly as tall as the header beside it: the two
140
+ * meet at the top left corner and a 4px disagreement there reads as a seam.
141
+ * Its inset is the nav row inset, so the mark lines up with the nav icons
142
+ * under it instead of with their labels.
143
+ */
144
+ export declare const APP_BRAND = "flex h-header shrink-0 items-center gap-2 border-b border-line px-inline";
145
+ /** A band at the foot of the sidebar, on the nav's own inset. */
146
+ export declare const APP_SIDEBAR_BAND = "shrink-0 border-t border-line px-inline py-3";
@@ -130,3 +130,30 @@ export function sectionHeading(level) {
130
130
  // one heading broken in half.
131
131
  return level === 2 ? 'text-lg font-semibold text-fg' : 'text-sm font-semibold text-fg';
132
132
  }
133
+ /**
134
+ * The authed app frame, stated once for every app that has one.
135
+ *
136
+ * Three apps hand rolled this and none of the three agreed. The sidebar was
137
+ * `w-56` in the admin and `w-60` in the other two, `bg-surface` in two and
138
+ * `bg-surface/30` in the third; the header existed in one and not in the other
139
+ * two. None of that was visible from inside any one app, which is why it went
140
+ * on being three shells for as long as it did.
141
+ *
142
+ * `border-e` and not `border-r`: the shell is the one place a right-to-left
143
+ * locale flips, and a physical border leaves the rule on the wrong edge.
144
+ */
145
+ export const APP_SIDEBAR = 'h-full w-sidebar shrink-0 flex-col border-e border-line bg-surface';
146
+ /**
147
+ * The header bar. Its gutter is the page gutter, so the section name in the
148
+ * header sits directly above the page title under it rather than 8px off.
149
+ */
150
+ export const APP_HEADER = 'flex h-header shrink-0 items-center gap-2 border-b border-line bg-surface px-page-x';
151
+ /**
152
+ * The sidebar's brand row. Exactly as tall as the header beside it: the two
153
+ * meet at the top left corner and a 4px disagreement there reads as a seam.
154
+ * Its inset is the nav row inset, so the mark lines up with the nav icons
155
+ * under it instead of with their labels.
156
+ */
157
+ export const APP_BRAND = 'flex h-header shrink-0 items-center gap-2 border-b border-line px-inline';
158
+ /** A band at the foot of the sidebar, on the nav's own inset. */
159
+ export const APP_SIDEBAR_BAND = 'shrink-0 border-t border-line px-inline py-3';
@@ -137,6 +137,11 @@
137
137
  * the 13px/1.25 control text plus input-y padding and a 1px border. */
138
138
  --spacing-control: 2.375rem;
139
139
  --spacing-panel-max: 15rem; /* 240px - the scroll cap on a floating option list */
140
+ /* 56px - the app header bar, and the sidebar's brand row above the nav.
141
+ * One token, because the two sit side by side across the top of every
142
+ * authed screen and a 4px disagreement between them reads as a broken
143
+ * seam. Three apps each picked their own height before this existed. */
144
+ --spacing-header: 3.5rem;
140
145
  --spacing-nav-indent: 0.875rem; /* 14px - one level of sidebar nesting */
141
146
  --spacing-nav-rail: 3.5rem; /* 56px - the collapsed icon rail */
142
147
  --spacing-sidebar: 14rem; /* 224px - the expanded sidebar */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyeve-labs/ui-kit",
3
- "version": "0.13.1",
3
+ "version": "0.15.0",
4
4
  "description": "A clean, accessible, palette-aware Svelte 5 component library. The design system behind LyEve.",
5
5
  "license": "MIT",
6
6
  "author": "LyEve Labs <hello@lyeve.com>",
@@ -137,6 +137,11 @@
137
137
  * the 13px/1.25 control text plus input-y padding and a 1px border. */
138
138
  --spacing-control: 2.375rem;
139
139
  --spacing-panel-max: 15rem; /* 240px - the scroll cap on a floating option list */
140
+ /* 56px - the app header bar, and the sidebar's brand row above the nav.
141
+ * One token, because the two sit side by side across the top of every
142
+ * authed screen and a 4px disagreement between them reads as a broken
143
+ * seam. Three apps each picked their own height before this existed. */
144
+ --spacing-header: 3.5rem;
140
145
  --spacing-nav-indent: 0.875rem; /* 14px - one level of sidebar nesting */
141
146
  --spacing-nav-rail: 3.5rem; /* 56px - the collapsed icon rail */
142
147
  --spacing-sidebar: 14rem; /* 224px - the expanded sidebar */