entasis 0.13.6 → 0.13.7

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.
@@ -266,7 +266,7 @@
266
266
  });
267
267
 
268
268
  function hasIcon(entry: SidebarMenuEntry) {
269
- return entry.icon != null;
269
+ return entry.icon != null || entry.media != null;
270
270
  }
271
271
 
272
272
  function hasCollapsibleMedia(button: SidebarMenuButtonItem | undefined) {
@@ -28,6 +28,7 @@
28
28
  import SidebarFlyout from './SidebarFlyout.svelte';
29
29
  import SidebarIcon from './SidebarIcon.svelte';
30
30
  import SidebarMenuSubItem from './SidebarMenuSubItem.svelte';
31
+ import SidebarMenuText from './SidebarMenuText.svelte';
31
32
  import { useSidebarSuffixWidth } from './sidebar.suffix.svelte.js';
32
33
  import { useSidebarMotion, useSidebarTheme, type SidebarThemeProps } from './sidebar.theme.js';
33
34
 
@@ -82,6 +83,13 @@
82
83
  );
83
84
  const hasSubmenu = $derived(!!item.items?.length);
84
85
  const showBadge = $derived(item.badge != null && !isIconCollapsed);
86
+ // A description stacks a second line under the label, so the row takes the large height
87
+ // unless the entry sets its own size.
88
+ const rowSize = $derived(item.size ?? (item.description != null ? 'large' : undefined));
89
+ const lead = $derived(item.media ? 'media' : 'icon');
90
+ // On a two-line row the suffix and the badge sit on the description's line, so the label's
91
+ // line, and its meta, run to the same end on every row.
92
+ const twoLine = $derived(item.description != null);
85
93
  // An in-flow chevron or dots after the badge: the row then keeps its action's room at every
86
94
  // width, since the action would otherwise cover that glyph when it shows.
87
95
  const hasTrailingGlyph = $derived(
@@ -94,8 +102,12 @@
94
102
  const endReserve = $derived(
95
103
  item.suffix
96
104
  ? item.action
97
- ? 'suffixAction'
98
- : 'suffix'
105
+ ? twoLine
106
+ ? 'suffixLineAction'
107
+ : 'suffixAction'
108
+ : twoLine
109
+ ? 'suffixLine'
110
+ : 'suffix'
99
111
  : !item.action
100
112
  ? 'none'
101
113
  : showBadge && !hasTrailingGlyph
@@ -176,7 +188,11 @@
176
188
  </script>
177
189
 
178
190
  {#snippet entryContent()}
179
- {#if hasIconSurface}
191
+ {#if item.media}
192
+ <span data-slot="sidebar-menu-media" data-sidebar="menu-media" class={classes.menuMedia()}>
193
+ {@render item.media(api, item)}
194
+ </span>
195
+ {:else if hasIconSurface}
180
196
  <span
181
197
  data-slot="sidebar-menu-icon"
182
198
  data-color={item.iconColor}
@@ -187,7 +203,25 @@
187
203
  {:else}
188
204
  <SidebarIcon icon={item.icon} class={back ? 'rtl:-scale-x-100' : undefined} />
189
205
  {/if}
190
- <span class={classes.menuLabel()}>{item.label}</span>
206
+ {#if item.description != null || item.meta != null}
207
+ <SidebarMenuText
208
+ label={item.label}
209
+ description={item.description}
210
+ meta={isIconCollapsed ? undefined : item.meta}
211
+ suffix={!!item.suffix}
212
+ trailing={twoLine ? badge : undefined}
213
+ {size}
214
+ {theme}
215
+ />
216
+ {:else}
217
+ <span class={classes.menuLabel()}>{item.label}</span>
218
+ {/if}
219
+ {#if !twoLine}
220
+ {@render badge()}
221
+ {/if}
222
+ {/snippet}
223
+
224
+ {#snippet badge()}
191
225
  {#if showBadge}
192
226
  <span
193
227
  data-slot="sidebar-menu-badge"
@@ -224,7 +258,7 @@
224
258
  role={item.disabled ? 'link' : undefined}
225
259
  data-slot="sidebar-menu-button"
226
260
  data-sidebar="menu-button"
227
- data-size={item.size ?? 'normal'}
261
+ data-size={rowSize ?? 'normal'}
228
262
  data-active={item.isActive ? 'true' : undefined}
229
263
  data-active-variant={activeVariant}
230
264
  aria-current={item.isActive ? 'page' : undefined}
@@ -236,7 +270,8 @@
236
270
  activeVariant,
237
271
  size,
238
272
  density,
239
- itemSize: item.size,
273
+ itemSize: rowSize,
274
+ lead,
240
275
  endReserve,
241
276
  className: item.class
242
277
  })}
@@ -254,7 +289,7 @@
254
289
  type="button"
255
290
  data-slot="sidebar-menu-button"
256
291
  data-sidebar="menu-button"
257
- data-size={item.size ?? 'normal'}
292
+ data-size={rowSize ?? 'normal'}
258
293
  data-active={item.isActive ? 'true' : undefined}
259
294
  data-active-variant={activeVariant}
260
295
  aria-current={item.isActive ? 'page' : undefined}
@@ -267,7 +302,8 @@
267
302
  activeVariant,
268
303
  size,
269
304
  density,
270
- itemSize: item.size,
305
+ itemSize: rowSize,
306
+ lead,
271
307
  endReserve,
272
308
  className: item.class
273
309
  })}
@@ -302,7 +338,7 @@
302
338
  type="button"
303
339
  data-slot="sidebar-menu-button"
304
340
  data-sidebar="menu-button"
305
- data-size={item.size ?? 'normal'}
341
+ data-size={rowSize ?? 'normal'}
306
342
  data-active-variant={activeVariant}
307
343
  disabled={item.disabled || undefined}
308
344
  class={classes.menuButton({
@@ -310,7 +346,8 @@
310
346
  activeVariant,
311
347
  size,
312
348
  density,
313
- itemSize: item.size,
349
+ itemSize: rowSize,
350
+ lead,
314
351
  endReserve,
315
352
  className: ['aria-expanded:bg-neutral-muted', item.class]
316
353
  })}
@@ -339,7 +376,7 @@
339
376
  type="button"
340
377
  data-slot="sidebar-menu-button"
341
378
  data-sidebar="menu-button"
342
- data-size={item.size ?? 'normal'}
379
+ data-size={rowSize ?? 'normal'}
343
380
  data-active={item.isActive ? 'true' : undefined}
344
381
  data-active-variant={activeVariant}
345
382
  class={classes.menuButton({
@@ -347,7 +384,8 @@
347
384
  activeVariant,
348
385
  size,
349
386
  density,
350
- itemSize: item.size,
387
+ itemSize: rowSize,
388
+ lead,
351
389
  endReserve,
352
390
  className: item.class
353
391
  })}
@@ -434,7 +472,7 @@
434
472
  type="button"
435
473
  data-slot="sidebar-menu-button"
436
474
  data-sidebar="menu-button"
437
- data-size={item.size ?? 'normal'}
475
+ data-size={rowSize ?? 'normal'}
438
476
  data-active={item.isActive ? 'true' : undefined}
439
477
  data-active-variant={activeVariant}
440
478
  disabled={item.disabled || undefined}
@@ -443,7 +481,8 @@
443
481
  activeVariant,
444
482
  size,
445
483
  density,
446
- itemSize: item.size,
484
+ itemSize: rowSize,
485
+ lead,
447
486
  endReserve,
448
487
  className: item.class
449
488
  })}
@@ -473,7 +512,12 @@
473
512
  data-sidebar="menu-suffix"
474
513
  inert={isIconCollapsed ? true : undefined}
475
514
  aria-hidden={isIconCollapsed ? 'true' : undefined}
476
- class={classes.suffix({ size, density, action: item.action ? 'action' : 'none' })}
515
+ class={classes.suffix({
516
+ size,
517
+ density,
518
+ action: item.action ? 'action' : 'none',
519
+ line: twoLine ? 'two' : 'one'
520
+ })}
477
521
  {@attach suffixWidth.measure}
478
522
  >
479
523
  {@render item.suffix(api, item)}
@@ -11,6 +11,7 @@
11
11
  import { sidebarInwardPhysicalSide, sidebarTooltip } from './sidebar-position.js';
12
12
  import SidebarAction from './SidebarAction.svelte';
13
13
  import SidebarIcon from './SidebarIcon.svelte';
14
+ import SidebarMenuText from './SidebarMenuText.svelte';
14
15
  import { useSidebarSuffixWidth } from './sidebar.suffix.svelte.js';
15
16
  import { useSidebarTheme, type SidebarThemeProps } from './sidebar.theme.js';
16
17
 
@@ -39,6 +40,9 @@
39
40
  // tile, so an untinted row never inherits the ambient `data-color`.
40
41
  const hasIconSurface = $derived(!!sub.icon && (sub.iconVariant === 'tile' || !!sub.iconColor));
41
42
  const hasBadge = $derived(sub.badge != null);
43
+ // On a two-line row the suffix and the badge sit on the description's line, so the label's
44
+ // line, and its meta, run to the same end on every row.
45
+ const twoLine = $derived(sub.description != null);
42
46
  const suffixWidth = useSidebarSuffixWidth();
43
47
  const uid = $props.id();
44
48
  const suffixId = `${uid}-suffix`;
@@ -63,13 +67,18 @@
63
67
  // stays visible before the action, so a row with one reserves both at every width.
64
68
  endReserve: sub.suffix
65
69
  ? sub.action
66
- ? 'suffixAction'
67
- : 'suffix'
70
+ ? twoLine
71
+ ? 'suffixLineAction'
72
+ : 'suffixAction'
73
+ : twoLine
74
+ ? 'suffixLine'
75
+ : 'suffix'
68
76
  : !sub.action
69
77
  ? 'none'
70
78
  : hasBadge
71
79
  ? 'actionBelowMd'
72
80
  : 'action',
81
+ tall: sub.description != null || sub.media != null,
73
82
  className: sub.class
74
83
  })
75
84
  );
@@ -85,7 +94,11 @@
85
94
  </script>
86
95
 
87
96
  {#snippet entryContent()}
88
- {#if hasIconSurface}
97
+ {#if sub.media}
98
+ <span data-slot="sidebar-menu-media" data-sidebar="menu-media" class={classes.menuMedia()}>
99
+ {@render sub.media(api, sub)}
100
+ </span>
101
+ {:else if hasIconSurface}
89
102
  <span
90
103
  data-slot="sidebar-menu-icon"
91
104
  data-color={sub.iconColor}
@@ -100,20 +113,37 @@
100
113
  {:else}
101
114
  <SidebarIcon icon={sub.icon} />
102
115
  {/if}
103
- {#if hasBadge}
116
+ {#if sub.description != null || sub.meta != null}
117
+ <SidebarMenuText
118
+ label={sub.label}
119
+ description={sub.description}
120
+ meta={sub.meta}
121
+ suffix={!!sub.suffix}
122
+ trailing={twoLine && hasBadge ? badge : undefined}
123
+ size={subSize}
124
+ {theme}
125
+ />
126
+ {#if !twoLine && hasBadge}
127
+ {@render badge()}
128
+ {/if}
129
+ {:else if hasBadge}
104
130
  <span data-slot="sidebar-menu-sub-label">{sub.label}</span>
105
- <span
106
- data-slot="sidebar-menu-sub-badge"
107
- data-sidebar="menu-sub-badge"
108
- class={classes.badge({ size: subSize, action: sub.action && !sub.suffix ? 'sub' : 'none' })}
109
- >
110
- {sub.badge}
111
- </span>
131
+ {@render badge()}
112
132
  {:else}
113
133
  <span>{sub.label}</span>
114
134
  {/if}
115
135
  {/snippet}
116
136
 
137
+ {#snippet badge()}
138
+ <span
139
+ data-slot="sidebar-menu-sub-badge"
140
+ data-sidebar="menu-sub-badge"
141
+ class={classes.badge({ size: subSize, action: sub.action && !sub.suffix ? 'sub' : 'none' })}
142
+ >
143
+ {sub.badge}
144
+ </span>
145
+ {/snippet}
146
+
117
147
  <li
118
148
  bind:this={itemRef}
119
149
  data-slot="sidebar-menu-sub-item"
@@ -175,7 +205,8 @@
175
205
  class={classes.suffix({
176
206
  size: subSize,
177
207
  density,
178
- action: sub.action ? 'action' : 'none'
208
+ action: sub.action ? 'action' : 'none',
209
+ line: twoLine ? 'two' : 'one'
179
210
  })}
180
211
  {@attach suffixWidth.measure}
181
212
  >
@@ -0,0 +1,60 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { SidebarSize } from './sidebar.props.js';
4
+ import { useSidebarTheme, type SidebarThemeProps } from './sidebar.theme.js';
5
+
6
+ let {
7
+ label,
8
+ description,
9
+ meta,
10
+ size,
11
+ suffix = false,
12
+ trailing,
13
+ theme
14
+ }: {
15
+ label: string;
16
+ description?: string | Snippet;
17
+ /** Left out while the Sidebar is collapsed to icons. */
18
+ meta?: string | Snippet;
19
+ size: SidebarSize;
20
+ /** The row has a suffix, centred on the description's line. */
21
+ suffix?: boolean;
22
+ /** End of the description's line: the row's badge. */
23
+ trailing?: Snippet;
24
+ theme?: SidebarThemeProps;
25
+ } = $props();
26
+
27
+ const classes = $derived(useSidebarTheme(theme));
28
+ </script>
29
+
30
+ {#snippet text(content: string | Snippet)}
31
+ {#if typeof content === 'string'}{content}{:else}{@render content()}{/if}
32
+ {/snippet}
33
+
34
+ <!-- The label's line (the label, then the meta at its end) over the description's line. -->
35
+ <span data-slot="sidebar-menu-text" class={classes.menuText()}>
36
+ <span class={classes.menuTitleRow()}>
37
+ <span data-slot="sidebar-menu-label" class={classes.menuTitle()}>{label}</span>
38
+ {#if meta != null}
39
+ <span
40
+ data-slot="sidebar-menu-meta"
41
+ data-sidebar="menu-meta"
42
+ class={classes.menuMeta({ size })}
43
+ >
44
+ {@render text(meta)}
45
+ </span>
46
+ {/if}
47
+ </span>
48
+ {#if description != null}
49
+ <span class={classes.menuDescriptionRow({ suffix })}>
50
+ <span
51
+ data-slot="sidebar-menu-description"
52
+ data-sidebar="menu-description"
53
+ class={classes.menuSecondary({ size })}
54
+ >
55
+ {@render text(description)}
56
+ </span>
57
+ {@render trailing?.()}
58
+ </span>
59
+ {/if}
60
+ </span>
@@ -0,0 +1,18 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { SidebarSize } from './sidebar.props.js';
3
+ import { type SidebarThemeProps } from './sidebar.theme.js';
4
+ type $$ComponentProps = {
5
+ label: string;
6
+ description?: string | Snippet;
7
+ /** Left out while the Sidebar is collapsed to icons. */
8
+ meta?: string | Snippet;
9
+ size: SidebarSize;
10
+ /** The row has a suffix, centred on the description's line. */
11
+ suffix?: boolean;
12
+ /** End of the description's line: the row's badge. */
13
+ trailing?: Snippet;
14
+ theme?: SidebarThemeProps;
15
+ };
16
+ declare const SidebarMenuText: import("svelte").Component<$$ComponentProps, {}, "">;
17
+ type SidebarMenuText = ReturnType<typeof SidebarMenuText>;
18
+ export default SidebarMenuText;
@@ -1 +1 @@
1
- export declare const sidebarDescription = "\n# Sidebar Component\n\nSidebar navigation with data-driven groups, icon collapse, mobile drawer behavior,\nrecursive tree groups, header/footer rows, search, actions, and snippet escape hatches.\n\n## Import\n\n```svelte\n<script lang=\"ts\">\n\timport { Sidebar, type SidebarGroup } from 'entasis/sidebar';\n</script>\n```\n\n## Basic Usage\n\n```svelte\n<script lang=\"ts\">\n\timport { Sidebar, type SidebarGroup } from 'entasis/sidebar';\n\timport { houseIcon } from 'entasis/icons/house';\n\timport { gearIcon } from 'entasis/icons/gear';\n\n\tconst items: SidebarGroup[] = [\n\t\t{\n\t\t\tlabel: 'Workspace',\n\t\t\titems: [\n\t\t\t\t{ label: 'Dashboard', href: '/', icon: houseIcon, isActive: true },\n\t\t\t\t{ label: 'Settings', href: '/settings', icon: gearIcon }\n\t\t\t]\n\t\t}\n\t];\n</script>\n\n<Sidebar items={items}>\n\t{#snippet children({ toggle })}\n\t\t<header>\n\t\t\t<button type=\"button\" onclick={toggle}>Toggle</button>\n\t\t</header>\n\t\t<main>Page content</main>\n\t{/snippet}\n</Sidebar>\n```\n\n## AI-Safe Usage Contract\n\n1. Use `items` for normal navigation. Use `content` only when data-driven rows cannot express the layout.\n2. Use local Entasis icon snippets such as `houseIcon`, not Lucide component constructors.\n3. Use `MenuItem[]` from `entasis/menu` for `menu` and action dropdowns.\n4. Do not combine `menu` with `href` or `onclick` on the same row; use `action` for a trailing row menu.\n5. Keep `children`, `header`, `content`, `footer`, `banner`, and action snippets pure; they receive `SidebarApi`.\n6. Use `collapsible=\"icon\"` for icon rail behavior, `collapsible=\"offcanvas\"` for hidden desktop panels, and `collapsible=\"none\"` for fixed sidebars. Icon collapse automatically falls back to offcanvas when any data-driven row lacks an icon.\n7. Offcanvas sidebars reveal over the content from the screen edge by default when hidden; set `edgeReveal={false}` to disable that. A revealed hidden sidebar keeps its resize handle and dismisses through a small rectangular pointer tolerance.\n8. Set `keyboardShortcut={false}` when embedding Sidebar inside another shortcut-heavy surface.\n9. Sidebar owns navigation, resize mechanics, the lower application wall, and variant surface geometry. AppShell forwards its variant and composes PageShell inside that surface.\n10. Use `size` for typography, icon scale, and item height. Use `density` independently for section padding, gaps, and submenu spacing.\n11. Use `activityBar` for a persistent icon rail outside the panel (section switching, workspaces). Every item needs an `icon` and a `label`; the label is the accessible name and the tooltip. It is layout mode only: `mode=\"panel\"` renders the navigation panel alone. The rail does not change the panel by itself: keep the selected rail item in state from `onSelect`, mark it `isActive`, and either derive the Sidebar's `items` from it or, for an animated switch, give each rail item a view in `views` and set `view` from it.\n12. Use `expandOnHover` only with `collapsible=\"icon\"`. It is a temporary peek, not a toggle: the persisted collapsed state never changes, while the peeked panel renders with expanded semantics.\n13. Use `views` when the panel's contents change as the user moves through the app: sections switched from the activity bar or the route, and nested menus that open inside the panel. Key each view, set `parent` on nested ones, open them with a row's `view`, and drive `view` from state or the URL. Do not hand-animate `items` swaps.\n\n## Data Model\n\n### SidebarGroup\n- **label**: string - Group label, hidden in icon-collapsed mode.\n- **items**: SidebarMenuEntry[] - Menu rows.\n- **tree**: SidebarTreeNode[] - Recursive tree rows instead of menu items.\n- **action**: SidebarMenuActionDescriptor | SidebarMenuActionDescriptor[] | Snippet<[SidebarApi]> - Group actions pinned to the label's end. The label truncates on one line before them. Pass an array to pin several affordances (a `+` and a drag handle) to one group header; each renders as its own icon-only ghost button.\n- **collapsible**: boolean - Makes the group label a toggle.\n- **defaultOpen**: boolean - Initial collapsible group state.\n- **separator**: boolean - Divider before the group.\n\n### SidebarMenuEntry\n- **label**: string - Visible row label.\n- **icon**: SidebarIcon - Entasis icon snippet or string.\n- **iconColor**: Colors - Role tint for the leading icon, applied through `data-color`.\n- **iconVariant**: 'bare' | 'tile' - Leading icon treatment. `tile` paints a rounded square (`bg-color-muted text-color-muted-readable`) around the glyph, so per-project colour chips come from the role scale instead of hand-built markup.\n- **href**: string - Render as an anchor. Mutually exclusive with menu.\n- **onclick**: (event: MouseEvent) => void - Native click handler for button or anchor rows. Mutually exclusive with menu. On a row with `items` and no `href`, it runs before the submenu toggles, inline or in the collapsed flyout; `event.preventDefault()` skips the toggle.\n- **isActive**: boolean - Adds active styling and `aria-current=\"page\"`.\n- **disabled**: boolean - Disables button rows and marks anchor rows disabled.\n- **badge**: string | number - Trailing count/status, hidden in icon mode. It follows the label inside the row (before any chevron), so the label truncates before it whatever its width. On a row with an `action`, the badge steps aside while the action shows (hover, focus, open menu) from `md` up, and sits just before the always-visible action below `md`: the two never overlap.\n- **tooltip**: string - Entasis Tooltip content in icon mode. Defaults to label.\n- **items**: SidebarMenuSubEntry[] - Inline nested menu. Collapsed to icons on desktop, a row with `items` and no `href`, `view` or `menu` opens them in a flyout of links instead.\n- **collapsible**: boolean - Set false for an always-open submenu.\n- **defaultOpen**: boolean - Initial nested menu state.\n- **menu**: MenuItem[] - Popup menu opened from the full row. Mutually exclusive with href/onclick.\n- **menuSide** / **menuAlign**: SidebarMenuSide / 'start' | 'center' | 'end' - Where that menu opens. `menuSide` takes a physical side (`top`, `right`, `bottom`, `left`) or a logical one (`inline-start`, `inline-end`, mirrored in right-to-left). Left out, a desktop menu opens toward the page (`inline-end` of a left sidebar, `inline-start` of a right one, so it follows the reading direction) aligned to the row's start, and a mobile one below the row aligned to its end. The same pair sits on action descriptors and on `headerButton` / `footerButton`.\n- **view**: string - Key of the `views` entry the row opens, sliding it in; the row shows a trailing chevron. Mutually exclusive with href, menu and items.\n- **action**: SidebarMenuActionDescriptor | Snippet<[SidebarApi]> - Hover/focus trailing action.\n- **suffix**: Snippet<[SidebarApi, SidebarMenuEntry]> - Content at the row's end, beside the label: a count, a `Chip`, an icon, a status dot, its own `PopupMenu` trigger. It receives the row's own entry, so rows built as data share one snippet declared in markup (`{#snippet suffix(api, entry)}`) and tell themselves apart by `entry.href`. It renders beside the row's link or button, never inside it (`data-sidebar=\"menu-suffix\"`, drawn by the `suffix` theme part), so it may hold controls: a click or a keypress inside it never follows the row's `href` nor toggles it, and Tab reaches its controls right after the row, before the `action`. The row's link or button is `aria-describedby` the suffix, so the row reads with it (\"Dossiers\", \"20\"). The label truncates before it: the row reserves its measured width (`--sidebar-suffix-width`), and the `suffix` part bounds it (`max-w-[40%]` of the row), so a wide suffix clips instead of breaking the row. With an `action`, it stays visible just before the action at every width (the row keeps the action's room, and a `badge` then stays put instead of stepping aside). Hidden, `inert` and `aria-hidden` in icon-collapsed mode; shown in a hover peek and in the mobile drawer.\n\n### SidebarMenuSubEntry\nA row of an inline submenu (`items` on a menu entry). It carries the same row features as a\ntop-level entry, so nested navigation (pages filed in folders) keeps its menus, pins and tints\nwithout flattening the tree. Every field below `size` is optional, and a sub entry that sets none\nof them renders exactly as a plain one.\n- **label**: string - Visible row label.\n- **href** / **onclick** - Render as an anchor, or a button with a native click handler.\n- **icon**: SidebarIcon - Leading icon.\n- **isActive** / **disabled** / **size** - As on a top-level entry; `size` defaults to the Sidebar size.\n- **iconColor**: Colors - Role tint for the leading icon, applied through `data-color`.\n- **iconVariant**: 'bare' | 'tile' - `tile` paints the same tinted rounded square as a top-level row.\n- **badge**: string | number - Trailing count or status; the label truncates before it. It shares the row's end column with the parent rows' badges and makes way for the row's `action` as a top-level badge does.\n- **tooltip**: string - Tooltip text. The label is always visible on an inline sub row, so the tooltip is extra information: shown whenever one is set, never on mobile. The collapsed row's flyout lists only each entry's label, icon, `href` or `onclick`, and `isActive`/`disabled` state: it shows no tooltip, `badge`, `suffix` or `action`.\n- **class**: string - Classes applied to the row button.\n- **action**: SidebarMenuActionDescriptor | Snippet<[SidebarApi]> - Trailing action pinned to the row's end (`data-sidebar=\"menu-sub-action\"`), drawn by the `subAction` theme part: hidden until the row is hovered or focused from `md` up, kept visible while its menu is open, always visible below `md`. The label truncates before it.\n- **suffix**: Snippet<[SidebarApi, SidebarMenuSubEntry]> - Content at the row's end, beside the label: a count, a `Chip`, an icon, a status dot, its own `PopupMenu` trigger. It receives the row's own entry, so rows built as data share one snippet declared in markup (`{#snippet suffix(api, entry)}`) and tell themselves apart by `entry.href`. It renders beside the row's link or button, never inside it (`data-sidebar=\"menu-sub-suffix\"`, drawn by the `suffix` theme part), so it may hold controls: a click or a keypress inside it never follows the row's `href` nor toggles it, and Tab reaches its controls right after the row, before the `action`. The row's link or button is `aria-describedby` the suffix, so the row reads with it (\"Dossiers\", \"20\"). The label truncates before it: the row reserves its measured width (`--sidebar-suffix-width`), and the `suffix` part bounds it (`max-w-[40%]` of the row), so a wide suffix clips instead of breaking the row. With an `action`, it stays visible just before the action at every width (the row keeps the action's room, and a `badge` then stays put instead of stepping aside). Hidden, `inert` and `aria-hidden` in icon-collapsed mode; shown in a hover peek and in the mobile drawer.\n\n```svelte\n<script lang=\"ts\">\n\timport { Sidebar, type SidebarGroup } from 'entasis/sidebar';\n\timport { folderIcon } from 'entasis/icons/folder';\n\timport { fileTextIcon } from 'entasis/icons/fileText';\n\timport { pencilSimpleIcon } from 'entasis/icons/pencilSimple';\n\timport { pushPinIcon } from 'entasis/icons/pushPin';\n\timport { trashIcon } from 'entasis/icons/trash';\n\n\tconst items: SidebarGroup[] = [\n\t\t{\n\t\t\tlabel: 'Pages',\n\t\t\titems: [\n\t\t\t\t{\n\t\t\t\t\tlabel: 'Planning',\n\t\t\t\t\ticon: folderIcon,\n\t\t\t\t\tdefaultOpen: true,\n\t\t\t\t\titems: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tlabel: 'Q3 roadmap',\n\t\t\t\t\t\t\thref: '/pages/q3-roadmap',\n\t\t\t\t\t\t\ticon: fileTextIcon,\n\t\t\t\t\t\t\ticonColor: 'success',\n\t\t\t\t\t\t\ticonVariant: 'tile',\n\t\t\t\t\t\t\tbadge: 2,\n\t\t\t\t\t\t\taction: {\n\t\t\t\t\t\t\t\tlabel: 'More options for Q3 roadmap',\n\t\t\t\t\t\t\t\tmenu: [\n\t\t\t\t\t\t\t\t\t{ type: 'option', title: 'Rename', prefix: pencilSimpleIcon },\n\t\t\t\t\t\t\t\t\t{ type: 'option', title: 'Pin', prefix: pushPinIcon },\n\t\t\t\t\t\t\t\t\t{ type: 'option', title: 'Delete', prefix: trashIcon, color: 'danger' }\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{ label: 'Retro notes', href: '/pages/retro-notes', icon: fileTextIcon }\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t]\n\t\t}\n\t];\n</script>\n\n<Sidebar {items} />\n```\n\n### SidebarView\nOne named panel content in `views`.\n- **label**: string - The view's name, shown on the back row of the views nested under it.\n- **parent**: string - Key of the view this one is nested under. A nested view opens with a back row to its parent (named \"Back, <parent label>\"), and on mobile a swipe toward the inline end goes back. A view without `parent` is a top-level section.\n- **items**: SidebarGroup[] / **content**: Snippet<[SidebarApi]> - The view's body.\n- **headerButton**, **search**, **headerMenu**, **header**, **footerButton**, **footerMenu**, **footer** - Header and footer props for this view. Each one left undefined comes from the parent view, then from the Sidebar's own prop; `null` removes an inherited one.\n\nChanging the view slides the two views side by side, like pages: a deeper view comes in from the inline end while the old one leaves to the start, a shallower one slides back the other way, and between views at the same depth (sections) the later one in `views` counts as forward. When both views get every header and footer prop from the same place, the header and footer stay still and only the menu slides; when a view changes any of them, the whole panel slides as one page. `api.view` reads the current view and `api.setView(key)` changes it from a snippet.\n\n### SidebarActivityBar\nIcon rail pinned to the outer edge of the sidebar, visible in every display state.\n- **items**: SidebarActivityBarItem[] - Items rendered from the top.\n- **footerItems**: SidebarActivityBarItem[] - Items pinned to the end of the column.\n- **header** / **footer**: Snippet - Custom content before the first item and after the pinned ones.\n- **width**: string (default '3rem') - Column thickness, published as `--sidebar-width-activity`.\n- **label**: string - Accessible name for the column landmark. Set it whenever the panel also renders navigation.\n- **onSelect**: ({ item, index }) => void - Fires after an item is activated. `index` counts `items` then `footerItems`.\n\n### SidebarActivityBarItem\n- **icon**: SidebarIcon (required) - Icon rendered in the square.\n- **label**: string (required) - Accessible name and default tooltip; the square shows no text.\n- **id**: string - Stable render key.\n- **href** / **target** / **rel** - Render an anchor instead of a button.\n- **onclick**: (event: MouseEvent) => void - Native click handler.\n- **isActive**: boolean - Adds active styling and `aria-current=\"page\"`.\n- **badge**: string | number | Snippet - Corner badge pinned to the outer top corner. An empty string renders a bare dot. A string or number badge joins the accessible name (`\"Alerts, 3\"`); a dot and a Snippet badge are decorative, so put their meaning in `label`.\n- **disabled**: boolean - Blocks activation and skips the item during keyboard navigation.\n- **tooltip**: string | false - Tooltip override; `false` suppresses it.\n\n### SidebarMenuButtonItem\nUse for `headerButton`, `footerButton`, or direct `<SidebarMenuButton />` rows.\n- **icon**: SidebarIcon - Leading logo/icon.\n- **avatar**: { src?: string; alt?: string; fallback?: string } - Leading avatar.\n- **variant**: 'default' | 'brand' | 'compact'.\n- **title**: string - Primary text.\n- **subtitle**: string - Secondary text.\n- **trailing**: SidebarIcon | false | SidebarMenuActionDescriptor - Trailing content. An icon is decorative; an action descriptor (`{ icon, label, onclick }`, the same shape as a group action) renders its own icon-only ghost button beside the row, so a workspace card can carry its own collapse control without a custom `header` snippet. Descriptor handlers are `onclick(event, api)`, so `api.toggle()` is reachable.\n- **href** / **onclick** / **menu** - Choose link, button, or popup behavior. `onclick(event, api)` receives the SidebarApi beside the event, so `api.toggle()` is reachable from the row itself.\n- **menuIconClass**: string - Class override for option icons inside the popup menu.\n\n## Props\n\n### State\n- **open**: boolean (bindable, default true) - Desktop expanded state.\n- **defaultOpen**: boolean (default true) - Initial desktop state when `open` is omitted.\n- **onOpenChange**: (open: boolean) => void - Called once for a library-originated desktop state change. Repeated requests and parent prop updates stay silent.\n- **onDisplayStateChange**: (state: SidebarDisplayState) => void - Called once for a library-originated semantic display-state change.\n- **view**: string (bindable) - Key of the view on screen when `views` is set. Defaults to `defaultView`, then the first view.\n- **defaultView**: string - Initial view when `view` is omitted.\n- **onViewChange**: (view: string) => void - Called once for a library-originated view change: a view row, a back row, a swipe. Parent prop updates stay silent; with a route-driven `view`, navigate here.\n- **api.displayState**: 'expanded' | 'collapsed' | 'hidden' - Semantic desktop state; hidden means closed offcanvas. A hover peek does not change it.\n- **api.isPeeking**: boolean - True while a hover peek renders the collapsed panel at full width. Read it alongside `displayState` when a snippet hides content in icon mode.\n- **keyboardShortcut**: string | false (default 'b') - Ctrl/Cmd shortcut key.\n\n### Layout\n- **side**: 'left' | 'right' - Desktop and mobile side.\n- **variant**: 'admin' | 'floating' | 'inset' | 'split' | 'framed' - Sidebar geometry. `framed` is the admin geometry for a sidebar hosted inside a raised card (AppShell variant framed), with a `surface-recessed` well. `admin` renders the conventional full-height navigation column; `inset` integrates navigation into the lower wall with an inset content surface; `split` renders detached sidebar and content surfaces.\n- **size**: 'small' | 'normal' | 'large' (default 'normal') - Typography, icon, avatar, badge, leading-media, item-height, and search-height scale.\n- **iconSize**: 'small' | 'normal' | 'large' - Icon and leading-media scale inside the panel on its own; defaults to `size`. Menu rows, sub rows, group labels and the header button follow it; the activity bar keeps `size`.\n- **activeVariant**: 'soft' | 'outline' | 'solid' (default 'soft') - How active rows are painted. `soft` is the shared selected recipe (`selectedSoft`: `bg-selected-muted text-selected-muted-readable`), `solid` its loud counterpart (`selectedSolid`: `bg-selected text-selected-contrast`), `outline` a bordered surface card (`bg-surface border border-neutral-muted text-neutral`) that reads as a raised card on a tinted well. Each row carries the choice as `data-active-variant`, so no descendant selector is needed to restyle selection.\n- **density**: 'compact' | 'normal' | 'comfortable' (default 'normal') - Section padding, group padding, gaps, horizontal inset, and submenu spacing.\n- **collapsible**: 'offcanvas' | 'icon' | 'none' - Collapse behavior. Icon mode requires icons on every data-driven row and otherwise resolves to offcanvas.\n- **collapseIcon**: DisclosureIndicator \u2014 'chevron' | 'plus-minus' | 'none' (default 'chevron') - Disclosure indicator drawn on collapsible menu rows. 'none' renders no indicator.\n- **mode**: 'layout' | 'panel' - Full resizing layout or only the visible navigation panel.\n- **frame**: 'viewport' | 'contained' - Standalone Sidebar positioning. Viewport mode uses Theme's dynamic window-height token; contained mode fills a positioned parent.\n- **width**: string - Expanded width.\n- **widthIcon**: string - Icon-collapsed width.\n- **widthMobile**: string - Mobile drawer width. The drawer runs the full screen height, flush with its edge (no inset); an activity bar adds its own width beside it. A swipe toward its edge closes it, from anywhere on it, rows included. Following a link in the panel closes it too (a plain click, also when a client-side router handles it); the activity bar's links, buttons, and clicks that open elsewhere (a modifier key, `target=\"_blank\"`, `download`) keep it open.\n- **rail**: boolean | 'line' | 'thumb' - Edge toggle rail. `true` keeps the thin line style; `thumb` renders a short visible handle with the same full-height hitbox. The appearance is preserved when the rail shares the resize control.\n- **activityBar**: SidebarActivityBar - Icon rail pinned outside the panel, in layout mode only (`mode=\"panel\"` renders the panel alone and ignores it). It never slides off screen: only the panel takes the offcanvas offset, and the reserved layout column is the panel width plus the rail width. It wears the surface of the variant's panel: a hairline column on the canvas for `admin`, the recessed well for `framed`, borderless on the canvas for `inset`, and a card of its own (the panel's radius, edge and gutter) for `floating` and `split`. Every gutter between the activity bar, the panel and the page, and between them and the frame edge, is `--space-md`, so it scales with the theme's spacing. With `inset` and `split`, hiding the panel normally lets the page drop its frame and fill the edge; with an activity bar the rail is still beside the page, so the page keeps its framed form (padding, rounding, AppShell's border and shadow), and a `split` page takes the gutter the panel used to supply. The wrapper carries `data-page-flush` and the `main` part a `flush` variant only when the page does fill the edge. On mobile it stays a column at the drawer's outer edge, beside the panel, as the hairline column on the canvas whatever the variant.\n- **expandOnHover**: boolean (default false) - With `collapsible=\"icon\"`, hovering or focusing into the collapsed panel expands it to `width` over the page (`data-peek=\"true\"`) while the reserved column stays at `widthIcon`, so page content does not reflow. The persisted collapsed state is untouched, and the peeked panel renders exactly like an expanded one: group headers, badges, search, inline submenus, and inline tree branches all come back, and the rail or resize handle travels to its inner edge.\n- **edgeReveal**: boolean (default true) - Pointer/focus edge preview for hidden offcanvas sidebars. Hover reveal overlays content, remains resizable when configured, and re-hides after the pointer leaves its small rectangular tolerance. Dragging the sidebar closed suppresses immediate hover reopening until the pointer leaves the edge trigger; toggle/click opens persistently.\n- **resizable**: boolean | SidebarResizableOptions - Enables pointer and keyboard resizing while expanded, icon-collapsed, or temporarily edge-revealed. `minWidth` and `maxWidth` (a CSS length or a number of px; defaults `12rem` and `32rem`) bound both the drag and the keyboard resize; a `maxWidth` below `minWidth` throws. By default, collapse requires dragging 75% of `minWidth` beyond the minimum; override `collapseThreshold` for a custom boundary. Use `storageKey` to restore and persist the expanded width across sessions.\n - `onWidthChange({ width, isUserInteraction })` reports every expanded-width change with one named payload: continuously while the user resizes (`isUserInteraction: true`) and once when a stored width is restored (`isUserInteraction: false`).\n\n### Content\n- **items**: SidebarGroup[] - Data-driven body navigation.\n- **views**: Record<string, SidebarView> - Named panel contents, one on screen at a time, replacing `items` and `content`. See SidebarView.\n- **headerButton** / **footerButton**: SidebarMenuButtonItem - Sticky large rows.\n- **search**: SidebarSearch - Header search input; use its native `oninput` handler.\n- **headerMenu** / **footerMenu**: SidebarMenuEntry[] - Sticky quick menus.\n- Menu entries and nested entries accept `size: 'small' | 'normal' | 'large'` for row geometry.\n- **header**, **content**, **footer**, **children**, **banner**: Snippet<[SidebarApi]> - Escape hatches. The `header` snippet renders **first** in the header region, above `headerButton`, `search` and `headerMenu`.\n\n### Styling\n- **class**: string - Classes applied to the Sidebar root.\n- **theme**: SidebarThemeProps - Semantic part overrides such as `panel`, `header`, `nav`, `footer`, menu, search, rail, and mobile drawer parts. Every part, with the element it lands on, its variants and their default classes, is listed in the entasis skill's `theme-parts/sidebar.md`; the registry key is `sidebar`.\n\n## Motion\n\n- **motion** theme slot: the height animation shared by collapsible groups, inline submenus, and\n tree branches. Its `in` / `out` `duration`, `delay` and `easing` (motion tokens) time the\n open and the close; the panel animates its height, block padding, borders and margins.\n- Ladder: `<Theme components={{ sidebar: { motion } }}>` \u2192 `setSidebarTheme({ motion })` \u2192\n `theme.motion`. Reduced motion collapses it to 0.\n- The `view` variant (`part: 'view'`) is the pager between views: `in.x` / `out.x` is how far a view travels (default `'100%'`: the two views slide side by side like pages) while it fades between `opacity` (default 0) and 1, at the `slow` duration on the `enter` easing. The back swipe moves the same layers the view change would, with the same travel and opacity. Reduced motion makes the switch instant while a swipe still follows the finger.\n\n## Restyle recipes\n\nThe five asks that come up first, as the override to copy. Each one is rendered and asserted by\n`sidebar-recipes.svelte.test.ts`, so it cannot drift from the component. One rule behind them: a\ndefault written under a variant prefix (`data-[active-variant=solid]:data-active:bg-selected`,\n`data-[side=left]:border-r`) is only replaced by an override carrying the same prefixes; an\nunprefixed class coexists with it and loses on specificity. `theme-parts/sidebar.md` shows every\ndefault verbatim, prefixes included.\n\n- **Dark panel.** `dark` scopes the dark theme's variables to the panel, so every neutral ink inside\n flips with it (every theme also emits its variables on `.<name>`, so the class scopes the dark theme to one subtree); needs a theme named `dark`, which the documented setup declares.\n `theme={{ panel: { base: 'dark bg-slate-900' }, mobilePanel: { base: 'dark bg-slate-900' } }}`\n- **Active row in your colour.** `activeVariant=\"solid\"` plus the same prefix chain as the default:\n `theme={{ menuButton: { base: 'rounded-full', activeVariant: { solid: 'data-[active-variant=solid]:data-active:bg-indigo-600 data-[active-variant=solid]:data-active:text-white' } } }}`\n- **No hover change.** The overlay is a `::before` and the default also brightens the ink on hover:\n `theme={{ menuButton: { base: 'state-layer-none hover:text-inherit' }, subButton: { base: 'state-layer-none hover:text-neutral/70' } }}`\n- **Flat panel.** The edge is a prefixed `border-r` / `border-l` on the admin variant, the shadow a\n `raised-*` on the floating ones:\n `theme={{ panel: { base: 'raised-none', variant: { admin: 'data-[side=left]:border-r-0 data-[side=right]:border-l-0' } } }}`\n- **Row height.** A plain height beats the compound `h-control-*`: `theme={{ menuButton: { base: 'h-11' } }}`\n- **Bigger icons.** A prop, not a theme: `iconSize=\"large\"`.\n\n## Styling hooks\n\n`data-open` on a disclosure toggle is a presence attribute (before 0.12.0 a submenu toggle carried `data-open=\"true\"`); match it with `[data-open]` or `data-open:`.\n\nAttributes are presence attributes (set while the state holds, omitted otherwise) unless they carry a value.\n\n**Data attributes**\n- `data-state`: On the root: `expanded` or `collapsed`.\n- `data-display-state`: On the root: what is on screen, `expanded`, `collapsed` (the icon rail) or `hidden`.\n- `data-collapsible`: On the root: `icon` while collapsed to the icon rail, `offcanvas` while hidden, empty while expanded.\n- `data-side`: On the root, the panel and its rail and handles: `left` or `right`.\n- `data-variant`: On the root: the resolved `variant`.\n- `data-size`: On the root: the resolved `size`.\n- `data-density`: On the root: the resolved `density`.\n- `data-peek`: On the layout wrapper: `true` while a hover peek shows the collapsed panel at full width.\n- `data-active`: On a row: `true` while it is the current page (`isActive`).\n- `data-active-variant`: On a row: the resolved `activeVariant`.\n- `data-open`: On the toggle of a collapsible group, a submenu or a tree branch, and on its panel: present while it is open.\n- `data-closed`: On the toggle of a collapsible group, a submenu or a tree branch, and on its panel: present while it is closed, including during the exit transition.\n- `data-entering`: On a group, submenu or tree branch panel: present while its opening animation plays.\n- `data-exiting`: On a group, submenu or tree branch panel: present while its closing animation plays.\n- `data-popup-open`: On a row: present while one of its popups is open, its `menu`, its flyout on the collapsed rail, or its tooltip (every row on the collapsed rail, and every row with `tooltips=\"always\"`). Match `[aria-expanded=\"true\"]` to style an open menu or flyout only.\n- `data-sidebar`: The part (`sidebar`, `menu-button`, `menu-sub`, `group-content`, `rail`, ...).\n\n**CSS variables**\n- `--sidebar-width`: On the root: the expanded panel width.\n- `--sidebar-width-icon`: On the root: the width of the collapsed icon rail.\n- `--sidebar-width-mobile`: On the root: the width of the mobile drawer.\n- `--sidebar-suffix-width`: On a row with a `suffix` (its `menu-item` row wrapper or `menu-sub-item`): the suffix's measured width in px, reserved at the row's end so the label truncates before it.\n- `--disclosure-panel-height`: On a group, submenu or tree branch panel: its measured height in px while it animates, `auto` once open and `0px` once closed.\n\nThe typed maps `sidebarDataAttributes` and `sidebarCssVariables` (from `entasis/sidebar`) list each name with its meaning.\n\n## Accessibility\n\n- The body navigation renders inside a named `<nav>` landmark (`data-sidebar=\"nav\"`), so assistive tech can jump straight to it and tell it apart from the activity bar's own landmark.\n- Active links set `aria-current=\"page\"`.\n- Collapsible rows and groups set `aria-expanded`.\n- Disabled buttons use `disabled`; disabled links omit `href`, use `aria-disabled` and `tabindex=-1`, and block activation.\n- Mobile drawer includes a backdrop button labelled \"Close Sidebar\".\n- Icon-collapsed rows keep their labels mounted and visually fade them, preserving accessible names and stable icon geometry.\n- Search, group controls, actions, and nested rows become inert before collapse can remove or hide them; focus returns to the owning visible row.\n- A row's `suffix` is a sibling of its link or button, between it and the row's `action`: Tab reaches its controls right after the row, its content describes the row (`aria-describedby`), and it is `inert` and `aria-hidden` whenever the Sidebar is collapsed to icons, focus inside it returning to the row.\n- A submenu row's `action` is a sibling of its link, so Tab reaches the action button right after the row; its trigger takes `label` as its accessible name, and Escape closes its menu and returns focus to the trigger. It is `inert` and `aria-hidden` whenever the Sidebar is collapsed to icons.\n- Collapsible groups, tree branches, and inline submenus are disclosure panels (the shared `disclosurePanel` Collapsible and Accordion use): the toggle sets `aria-expanded` and, while the panel is rendered, `aria-controls` to its id; the panel carries `data-open` / `data-closed` and `data-entering` / `data-exiting`, exposes `--disclosure-panel-height` while it animates, and stays `inert` while it closes. Open, close and sidebar-collapse changes animate the panel's height and reverse from where they are when interrupted; the `motion` slot's duration, delay and easing time them.\n- Tree roots use menu-row styling and nested tree nodes use submenu-row styling. In desktop icon mode, a root folder, and a menu row with `items` and no `href`, `view` or `menu`, is a disclosure button (`aria-expanded`, `aria-controls`) that opens a flyout holding a named `<nav>` of links, with NavigationMenu's semantics: nested folders are named sub-lists, a row with an `onclick` and no `href` is a button, a disabled sub entry stays listed without a destination, and a press on any row closes the flyout (the row's own handler runs first, and `preventDefault()` keeps it open). Opening from the keyboard keeps focus on the row; Tab enters the flyout's links, Tab past the last one continues after the row and closes it, Shift+Tab from the first returns to the row, and Escape closes it and refocuses the row. There is no `role=\"menu\"`; root leaves retain direct navigation and tooltips.\n- Row menus, flyouts and tooltips open toward the page: at the inline end of a `left` sidebar and the inline start of a `right` one, the side the layout row puts the page on in either reading direction (a `left` sidebar is the row's start, on the right in right-to-left). An explicit `menuSide` wins.\n- When `rail` and `resizable` are both enabled, one edge control owns click-to-toggle, drag resize, and keyboard resize without overlapping hitboxes.\n- Hidden offcanvas sidebars keep that combined edge control while temporarily revealed. Resizing does not pin the sidebar open; leaving the panel, trigger, and handle tolerance re-hides it without discarding the configured width.\n- Both peeks (edge reveal and `expandOnHover`) stay open while focus is inside the panel or while an overlay opened from inside it is open, including nested submenus. They release about 120ms after the pointer, focus, and every such overlay are gone.\n- The activity bar is its own `<nav>` landmark with a `<ul>` of items, a roving tabindex, and ArrowUp/ArrowDown/Home/End navigation that loops and skips disabled items. Tab lands on the `isActive` item. Each square takes its accessible name from `label`, with a string or number `badge` appended to it, and shows `label` as a tooltip on hover and focus.\n- A hidden offcanvas panel is `inert`, so Tab never lands in a panel parked off screen; a peek makes it interactive again.\n- Views: when focus sat in the view being replaced, it lands on the back row after going deeper and on the row that opened the view after coming back. A view change from outside the panel (a route) leaves focus where it is. The back swipe is a shortcut; the back row stays the accessible path.\n\n## Notes\n\n- Dropdown menus use Entasis `PopupMenu` and `MenuItem[]`.\n- The component uses semantic Entasis tokens. Do not add shadcn `sidebar-*` color tokens.\n- Snippet icons from `entasis/icons/*` are the preferred icon format.\n";
1
+ export declare const sidebarDescription = "\n# Sidebar Component\n\nSidebar navigation with data-driven groups, icon collapse, mobile drawer behavior,\nrecursive tree groups, header/footer rows, search, actions, and snippet escape hatches.\n\n## Import\n\n```svelte\n<script lang=\"ts\">\n\timport { Sidebar, type SidebarGroup } from 'entasis/sidebar';\n</script>\n```\n\n## Basic Usage\n\n```svelte\n<script lang=\"ts\">\n\timport { Sidebar, type SidebarGroup } from 'entasis/sidebar';\n\timport { houseIcon } from 'entasis/icons/house';\n\timport { gearIcon } from 'entasis/icons/gear';\n\n\tconst items: SidebarGroup[] = [\n\t\t{\n\t\t\tlabel: 'Workspace',\n\t\t\titems: [\n\t\t\t\t{ label: 'Dashboard', href: '/', icon: houseIcon, isActive: true },\n\t\t\t\t{ label: 'Settings', href: '/settings', icon: gearIcon }\n\t\t\t]\n\t\t}\n\t];\n</script>\n\n<Sidebar items={items}>\n\t{#snippet children({ toggle })}\n\t\t<header>\n\t\t\t<button type=\"button\" onclick={toggle}>Toggle</button>\n\t\t</header>\n\t\t<main>Page content</main>\n\t{/snippet}\n</Sidebar>\n```\n\n## AI-Safe Usage Contract\n\n1. Use `items` for normal navigation. Use `content` only when data-driven rows cannot express the layout.\n2. Use local Entasis icon snippets such as `houseIcon`, not Lucide component constructors.\n3. Use `MenuItem[]` from `entasis/menu` for `menu` and action dropdowns.\n4. Do not combine `menu` with `href` or `onclick` on the same row; use `action` for a trailing row menu.\n5. Keep `children`, `header`, `content`, `footer`, `banner`, and action snippets pure; they receive `SidebarApi`.\n6. Use `collapsible=\"icon\"` for icon rail behavior, `collapsible=\"offcanvas\"` for hidden desktop panels, and `collapsible=\"none\"` for fixed sidebars. Icon collapse automatically falls back to offcanvas when any data-driven row lacks an icon.\n7. Offcanvas sidebars reveal over the content from the screen edge by default when hidden; set `edgeReveal={false}` to disable that. A revealed hidden sidebar keeps its resize handle and dismisses through a small rectangular pointer tolerance.\n8. Set `keyboardShortcut={false}` when embedding Sidebar inside another shortcut-heavy surface.\n9. Sidebar owns navigation, resize mechanics, the lower application wall, and variant surface geometry. AppShell forwards its variant and composes PageShell inside that surface.\n10. Use `size` for typography, icon scale, and item height. Use `density` independently for section padding, gaps, and submenu spacing.\n11. Use `activityBar` for a persistent icon rail outside the panel (section switching, workspaces). Every item needs an `icon` and a `label`; the label is the accessible name and the tooltip. It is layout mode only: `mode=\"panel\"` renders the navigation panel alone. The rail does not change the panel by itself: keep the selected rail item in state from `onSelect`, mark it `isActive`, and either derive the Sidebar's `items` from it or, for an animated switch, give each rail item a view in `views` and set `view` from it.\n12. Use `expandOnHover` only with `collapsible=\"icon\"`. It is a temporary peek, not a toggle: the persisted collapsed state never changes, while the peeked panel renders with expanded semantics.\n13. Use `views` when the panel's contents change as the user moves through the app: sections switched from the activity bar or the route, and nested menus that open inside the panel. Key each view, set `parent` on nested ones, open them with a row's `view`, and drive `view` from state or the URL. Do not hand-animate `items` swaps.\n\n## Data Model\n\n### SidebarGroup\n- **label**: string - Group label, hidden in icon-collapsed mode.\n- **items**: SidebarMenuEntry[] - Menu rows.\n- **tree**: SidebarTreeNode[] - Recursive tree rows instead of menu items.\n- **action**: SidebarMenuActionDescriptor | SidebarMenuActionDescriptor[] | Snippet<[SidebarApi]> - Group actions pinned to the label's end. The label truncates on one line before them. Pass an array to pin several affordances (a `+` and a drag handle) to one group header; each renders as its own icon-only ghost button.\n- **collapsible**: boolean - Makes the group label a toggle.\n- **defaultOpen**: boolean - Initial collapsible group state.\n- **separator**: boolean - Divider before the group.\n\n### SidebarMenuEntry\n- **label**: string - Visible row label.\n- **icon**: SidebarIcon - Entasis icon snippet or string.\n- **iconColor**: Colors - Role tint for the leading icon, applied through `data-color`.\n- **iconVariant**: 'bare' | 'tile' - Leading icon treatment. `tile` paints a rounded square (`bg-color-muted text-color-muted-readable`) around the glyph, so per-project colour chips come from the role scale instead of hand-built markup.\n- **media**: Snippet<[SidebarApi, SidebarMenuEntry]> - Leading media in place of `icon`: a face, an avatar, a logo. It renders inside the row (`data-sidebar=\"menu-media\"`, drawn by the `menuMedia` theme part) in a box at the Sidebar's media size (32px at `size=\"normal\"`; `iconSize=\"large\"` gives 36px), which an `svg` or `img` placed directly inside fills. It receives the row's entry, so one snippet draws every face (`{#snippet face(api, entry)}`). Collapsed to icons it stays visible and centred, as an icon does, and it counts as the row's icon for icon-collapse eligibility.\n- **description**: string | Snippet - Second line under the label: the last message, a path, a status. Drawn by the `menuSecondary` recipe (lighter, regular weight) and truncated to one line. The row takes the large height (`h-14` at `size=\"normal\"`) unless the entry sets its own `size`. The label, its meta and the description sit in one column (`menuText`, `menuTitleRow`, `menuTitle`, `menuDescriptionRow` theme parts) that fades with the labels in icon mode. On such a two-line row the `badge` and the `suffix` sit at the end of the description's line, which reserves the suffix's width, so the label's line and its `meta` end on the same column on every row, with or without a suffix.\n- **meta**: string | Snippet - Short text at the end of the label's line: a time, \"Hier\" (`data-sidebar=\"menu-meta\"`, `menuMeta` theme part). The label truncates before it. On a one-line row it ends before any `suffix`; on a row with a `description` it ends at the row's end, above the suffix. Left out in icon-collapsed mode, like `badge`. The row's accessible name reads label, meta, then description.\n- **href**: string - Render as an anchor. Mutually exclusive with menu.\n- **onclick**: (event: MouseEvent) => void - Native click handler for button or anchor rows. Mutually exclusive with menu. On a row with `items` and no `href`, it runs before the submenu toggles, inline or in the collapsed flyout; `event.preventDefault()` skips the toggle.\n- **isActive**: boolean - Adds active styling and `aria-current=\"page\"`.\n- **disabled**: boolean - Disables button rows and marks anchor rows disabled.\n- **badge**: string | number - Trailing count/status, hidden in icon mode. It follows the label inside the row (before any chevron), so the label truncates before it whatever its width. On a row with an `action`, the badge steps aside while the action shows (hover, focus, open menu) from `md` up, and sits just before the always-visible action below `md`: the two never overlap.\n- **tooltip**: string - Entasis Tooltip content in icon mode. Defaults to label.\n- **items**: SidebarMenuSubEntry[] - Inline nested menu. Collapsed to icons on desktop, a row with `items` and no `href`, `view` or `menu` opens them in a flyout of links instead.\n- **collapsible**: boolean - Set false for an always-open submenu.\n- **defaultOpen**: boolean - Initial nested menu state.\n- **menu**: MenuItem[] - Popup menu opened from the full row. Mutually exclusive with href/onclick.\n- **menuSide** / **menuAlign**: SidebarMenuSide / 'start' | 'center' | 'end' - Where that menu opens. `menuSide` takes a physical side (`top`, `right`, `bottom`, `left`) or a logical one (`inline-start`, `inline-end`, mirrored in right-to-left). Left out, a desktop menu opens toward the page (`inline-end` of a left sidebar, `inline-start` of a right one, so it follows the reading direction) aligned to the row's start, and a mobile one below the row aligned to its end. The same pair sits on action descriptors and on `headerButton` / `footerButton`.\n- **view**: string - Key of the `views` entry the row opens, sliding it in; the row shows a trailing chevron. Mutually exclusive with href, menu and items.\n- **action**: SidebarMenuActionDescriptor | Snippet<[SidebarApi]> - Hover/focus trailing action.\n- **suffix**: Snippet<[SidebarApi, SidebarMenuEntry]> - Content at the row's end, beside the label: a count, a `Chip`, an icon, a status dot, its own `PopupMenu` trigger. It receives the row's own entry, so rows built as data share one snippet declared in markup (`{#snippet suffix(api, entry)}`) and tell themselves apart by `entry.href`. It renders beside the row's link or button, never inside it (`data-sidebar=\"menu-suffix\"`, drawn by the `suffix` theme part), so it may hold controls: a click or a keypress inside it never follows the row's `href` nor toggles it, and Tab reaches its controls right after the row, before the `action`. The row's link or button is `aria-describedby` the suffix, so the row reads with it (\"Dossiers\", \"20\"). The label truncates before it: the row reserves its measured width (`--sidebar-suffix-width`), and the `suffix` part bounds it (`max-w-[40%]` of the row), so a wide suffix clips instead of breaking the row. With an `action`, it stays visible just before the action at every width (the row keeps the action's room, and a `badge` then stays put instead of stepping aside). Hidden, `inert` and `aria-hidden` in icon-collapsed mode; shown in a hover peek and in the mobile drawer.\n\n### SidebarMenuSubEntry\nA row of an inline submenu (`items` on a menu entry). It carries the same row features as a\ntop-level entry, so nested navigation (pages filed in folders) keeps its menus, pins and tints\nwithout flattening the tree. Every field below `size` is optional, and a sub entry that sets none\nof them renders exactly as a plain one.\n- **label**: string - Visible row label.\n- **href** / **onclick** - Render as an anchor, or a button with a native click handler.\n- **icon**: SidebarIcon - Leading icon.\n- **isActive** / **disabled** / **size** - As on a top-level entry; `size` defaults to the Sidebar size.\n- **iconColor**: Colors - Role tint for the leading icon, applied through `data-color`.\n- **iconVariant**: 'bare' | 'tile' - `tile` paints the same tinted rounded square as a top-level row.\n- **media** / **description** / **meta** - As on a top-level entry (`media` receives the sub entry). A sub row with a `description` or a `media` takes the two-line height (40 / 48 / 56px by size).\n- **badge**: string | number - Trailing count or status; the label truncates before it. It shares the row's end column with the parent rows' badges and makes way for the row's `action` as a top-level badge does.\n- **tooltip**: string - Tooltip text. The label is always visible on an inline sub row, so the tooltip is extra information: shown whenever one is set, never on mobile. The collapsed row's flyout lists only each entry's label, icon, `href` or `onclick`, and `isActive`/`disabled` state: it shows no tooltip, `badge`, `suffix` or `action`.\n- **class**: string - Classes applied to the row button.\n- **action**: SidebarMenuActionDescriptor | Snippet<[SidebarApi]> - Trailing action pinned to the row's end (`data-sidebar=\"menu-sub-action\"`), drawn by the `subAction` theme part: hidden until the row is hovered or focused from `md` up, kept visible while its menu is open, always visible below `md`. The label truncates before it.\n- **suffix**: Snippet<[SidebarApi, SidebarMenuSubEntry]> - Content at the row's end, beside the label: a count, a `Chip`, an icon, a status dot, its own `PopupMenu` trigger. It receives the row's own entry, so rows built as data share one snippet declared in markup (`{#snippet suffix(api, entry)}`) and tell themselves apart by `entry.href`. It renders beside the row's link or button, never inside it (`data-sidebar=\"menu-sub-suffix\"`, drawn by the `suffix` theme part), so it may hold controls: a click or a keypress inside it never follows the row's `href` nor toggles it, and Tab reaches its controls right after the row, before the `action`. The row's link or button is `aria-describedby` the suffix, so the row reads with it (\"Dossiers\", \"20\"). The label truncates before it: the row reserves its measured width (`--sidebar-suffix-width`), and the `suffix` part bounds it (`max-w-[40%]` of the row), so a wide suffix clips instead of breaking the row. With an `action`, it stays visible just before the action at every width (the row keeps the action's room, and a `badge` then stays put instead of stepping aside). Hidden, `inert` and `aria-hidden` in icon-collapsed mode; shown in a hover peek and in the mobile drawer.\n\n```svelte\n<script lang=\"ts\">\n\timport { Sidebar, type SidebarGroup } from 'entasis/sidebar';\n\timport { folderIcon } from 'entasis/icons/folder';\n\timport { fileTextIcon } from 'entasis/icons/fileText';\n\timport { pencilSimpleIcon } from 'entasis/icons/pencilSimple';\n\timport { pushPinIcon } from 'entasis/icons/pushPin';\n\timport { trashIcon } from 'entasis/icons/trash';\n\n\tconst items: SidebarGroup[] = [\n\t\t{\n\t\t\tlabel: 'Pages',\n\t\t\titems: [\n\t\t\t\t{\n\t\t\t\t\tlabel: 'Planning',\n\t\t\t\t\ticon: folderIcon,\n\t\t\t\t\tdefaultOpen: true,\n\t\t\t\t\titems: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tlabel: 'Q3 roadmap',\n\t\t\t\t\t\t\thref: '/pages/q3-roadmap',\n\t\t\t\t\t\t\ticon: fileTextIcon,\n\t\t\t\t\t\t\ticonColor: 'success',\n\t\t\t\t\t\t\ticonVariant: 'tile',\n\t\t\t\t\t\t\tbadge: 2,\n\t\t\t\t\t\t\taction: {\n\t\t\t\t\t\t\t\tlabel: 'More options for Q3 roadmap',\n\t\t\t\t\t\t\t\tmenu: [\n\t\t\t\t\t\t\t\t\t{ type: 'option', title: 'Rename', prefix: pencilSimpleIcon },\n\t\t\t\t\t\t\t\t\t{ type: 'option', title: 'Pin', prefix: pushPinIcon },\n\t\t\t\t\t\t\t\t\t{ type: 'option', title: 'Delete', prefix: trashIcon, color: 'danger' }\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{ label: 'Retro notes', href: '/pages/retro-notes', icon: fileTextIcon }\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t]\n\t\t}\n\t];\n</script>\n\n<Sidebar {items} />\n```\n\nA conversation list built as data: each row leads with a face (`media`), reads a name and a time\n(`meta`) over the last message (`description`), and ends with an unread dot (`suffix`). The\nrows keep `isActive`, `href`, tooltips and icon collapse.\n\n```svelte\n<script lang=\"ts\">\n\timport {\n\t\tSidebar,\n\t\ttype SidebarApi,\n\t\ttype SidebarGroup,\n\t\ttype SidebarMenuEntry,\n\t\ttype SidebarMenuSubEntry\n\t} from 'entasis/sidebar';\n\n\tconst agents = [\n\t\t{ id: 'ada', name: 'Ada', time: '10:42', last: 'The report is ready.', unread: true },\n\t\t{ id: 'brune', name: 'Brune', time: 'Hier', last: 'Venue booked for Thursday.' }\n\t];\n\n\tconst items: SidebarGroup[] = [\n\t\t{\n\t\t\tlabel: 'Agents',\n\t\t\titems: agents.map((agent) => ({\n\t\t\t\tlabel: agent.name,\n\t\t\t\thref: `/agents/${agent.id}`,\n\t\t\t\tmedia: face,\n\t\t\t\tmeta: agent.time,\n\t\t\t\tdescription: agent.last,\n\t\t\t\tisActive: agent.id === 'ada',\n\t\t\t\tsuffix: agent.unread ? unread : undefined\n\t\t\t}))\n\t\t}\n\t];\n</script>\n\n{#snippet face(_api: SidebarApi, entry: SidebarMenuEntry | SidebarMenuSubEntry)}\n\t<svg viewBox=\"0 0 40 40\" aria-hidden=\"true\">\n\t\t<circle cx=\"20\" cy=\"20\" r=\"20\" fill=\"currentColor\" opacity=\"0.2\" />\n\t\t<text x=\"20\" y=\"25\" text-anchor=\"middle\" font-size=\"14\">{entry.label[0]}</text>\n\t</svg>\n{/snippet}\n\n{#snippet unread()}\n\t<span class=\"bg-primary block size-2.5 rounded-full\" aria-label=\"Unread\"></span>\n{/snippet}\n\n<Sidebar {items} iconSize=\"large\" collapsible=\"icon\" />\n```\n\n### SidebarView\nOne named panel content in `views`.\n- **label**: string - The view's name, shown on the back row of the views nested under it.\n- **parent**: string - Key of the view this one is nested under. A nested view opens with a back row to its parent (named \"Back, <parent label>\"), and on mobile a swipe toward the inline end goes back. A view without `parent` is a top-level section.\n- **items**: SidebarGroup[] / **content**: Snippet<[SidebarApi]> - The view's body.\n- **headerButton**, **search**, **headerMenu**, **header**, **footerButton**, **footerMenu**, **footer** - Header and footer props for this view. Each one left undefined comes from the parent view, then from the Sidebar's own prop; `null` removes an inherited one.\n\nChanging the view slides the two views side by side, like pages: a deeper view comes in from the inline end while the old one leaves to the start, a shallower one slides back the other way, and between views at the same depth (sections) the later one in `views` counts as forward. When both views get every header and footer prop from the same place, the header and footer stay still and only the menu slides; when a view changes any of them, the whole panel slides as one page. `api.view` reads the current view and `api.setView(key)` changes it from a snippet.\n\n### SidebarActivityBar\nIcon rail pinned to the outer edge of the sidebar, visible in every display state.\n- **items**: SidebarActivityBarItem[] - Items rendered from the top.\n- **footerItems**: SidebarActivityBarItem[] - Items pinned to the end of the column.\n- **header** / **footer**: Snippet - Custom content before the first item and after the pinned ones.\n- **width**: string (default '3rem') - Column thickness, published as `--sidebar-width-activity`.\n- **label**: string - Accessible name for the column landmark. Set it whenever the panel also renders navigation.\n- **onSelect**: ({ item, index }) => void - Fires after an item is activated. `index` counts `items` then `footerItems`.\n\n### SidebarActivityBarItem\n- **icon**: SidebarIcon (required) - Icon rendered in the square.\n- **label**: string (required) - Accessible name and default tooltip; the square shows no text.\n- **id**: string - Stable render key.\n- **href** / **target** / **rel** - Render an anchor instead of a button.\n- **onclick**: (event: MouseEvent) => void - Native click handler.\n- **isActive**: boolean - Adds active styling and `aria-current=\"page\"`.\n- **badge**: string | number | Snippet - Corner badge pinned to the outer top corner. An empty string renders a bare dot. A string or number badge joins the accessible name (`\"Alerts, 3\"`); a dot and a Snippet badge are decorative, so put their meaning in `label`.\n- **disabled**: boolean - Blocks activation and skips the item during keyboard navigation.\n- **tooltip**: string | false - Tooltip override; `false` suppresses it.\n\n### SidebarMenuButtonItem\nUse for `headerButton`, `footerButton`, or direct `<SidebarMenuButton />` rows.\n- **icon**: SidebarIcon - Leading logo/icon.\n- **avatar**: { src?: string; alt?: string; fallback?: string } - Leading avatar.\n- **variant**: 'default' | 'brand' | 'compact'.\n- **title**: string - Primary text.\n- **subtitle**: string - Secondary text.\n- **trailing**: SidebarIcon | false | SidebarMenuActionDescriptor - Trailing content. An icon is decorative; an action descriptor (`{ icon, label, onclick }`, the same shape as a group action) renders its own icon-only ghost button beside the row, so a workspace card can carry its own collapse control without a custom `header` snippet. Descriptor handlers are `onclick(event, api)`, so `api.toggle()` is reachable.\n- **href** / **onclick** / **menu** - Choose link, button, or popup behavior. `onclick(event, api)` receives the SidebarApi beside the event, so `api.toggle()` is reachable from the row itself.\n- **menuIconClass**: string - Class override for option icons inside the popup menu.\n\n## Props\n\n### State\n- **open**: boolean (bindable, default true) - Desktop expanded state.\n- **defaultOpen**: boolean (default true) - Initial desktop state when `open` is omitted.\n- **onOpenChange**: (open: boolean) => void - Called once for a library-originated desktop state change. Repeated requests and parent prop updates stay silent.\n- **onDisplayStateChange**: (state: SidebarDisplayState) => void - Called once for a library-originated semantic display-state change.\n- **view**: string (bindable) - Key of the view on screen when `views` is set. Defaults to `defaultView`, then the first view.\n- **defaultView**: string - Initial view when `view` is omitted.\n- **onViewChange**: (view: string) => void - Called once for a library-originated view change: a view row, a back row, a swipe. Parent prop updates stay silent; with a route-driven `view`, navigate here.\n- **api.displayState**: 'expanded' | 'collapsed' | 'hidden' - Semantic desktop state; hidden means closed offcanvas. A hover peek does not change it.\n- **api.isPeeking**: boolean - True while a hover peek renders the collapsed panel at full width. Read it alongside `displayState` when a snippet hides content in icon mode.\n- **keyboardShortcut**: string | false (default 'b') - Ctrl/Cmd shortcut key.\n\n### Layout\n- **side**: 'left' | 'right' - Desktop and mobile side.\n- **variant**: 'admin' | 'floating' | 'inset' | 'split' | 'framed' - Sidebar geometry. `framed` is the admin geometry for a sidebar hosted inside a raised card (AppShell variant framed), with a `surface-recessed` well. `admin` renders the conventional full-height navigation column; `inset` integrates navigation into the lower wall with an inset content surface; `split` renders detached sidebar and content surfaces.\n- **size**: 'small' | 'normal' | 'large' (default 'normal') - Typography, icon, avatar, badge, leading-media, item-height, and search-height scale.\n- **iconSize**: 'small' | 'normal' | 'large' - Icon and leading-media scale inside the panel on its own; defaults to `size`. Menu rows, sub rows, group labels and the header button follow it; the activity bar keeps `size`.\n- **activeVariant**: 'soft' | 'outline' | 'solid' (default 'soft') - How active rows are painted. `soft` is the shared selected recipe (`selectedSoft`: `bg-selected-muted text-selected-muted-readable`), `solid` its loud counterpart (`selectedSolid`: `bg-selected text-selected-contrast`), `outline` a bordered surface card (`bg-surface border border-neutral-muted text-neutral`) that reads as a raised card on a tinted well. Each row carries the choice as `data-active-variant`, so no descendant selector is needed to restyle selection.\n- **density**: 'compact' | 'normal' | 'comfortable' (default 'normal') - Section padding, group padding, gaps, horizontal inset, and submenu spacing.\n- **collapsible**: 'offcanvas' | 'icon' | 'none' - Collapse behavior. Icon mode requires icons on every data-driven row and otherwise resolves to offcanvas.\n- **collapseIcon**: DisclosureIndicator \u2014 'chevron' | 'plus-minus' | 'none' (default 'chevron') - Disclosure indicator drawn on collapsible menu rows. 'none' renders no indicator.\n- **mode**: 'layout' | 'panel' - Full resizing layout or only the visible navigation panel.\n- **frame**: 'viewport' | 'contained' - Standalone Sidebar positioning. Viewport mode uses Theme's dynamic window-height token; contained mode fills a positioned parent.\n- **width**: string - Expanded width.\n- **widthIcon**: string - Icon-collapsed width.\n- **widthMobile**: string - Mobile drawer width. The drawer runs the full screen height, flush with its edge (no inset); an activity bar adds its own width beside it. A swipe toward its edge closes it, from anywhere on it, rows included. Following a link in the panel closes it too (a plain click, also when a client-side router handles it); the activity bar's links, buttons, and clicks that open elsewhere (a modifier key, `target=\"_blank\"`, `download`) keep it open.\n- **rail**: boolean | 'line' | 'thumb' - Edge toggle rail. `true` keeps the thin line style; `thumb` renders a short visible handle with the same full-height hitbox. The appearance is preserved when the rail shares the resize control.\n- **activityBar**: SidebarActivityBar - Icon rail pinned outside the panel, in layout mode only (`mode=\"panel\"` renders the panel alone and ignores it). It never slides off screen: only the panel takes the offcanvas offset, and the reserved layout column is the panel width plus the rail width. It wears the surface of the variant's panel: a hairline column on the canvas for `admin`, the recessed well for `framed`, borderless on the canvas for `inset`, and a card of its own (the panel's radius, edge and gutter) for `floating` and `split`. Every gutter between the activity bar, the panel and the page, and between them and the frame edge, is `--space-md`, so it scales with the theme's spacing. With `inset` and `split`, hiding the panel normally lets the page drop its frame and fill the edge; with an activity bar the rail is still beside the page, so the page keeps its framed form (padding, rounding, AppShell's border and shadow), and a `split` page takes the gutter the panel used to supply. The wrapper carries `data-page-flush` and the `main` part a `flush` variant only when the page does fill the edge. On mobile it stays a column at the drawer's outer edge, beside the panel, as the hairline column on the canvas whatever the variant.\n- **expandOnHover**: boolean (default false) - With `collapsible=\"icon\"`, hovering or focusing into the collapsed panel expands it to `width` over the page (`data-peek=\"true\"`) while the reserved column stays at `widthIcon`, so page content does not reflow. The persisted collapsed state is untouched, and the peeked panel renders exactly like an expanded one: group headers, badges, search, inline submenus, and inline tree branches all come back, and the rail or resize handle travels to its inner edge.\n- **edgeReveal**: boolean (default true) - Pointer/focus edge preview for hidden offcanvas sidebars. Hover reveal overlays content, remains resizable when configured, and re-hides after the pointer leaves its small rectangular tolerance. Dragging the sidebar closed suppresses immediate hover reopening until the pointer leaves the edge trigger; toggle/click opens persistently.\n- **resizable**: boolean | SidebarResizableOptions - Enables pointer and keyboard resizing while expanded, icon-collapsed, or temporarily edge-revealed. `minWidth` and `maxWidth` (a CSS length or a number of px; defaults `12rem` and `32rem`) bound both the drag and the keyboard resize; a `maxWidth` below `minWidth` throws. By default, collapse requires dragging 75% of `minWidth` beyond the minimum; override `collapseThreshold` for a custom boundary. Use `storageKey` to restore and persist the expanded width across sessions.\n - `onWidthChange({ width, isUserInteraction })` reports every expanded-width change with one named payload: continuously while the user resizes (`isUserInteraction: true`) and once when a stored width is restored (`isUserInteraction: false`).\n\n### Content\n- **items**: SidebarGroup[] - Data-driven body navigation.\n- **views**: Record<string, SidebarView> - Named panel contents, one on screen at a time, replacing `items` and `content`. See SidebarView.\n- **headerButton** / **footerButton**: SidebarMenuButtonItem - Sticky large rows.\n- **search**: SidebarSearch - Header search input; use its native `oninput` handler.\n- **headerMenu** / **footerMenu**: SidebarMenuEntry[] - Sticky quick menus.\n- Menu entries and nested entries accept `size: 'small' | 'normal' | 'large'` for row geometry.\n- **header**, **content**, **footer**, **children**, **banner**: Snippet<[SidebarApi]> - Escape hatches. The `header` snippet renders **first** in the header region, above `headerButton`, `search` and `headerMenu`.\n\n### Styling\n- **class**: string - Classes applied to the Sidebar root.\n- **theme**: SidebarThemeProps - Semantic part overrides such as `panel`, `header`, `nav`, `footer`, menu, search, rail, and mobile drawer parts. Every part, with the element it lands on, its variants and their default classes, is listed in the entasis skill's `theme-parts/sidebar.md`; the registry key is `sidebar`.\n\n## Motion\n\n- **motion** theme slot: the height animation shared by collapsible groups, inline submenus, and\n tree branches. Its `in` / `out` `duration`, `delay` and `easing` (motion tokens) time the\n open and the close; the panel animates its height, block padding, borders and margins.\n- Ladder: `<Theme components={{ sidebar: { motion } }}>` \u2192 `setSidebarTheme({ motion })` \u2192\n `theme.motion`. Reduced motion collapses it to 0.\n- The `view` variant (`part: 'view'`) is the pager between views: `in.x` / `out.x` is how far a view travels (default `'100%'`: the two views slide side by side like pages) while it fades between `opacity` (default 0) and 1, at the `slow` duration on the `enter` easing. The back swipe moves the same layers the view change would, with the same travel and opacity. Reduced motion makes the switch instant while a swipe still follows the finger.\n\n## Restyle recipes\n\nThe five asks that come up first, as the override to copy. Each one is rendered and asserted by\n`sidebar-recipes.svelte.test.ts`, so it cannot drift from the component. One rule behind them: a\ndefault written under a variant prefix (`data-[active-variant=solid]:data-active:bg-selected`,\n`data-[side=left]:border-r`) is only replaced by an override carrying the same prefixes; an\nunprefixed class coexists with it and loses on specificity. `theme-parts/sidebar.md` shows every\ndefault verbatim, prefixes included.\n\n- **Dark panel.** `dark` scopes the dark theme's variables to the panel, so every neutral ink inside\n flips with it (every theme also emits its variables on `.<name>`, so the class scopes the dark theme to one subtree); needs a theme named `dark`, which the documented setup declares.\n `theme={{ panel: { base: 'dark bg-slate-900' }, mobilePanel: { base: 'dark bg-slate-900' } }}`\n- **Active row in your colour.** `activeVariant=\"solid\"` plus the same prefix chain as the default:\n `theme={{ menuButton: { base: 'rounded-full', activeVariant: { solid: 'data-[active-variant=solid]:data-active:bg-indigo-600 data-[active-variant=solid]:data-active:text-white' } } }}`\n- **No hover change.** The overlay is a `::before` and the default also brightens the ink on hover:\n `theme={{ menuButton: { base: 'state-layer-none hover:text-inherit' }, subButton: { base: 'state-layer-none hover:text-neutral/70' } }}`\n- **Flat panel.** The edge is a prefixed `border-r` / `border-l` on the admin variant, the shadow a\n `raised-*` on the floating ones:\n `theme={{ panel: { base: 'raised-none', variant: { admin: 'data-[side=left]:border-r-0 data-[side=right]:border-l-0' } } }}`\n- **Row height.** A plain height beats the compound `h-control-*`: `theme={{ menuButton: { base: 'h-11' } }}`\n- **Bigger icons.** A prop, not a theme: `iconSize=\"large\"`.\n\n## Styling hooks\n\n`data-open` on a disclosure toggle is a presence attribute (before 0.12.0 a submenu toggle carried `data-open=\"true\"`); match it with `[data-open]` or `data-open:`.\n\nAttributes are presence attributes (set while the state holds, omitted otherwise) unless they carry a value.\n\n**Data attributes**\n- `data-state`: On the root: `expanded` or `collapsed`.\n- `data-display-state`: On the root: what is on screen, `expanded`, `collapsed` (the icon rail) or `hidden`.\n- `data-collapsible`: On the root: `icon` while collapsed to the icon rail, `offcanvas` while hidden, empty while expanded.\n- `data-side`: On the root, the panel and its rail and handles: `left` or `right`.\n- `data-variant`: On the root: the resolved `variant`.\n- `data-size`: On the root: the resolved `size`.\n- `data-density`: On the root: the resolved `density`.\n- `data-peek`: On the layout wrapper: `true` while a hover peek shows the collapsed panel at full width.\n- `data-active`: On a row: `true` while it is the current page (`isActive`).\n- `data-active-variant`: On a row: the resolved `activeVariant`.\n- `data-open`: On the toggle of a collapsible group, a submenu or a tree branch, and on its panel: present while it is open.\n- `data-closed`: On the toggle of a collapsible group, a submenu or a tree branch, and on its panel: present while it is closed, including during the exit transition.\n- `data-entering`: On a group, submenu or tree branch panel: present while its opening animation plays.\n- `data-exiting`: On a group, submenu or tree branch panel: present while its closing animation plays.\n- `data-popup-open`: On a row: present while one of its popups is open, its `menu`, its flyout on the collapsed rail, or its tooltip (every row on the collapsed rail, and every row with `tooltips=\"always\"`). Match `[aria-expanded=\"true\"]` to style an open menu or flyout only.\n- `data-sidebar`: The part (`sidebar`, `menu-button`, `menu-sub`, `group-content`, `rail`, ...).\n\n**CSS variables**\n- `--sidebar-width`: On the root: the expanded panel width.\n- `--sidebar-width-icon`: On the root: the width of the collapsed icon rail.\n- `--sidebar-width-mobile`: On the root: the width of the mobile drawer.\n- `--sidebar-suffix-width`: On a row with a `suffix` (its `menu-item` row wrapper or `menu-sub-item`): the suffix's measured width in px, reserved at the row's end so the label truncates before it.\n- `--disclosure-panel-height`: On a group, submenu or tree branch panel: its measured height in px while it animates, `auto` once open and `0px` once closed.\n\nThe typed maps `sidebarDataAttributes` and `sidebarCssVariables` (from `entasis/sidebar`) list each name with its meaning.\n\n## Accessibility\n\n- The body navigation renders inside a named `<nav>` landmark (`data-sidebar=\"nav\"`), so assistive tech can jump straight to it and tell it apart from the activity bar's own landmark.\n- Active links set `aria-current=\"page\"`.\n- Collapsible rows and groups set `aria-expanded`.\n- Disabled buttons use `disabled`; disabled links omit `href`, use `aria-disabled` and `tabindex=-1`, and block activation.\n- Mobile drawer includes a backdrop button labelled \"Close Sidebar\".\n- Icon-collapsed rows keep their labels mounted and visually fade them, preserving accessible names and stable icon geometry.\n- Search, group controls, actions, and nested rows become inert before collapse can remove or hide them; focus returns to the owning visible row.\n- A row's `suffix` is a sibling of its link or button, between it and the row's `action`: Tab reaches its controls right after the row, its content describes the row (`aria-describedby`), and it is `inert` and `aria-hidden` whenever the Sidebar is collapsed to icons, focus inside it returning to the row.\n- A submenu row's `action` is a sibling of its link, so Tab reaches the action button right after the row; its trigger takes `label` as its accessible name, and Escape closes its menu and returns focus to the trigger. It is `inert` and `aria-hidden` whenever the Sidebar is collapsed to icons.\n- Collapsible groups, tree branches, and inline submenus are disclosure panels (the shared `disclosurePanel` Collapsible and Accordion use): the toggle sets `aria-expanded` and, while the panel is rendered, `aria-controls` to its id; the panel carries `data-open` / `data-closed` and `data-entering` / `data-exiting`, exposes `--disclosure-panel-height` while it animates, and stays `inert` while it closes. Open, close and sidebar-collapse changes animate the panel's height and reverse from where they are when interrupted; the `motion` slot's duration, delay and easing time them.\n- Tree roots use menu-row styling and nested tree nodes use submenu-row styling. In desktop icon mode, a root folder, and a menu row with `items` and no `href`, `view` or `menu`, is a disclosure button (`aria-expanded`, `aria-controls`) that opens a flyout holding a named `<nav>` of links, with NavigationMenu's semantics: nested folders are named sub-lists, a row with an `onclick` and no `href` is a button, a disabled sub entry stays listed without a destination, and a press on any row closes the flyout (the row's own handler runs first, and `preventDefault()` keeps it open). Opening from the keyboard keeps focus on the row; Tab enters the flyout's links, Tab past the last one continues after the row and closes it, Shift+Tab from the first returns to the row, and Escape closes it and refocuses the row. There is no `role=\"menu\"`; root leaves retain direct navigation and tooltips.\n- Row menus, flyouts and tooltips open toward the page: at the inline end of a `left` sidebar and the inline start of a `right` one, the side the layout row puts the page on in either reading direction (a `left` sidebar is the row's start, on the right in right-to-left). An explicit `menuSide` wins.\n- When `rail` and `resizable` are both enabled, one edge control owns click-to-toggle, drag resize, and keyboard resize without overlapping hitboxes.\n- Hidden offcanvas sidebars keep that combined edge control while temporarily revealed. Resizing does not pin the sidebar open; leaving the panel, trigger, and handle tolerance re-hides it without discarding the configured width.\n- Both peeks (edge reveal and `expandOnHover`) stay open while focus is inside the panel or while an overlay opened from inside it is open, including nested submenus. They release about 120ms after the pointer, focus, and every such overlay are gone.\n- The activity bar is its own `<nav>` landmark with a `<ul>` of items, a roving tabindex, and ArrowUp/ArrowDown/Home/End navigation that loops and skips disabled items. Tab lands on the `isActive` item. Each square takes its accessible name from `label`, with a string or number `badge` appended to it, and shows `label` as a tooltip on hover and focus.\n- A hidden offcanvas panel is `inert`, so Tab never lands in a panel parked off screen; a peek makes it interactive again.\n- Views: when focus sat in the view being replaced, it lands on the back row after going deeper and on the row that opened the view after coming back. A view change from outside the panel (a route) leaves focus where it is. The back swipe is a shortcut; the back row stays the accessible path.\n\n## Notes\n\n- Dropdown menus use Entasis `PopupMenu` and `MenuItem[]`.\n- The component uses semantic Entasis tokens. Do not add shadcn `sidebar-*` color tokens.\n- Snippet icons from `entasis/icons/*` are the preferred icon format.\n";
@@ -73,6 +73,9 @@ recursive tree groups, header/footer rows, search, actions, and snippet escape h
73
73
  - **icon**: SidebarIcon - Entasis icon snippet or string.
74
74
  - **iconColor**: Colors - Role tint for the leading icon, applied through \`data-color\`.
75
75
  - **iconVariant**: 'bare' | 'tile' - Leading icon treatment. \`tile\` paints a rounded square (\`bg-color-muted text-color-muted-readable\`) around the glyph, so per-project colour chips come from the role scale instead of hand-built markup.
76
+ - **media**: Snippet<[SidebarApi, SidebarMenuEntry]> - Leading media in place of \`icon\`: a face, an avatar, a logo. It renders inside the row (\`data-sidebar="menu-media"\`, drawn by the \`menuMedia\` theme part) in a box at the Sidebar's media size (32px at \`size="normal"\`; \`iconSize="large"\` gives 36px), which an \`svg\` or \`img\` placed directly inside fills. It receives the row's entry, so one snippet draws every face (\`{#snippet face(api, entry)}\`). Collapsed to icons it stays visible and centred, as an icon does, and it counts as the row's icon for icon-collapse eligibility.
77
+ - **description**: string | Snippet - Second line under the label: the last message, a path, a status. Drawn by the \`menuSecondary\` recipe (lighter, regular weight) and truncated to one line. The row takes the large height (\`h-14\` at \`size="normal"\`) unless the entry sets its own \`size\`. The label, its meta and the description sit in one column (\`menuText\`, \`menuTitleRow\`, \`menuTitle\`, \`menuDescriptionRow\` theme parts) that fades with the labels in icon mode. On such a two-line row the \`badge\` and the \`suffix\` sit at the end of the description's line, which reserves the suffix's width, so the label's line and its \`meta\` end on the same column on every row, with or without a suffix.
78
+ - **meta**: string | Snippet - Short text at the end of the label's line: a time, "Hier" (\`data-sidebar="menu-meta"\`, \`menuMeta\` theme part). The label truncates before it. On a one-line row it ends before any \`suffix\`; on a row with a \`description\` it ends at the row's end, above the suffix. Left out in icon-collapsed mode, like \`badge\`. The row's accessible name reads label, meta, then description.
76
79
  - **href**: string - Render as an anchor. Mutually exclusive with menu.
77
80
  - **onclick**: (event: MouseEvent) => void - Native click handler for button or anchor rows. Mutually exclusive with menu. On a row with \`items\` and no \`href\`, it runs before the submenu toggles, inline or in the collapsed flyout; \`event.preventDefault()\` skips the toggle.
78
81
  - **isActive**: boolean - Adds active styling and \`aria-current="page"\`.
@@ -99,6 +102,7 @@ of them renders exactly as a plain one.
99
102
  - **isActive** / **disabled** / **size** - As on a top-level entry; \`size\` defaults to the Sidebar size.
100
103
  - **iconColor**: Colors - Role tint for the leading icon, applied through \`data-color\`.
101
104
  - **iconVariant**: 'bare' | 'tile' - \`tile\` paints the same tinted rounded square as a top-level row.
105
+ - **media** / **description** / **meta** - As on a top-level entry (\`media\` receives the sub entry). A sub row with a \`description\` or a \`media\` takes the two-line height (40 / 48 / 56px by size).
102
106
  - **badge**: string | number - Trailing count or status; the label truncates before it. It shares the row's end column with the parent rows' badges and makes way for the row's \`action\` as a top-level badge does.
103
107
  - **tooltip**: string - Tooltip text. The label is always visible on an inline sub row, so the tooltip is extra information: shown whenever one is set, never on mobile. The collapsed row's flyout lists only each entry's label, icon, \`href\` or \`onclick\`, and \`isActive\`/\`disabled\` state: it shows no tooltip, \`badge\`, \`suffix\` or \`action\`.
104
108
  - **class**: string - Classes applied to the row button.
@@ -150,6 +154,55 @@ of them renders exactly as a plain one.
150
154
  <Sidebar {items} />
151
155
  \`\`\`
152
156
 
157
+ A conversation list built as data: each row leads with a face (\`media\`), reads a name and a time
158
+ (\`meta\`) over the last message (\`description\`), and ends with an unread dot (\`suffix\`). The
159
+ rows keep \`isActive\`, \`href\`, tooltips and icon collapse.
160
+
161
+ \`\`\`svelte
162
+ <script lang="ts">
163
+ import {
164
+ Sidebar,
165
+ type SidebarApi,
166
+ type SidebarGroup,
167
+ type SidebarMenuEntry,
168
+ type SidebarMenuSubEntry
169
+ } from './index.ts';
170
+
171
+ const agents = [
172
+ { id: 'ada', name: 'Ada', time: '10:42', last: 'The report is ready.', unread: true },
173
+ { id: 'brune', name: 'Brune', time: 'Hier', last: 'Venue booked for Thursday.' }
174
+ ];
175
+
176
+ const items: SidebarGroup[] = [
177
+ {
178
+ label: 'Agents',
179
+ items: agents.map((agent) => ({
180
+ label: agent.name,
181
+ href: \`/agents/\${agent.id}\`,
182
+ media: face,
183
+ meta: agent.time,
184
+ description: agent.last,
185
+ isActive: agent.id === 'ada',
186
+ suffix: agent.unread ? unread : undefined
187
+ }))
188
+ }
189
+ ];
190
+ </script>
191
+
192
+ {#snippet face(_api: SidebarApi, entry: SidebarMenuEntry | SidebarMenuSubEntry)}
193
+ <svg viewBox="0 0 40 40" aria-hidden="true">
194
+ <circle cx="20" cy="20" r="20" fill="currentColor" opacity="0.2" />
195
+ <text x="20" y="25" text-anchor="middle" font-size="14">{entry.label[0]}</text>
196
+ </svg>
197
+ {/snippet}
198
+
199
+ {#snippet unread()}
200
+ <span class="bg-primary block size-2.5 rounded-full" aria-label="Unread"></span>
201
+ {/snippet}
202
+
203
+ <Sidebar {items} iconSize="large" collapsible="icon" />
204
+ \`\`\`
205
+
153
206
  ### SidebarView
154
207
  One named panel content in \`views\`.
155
208
  - **label**: string - The view's name, shown on the back row of the views nested under it.
@@ -172,6 +172,21 @@ export type SidebarMenuSubEntry = {
172
172
  onclick?: (event: MouseEvent) => void;
173
173
  /** Leading icon. */
174
174
  icon?: SidebarIcon;
175
+ /**
176
+ * Leading media in place of `icon` (a face, an avatar, a logo), in a box at the Sidebar's
177
+ * media size (`iconSize` scales it). An `svg` or `img` placed directly inside fills the box.
178
+ * The row takes the two-line height.
179
+ */
180
+ media?: Snippet<[SidebarApi, SidebarMenuSubEntry]>;
181
+ /**
182
+ * Second line under the label: a preview, a path, a status. Lighter, and truncated to one
183
+ * line. The row takes the two-line height.
184
+ */
185
+ description?: string | Snippet;
186
+ /**
187
+ * Short text at the end of the label's line: a time, « Hier ». The label truncates before it.
188
+ */
189
+ meta?: string | Snippet;
175
190
  /** Whether this entry represents the current page. */
176
191
  isActive?: boolean;
177
192
  /** Disable interaction. */
@@ -211,6 +226,22 @@ type SidebarMenuEntryBase = {
211
226
  label: string;
212
227
  /** Leading icon. */
213
228
  icon?: SidebarIcon;
229
+ /**
230
+ * Leading media in place of `icon` (a face, an avatar, a logo), in a box at the Sidebar's
231
+ * media size (`iconSize` scales it). An `svg` or `img` placed directly inside fills the box.
232
+ * It stays visible when the Sidebar collapses to icons, as an icon does.
233
+ */
234
+ media?: Snippet<[SidebarApi, SidebarMenuEntry]>;
235
+ /**
236
+ * Second line under the label: a preview, a path, a status. Lighter, and truncated to one
237
+ * line. The row then takes the large height (`size: 'large'` geometry) unless `size` is set.
238
+ */
239
+ description?: string | Snippet;
240
+ /**
241
+ * Short text at the end of the label's line: a time, « Hier ». The label truncates before it.
242
+ * Hidden in icon-collapsed mode, like `badge`.
243
+ */
244
+ meta?: string | Snippet;
214
245
  /** Role tint applied to the leading icon, through `data-color`. */
215
246
  iconColor?: Colors;
216
247
  /** Leading icon treatment. 'tile' paints a tinted rounded square around the glyph. */
@@ -187,12 +187,18 @@ export declare const sidebarTheme: {
187
187
  normal: string;
188
188
  large: string;
189
189
  };
190
+ lead: {
191
+ icon: string;
192
+ media: string;
193
+ };
190
194
  endReserve: {
191
195
  none: string;
192
196
  action: string;
193
197
  actionBelowMd: string;
194
198
  suffix: string;
195
199
  suffixAction: string;
200
+ suffixLine: string;
201
+ suffixLineAction: string;
196
202
  };
197
203
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
198
204
  menuLabel: (props?: import("../../utils/cva/types.ts").CVAClassProp | undefined) => string;
@@ -203,6 +209,23 @@ export declare const sidebarTheme: {
203
209
  large: string;
204
210
  };
205
211
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
212
+ menuText: (props?: import("../../utils/cva/types.ts").CVAClassProp | undefined) => string;
213
+ menuTitleRow: (props?: import("../../utils/cva/types.ts").CVAClassProp | undefined) => string;
214
+ menuTitle: (props?: import("../../utils/cva/types.ts").CVAClassProp | undefined) => string;
215
+ menuDescriptionRow: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
216
+ suffix: {
217
+ false: string;
218
+ true: string;
219
+ };
220
+ }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
221
+ menuMeta: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
222
+ size: {
223
+ small: string;
224
+ normal: string;
225
+ large: string;
226
+ };
227
+ }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
228
+ menuMedia: (props?: import("../../utils/cva/types.ts").CVAClassProp | undefined) => string;
206
229
  menuTrailing: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
207
230
  size: {
208
231
  small: string;
@@ -251,6 +274,12 @@ export declare const sidebarTheme: {
251
274
  actionBelowMd: string;
252
275
  suffix: string;
253
276
  suffixAction: string;
277
+ suffixLine: string;
278
+ suffixLineAction: string;
279
+ };
280
+ tall: {
281
+ false: string;
282
+ true: string;
254
283
  };
255
284
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
256
285
  menuAction: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
@@ -292,6 +321,10 @@ export declare const sidebarTheme: {
292
321
  none: string;
293
322
  action: string;
294
323
  };
324
+ line: {
325
+ one: string;
326
+ two: string;
327
+ };
295
328
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
296
329
  actionSlot: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
297
330
  size: {
@@ -748,12 +781,18 @@ export declare const setSidebarTheme: (theme: InferComponentTheme<{
748
781
  normal: string;
749
782
  large: string;
750
783
  };
784
+ lead: {
785
+ icon: string;
786
+ media: string;
787
+ };
751
788
  endReserve: {
752
789
  none: string;
753
790
  action: string;
754
791
  actionBelowMd: string;
755
792
  suffix: string;
756
793
  suffixAction: string;
794
+ suffixLine: string;
795
+ suffixLineAction: string;
757
796
  };
758
797
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
759
798
  menuLabel: (props?: import("../../utils/cva/types.ts").CVAClassProp | undefined) => string;
@@ -764,6 +803,23 @@ export declare const setSidebarTheme: (theme: InferComponentTheme<{
764
803
  large: string;
765
804
  };
766
805
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
806
+ menuText: (props?: import("../../utils/cva/types.ts").CVAClassProp | undefined) => string;
807
+ menuTitleRow: (props?: import("../../utils/cva/types.ts").CVAClassProp | undefined) => string;
808
+ menuTitle: (props?: import("../../utils/cva/types.ts").CVAClassProp | undefined) => string;
809
+ menuDescriptionRow: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
810
+ suffix: {
811
+ false: string;
812
+ true: string;
813
+ };
814
+ }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
815
+ menuMeta: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
816
+ size: {
817
+ small: string;
818
+ normal: string;
819
+ large: string;
820
+ };
821
+ }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
822
+ menuMedia: (props?: import("../../utils/cva/types.ts").CVAClassProp | undefined) => string;
767
823
  menuTrailing: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
768
824
  size: {
769
825
  small: string;
@@ -812,6 +868,12 @@ export declare const setSidebarTheme: (theme: InferComponentTheme<{
812
868
  actionBelowMd: string;
813
869
  suffix: string;
814
870
  suffixAction: string;
871
+ suffixLine: string;
872
+ suffixLineAction: string;
873
+ };
874
+ tall: {
875
+ false: string;
876
+ true: string;
815
877
  };
816
878
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
817
879
  menuAction: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
@@ -853,6 +915,10 @@ export declare const setSidebarTheme: (theme: InferComponentTheme<{
853
915
  none: string;
854
916
  action: string;
855
917
  };
918
+ line: {
919
+ one: string;
920
+ two: string;
921
+ };
856
922
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
857
923
  actionSlot: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
858
924
  size: {
@@ -1307,12 +1373,18 @@ export declare const useSidebarTheme: import("../../utils/cva/theme.ts").UseComp
1307
1373
  normal: string;
1308
1374
  large: string;
1309
1375
  };
1376
+ lead: {
1377
+ icon: string;
1378
+ media: string;
1379
+ };
1310
1380
  endReserve: {
1311
1381
  none: string;
1312
1382
  action: string;
1313
1383
  actionBelowMd: string;
1314
1384
  suffix: string;
1315
1385
  suffixAction: string;
1386
+ suffixLine: string;
1387
+ suffixLineAction: string;
1316
1388
  };
1317
1389
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
1318
1390
  menuLabel: (props?: import("../../utils/cva/types.ts").CVAClassProp | undefined) => string;
@@ -1323,6 +1395,23 @@ export declare const useSidebarTheme: import("../../utils/cva/theme.ts").UseComp
1323
1395
  large: string;
1324
1396
  };
1325
1397
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
1398
+ menuText: (props?: import("../../utils/cva/types.ts").CVAClassProp | undefined) => string;
1399
+ menuTitleRow: (props?: import("../../utils/cva/types.ts").CVAClassProp | undefined) => string;
1400
+ menuTitle: (props?: import("../../utils/cva/types.ts").CVAClassProp | undefined) => string;
1401
+ menuDescriptionRow: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
1402
+ suffix: {
1403
+ false: string;
1404
+ true: string;
1405
+ };
1406
+ }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
1407
+ menuMeta: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
1408
+ size: {
1409
+ small: string;
1410
+ normal: string;
1411
+ large: string;
1412
+ };
1413
+ }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
1414
+ menuMedia: (props?: import("../../utils/cva/types.ts").CVAClassProp | undefined) => string;
1326
1415
  menuTrailing: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
1327
1416
  size: {
1328
1417
  small: string;
@@ -1371,6 +1460,12 @@ export declare const useSidebarTheme: import("../../utils/cva/theme.ts").UseComp
1371
1460
  actionBelowMd: string;
1372
1461
  suffix: string;
1373
1462
  suffixAction: string;
1463
+ suffixLine: string;
1464
+ suffixLineAction: string;
1465
+ };
1466
+ tall: {
1467
+ false: string;
1468
+ true: string;
1374
1469
  };
1375
1470
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
1376
1471
  menuAction: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
@@ -1412,6 +1507,10 @@ export declare const useSidebarTheme: import("../../utils/cva/theme.ts").UseComp
1412
1507
  none: string;
1413
1508
  action: string;
1414
1509
  };
1510
+ line: {
1511
+ one: string;
1512
+ two: string;
1513
+ };
1415
1514
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
1416
1515
  actionSlot: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
1417
1516
  size: {
@@ -253,10 +253,25 @@ const defaultMenu = cva({
253
253
  // `suffix` and `suffixAction` hold a row's `suffix` (see `suffix`), which sits beside the row
254
254
  // rather than inside it: the row reserves the suffix's measured width (`--sidebar-suffix-width`,
255
255
  // its maximum width until measured), plus the row's own end padding or the action's box.
256
- const rowEndReserveKeys = { none: '', action: '', actionBelowMd: '', suffix: '', suffixAction: '' };
256
+ // On a two-line row the suffix sits on the description's line, which reserves its width itself
257
+ // (`menuDescriptionRow`), so the label's line runs to the row's end: `suffixLine` reserves
258
+ // nothing and `suffixLineAction` only the action's box.
259
+ const rowEndReserveKeys = {
260
+ none: '',
261
+ action: '',
262
+ actionBelowMd: '',
263
+ suffix: '',
264
+ suffixAction: '',
265
+ suffixLine: '',
266
+ suffixLineAction: ''
267
+ };
257
268
  const rowEndReserve = [
258
- { endReserve: 'action', size: ['small', 'normal'], class: 'pe-layout-lg' },
259
- { endReserve: 'action', size: 'large', class: 'pe-[calc(var(--spacing)*10)]' },
269
+ { endReserve: ['action', 'suffixLineAction'], size: ['small', 'normal'], class: 'pe-layout-lg' },
270
+ {
271
+ endReserve: ['action', 'suffixLineAction'],
272
+ size: 'large',
273
+ class: 'pe-[calc(var(--spacing)*10)]'
274
+ },
260
275
  { endReserve: 'actionBelowMd', size: ['small', 'normal'], class: 'max-md:pe-layout-lg' },
261
276
  { endReserve: 'actionBelowMd', size: 'large', class: 'max-md:pe-[calc(var(--spacing)*10)]' },
262
277
  {
@@ -304,13 +319,19 @@ const defaultMenuButton = cva({
304
319
  normal: '',
305
320
  large: ''
306
321
  },
322
+ // What leads the row. Collapsed to icons, a `media` row centres its media box rather than
323
+ // the icon glyph in the icon button.
324
+ lead: {
325
+ icon: '',
326
+ media: 'group-data-[collapsible=icon]:![padding-inline:calc((var(--sidebar-icon-button-width)-var(--sidebar-media-size))/2)]'
327
+ },
307
328
  endReserve: rowEndReserveKeys
308
329
  },
309
330
  compoundVariants: [
310
331
  ...rowEndReserve,
311
332
  // The suffix covers the row's end, so hovering it keeps the row's hover tint.
312
333
  {
313
- endReserve: ['suffix', 'suffixAction'],
334
+ endReserve: ['suffix', 'suffixAction', 'suffixLine', 'suffixLineAction'],
314
335
  class: 'group-has-[[data-sidebar=menu-suffix]:hover]/menu-row:before:opacity-[var(--state-hover-opacity)]'
315
336
  },
316
337
  { size: 'small', itemSize: 'small', class: 'h-control-sm' },
@@ -329,6 +350,7 @@ const defaultMenuButton = cva({
329
350
  size: 'normal',
330
351
  density: 'normal',
331
352
  itemSize: 'normal',
353
+ lead: 'icon',
332
354
  endReserve: 'none'
333
355
  }
334
356
  });
@@ -336,7 +358,7 @@ const defaultMenuLabel = cva({
336
358
  base: 'min-w-0 flex-1 truncate whitespace-nowrap opacity-100 transition-[opacity,transform] duration-normal ease-standard group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:translate-x-1 group-data-[collapsible=icon]:opacity-0'
337
359
  });
338
360
  const defaultMenuSecondary = cva({
339
- base: 'truncate text-neutral/70',
361
+ base: 'min-w-0 flex-1 truncate font-normal text-neutral/70',
340
362
  variants: {
341
363
  size: {
342
364
  small: 'text-xs',
@@ -348,6 +370,50 @@ const defaultMenuSecondary = cva({
348
370
  size: 'normal'
349
371
  }
350
372
  });
373
+ // A row with a `description` or a `meta` stacks its text: the label's line (the label, then the
374
+ // meta at its end) over the description. The column fades with the labels in icon mode.
375
+ const defaultMenuText = cva({
376
+ base: 'grid min-w-0 flex-1 text-start opacity-100 transition-[opacity,transform] duration-normal ease-standard group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:translate-x-1 group-data-[collapsible=icon]:opacity-0'
377
+ });
378
+ const defaultMenuTitleRow = cva({
379
+ base: 'flex min-w-0 items-baseline gap-sm whitespace-nowrap'
380
+ });
381
+ const defaultMenuTitle = cva({
382
+ base: 'min-w-0 flex-1 truncate'
383
+ });
384
+ // The description's line: the description, then the row's badge. With a `suffix`, the suffix
385
+ // sits on this line, so the line reserves its measured width and the label's line does not.
386
+ const defaultMenuDescriptionRow = cva({
387
+ base: 'flex min-w-0 items-center gap-sm',
388
+ variants: {
389
+ suffix: {
390
+ false: '',
391
+ true: 'pe-[calc(var(--sidebar-suffix-width,40%)+var(--space-sm))]'
392
+ }
393
+ },
394
+ defaultVariants: {
395
+ suffix: false
396
+ }
397
+ });
398
+ // A row's `meta`: a short text (a time, « Hier ») at the end of the label's line.
399
+ const defaultMenuMeta = cva({
400
+ base: 'text-neutral/70 shrink-0 font-normal tabular-nums group-data-[collapsible=icon]:hidden',
401
+ variants: {
402
+ size: {
403
+ small: 'text-xs',
404
+ normal: 'text-xs',
405
+ large: 'text-sm'
406
+ }
407
+ },
408
+ defaultVariants: {
409
+ size: 'normal'
410
+ }
411
+ });
412
+ // A row's `media` (a face, an avatar): a box at the media size, which an `svg` or `img` placed
413
+ // directly inside fills, over the row's icon sizing.
414
+ const defaultMenuMedia = cva({
415
+ base: 'flex size-[var(--sidebar-media-size)] shrink-0 items-center justify-center [&>img]:size-full [&>img]:object-cover [&>svg]:!size-full'
416
+ });
351
417
  const defaultMenuTrailing = cva({
352
418
  base: 'ms-auto shrink-0 opacity-100 transition-[opacity,transform] duration-normal ease-standard group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:translate-x-1 group-data-[collapsible=icon]:opacity-0 [&>svg]:size-[var(--sidebar-icon-size)]',
353
419
  variants: {
@@ -412,12 +478,20 @@ const defaultSubButton = cva({
412
478
  },
413
479
  // Room at the row's end for a pinned action, as on a top-level row. `none` adds nothing: a
414
480
  // plain row keeps its exact classes.
415
- endReserve: rowEndReserveKeys
481
+ endReserve: rowEndReserveKeys,
482
+ // A row with a `description` or a `media` takes the two-line height.
483
+ tall: {
484
+ false: '',
485
+ true: ''
486
+ }
416
487
  },
417
488
  compoundVariants: [
418
489
  ...rowEndReserve,
490
+ { tall: true, size: 'small', class: 'h-row-md' },
491
+ { tall: true, size: 'normal', class: 'h-row-lg' },
492
+ { tall: true, size: 'large', class: 'h-14' },
419
493
  {
420
- endReserve: ['suffix', 'suffixAction'],
494
+ endReserve: ['suffix', 'suffixAction', 'suffixLine', 'suffixLineAction'],
421
495
  class: 'group-has-[[data-sidebar=menu-sub-suffix]:hover]/menu-sub-item:before:opacity-[var(--state-hover-opacity)]'
422
496
  }
423
497
  ],
@@ -426,7 +500,8 @@ const defaultSubButton = cva({
426
500
  size: 'normal',
427
501
  density: 'normal',
428
502
  itemSize: 'normal',
429
- endReserve: 'none'
503
+ endReserve: 'none',
504
+ tall: false
430
505
  }
431
506
  });
432
507
  // A row's pinned action is a real small icon button: one box per Sidebar `size`, shared by menu
@@ -491,9 +566,18 @@ const defaultSuffix = cva({
491
566
  action: {
492
567
  none: '',
493
568
  action: ''
569
+ },
570
+ // Which line the suffix is centred on. A row with a description centres it on the
571
+ // description's line, half the label's line height below the row's middle.
572
+ line: {
573
+ one: '',
574
+ two: ''
494
575
  }
495
576
  },
496
577
  compoundVariants: [
578
+ { line: 'two', size: 'small', class: 'top-[calc(50%+0.5rem)]' },
579
+ { line: 'two', size: 'normal', class: 'top-[calc(50%+0.625rem)]' },
580
+ { line: 'two', size: 'large', class: 'top-[calc(50%+0.75rem)]' },
497
581
  { action: 'none', density: 'compact', class: 'end-[var(--space-sm)]' },
498
582
  { action: 'none', density: ['normal', 'comfortable'], class: 'end-[var(--space-md)]' },
499
583
  { action: 'action', size: ['small', 'normal'], class: 'end-[var(--layout-space-lg)]' },
@@ -502,7 +586,8 @@ const defaultSuffix = cva({
502
586
  defaultVariants: {
503
587
  size: 'normal',
504
588
  density: 'normal',
505
- action: 'none'
589
+ action: 'none',
590
+ line: 'one'
506
591
  }
507
592
  });
508
593
  // One icon-only ghost button box. `size` follows the descriptor, defaulting to the Sidebar size,
@@ -1162,6 +1247,12 @@ export const sidebarTheme = {
1162
1247
  menuButton: defaultMenuButton,
1163
1248
  menuLabel: defaultMenuLabel,
1164
1249
  menuSecondary: defaultMenuSecondary,
1250
+ menuText: defaultMenuText,
1251
+ menuTitleRow: defaultMenuTitleRow,
1252
+ menuTitle: defaultMenuTitle,
1253
+ menuDescriptionRow: defaultMenuDescriptionRow,
1254
+ menuMeta: defaultMenuMeta,
1255
+ menuMedia: defaultMenuMedia,
1165
1256
  menuTrailing: defaultMenuTrailing,
1166
1257
  subMenu: defaultSubMenu,
1167
1258
  treeSubMenu: defaultTreeSubMenu,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "entasis",
3
- "version": "0.13.6",
3
+ "version": "0.13.7",
4
4
  "license": "MIT",
5
5
  "scripts": {
6
6
  "dev": "vite dev",