entasis 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/components/Avatar/avatar.theme.js +3 -1
  2. package/dist/components/Avatar/avatarGroup.theme.js +4 -1
  3. package/dist/components/Form/Ask/ask.mcp.d.ts +1 -1
  4. package/dist/components/Form/Ask/ask.mcp.js +1 -1
  5. package/dist/components/Form/Field/Field.svelte +2 -2
  6. package/dist/components/Form/Field/Field.svelte.d.ts +6 -6
  7. package/dist/components/Form/Field/field.d.ts +12 -3
  8. package/dist/components/Form/Field/field.state.svelte.d.ts +8 -37
  9. package/dist/components/Form/Field/field.state.svelte.js +21 -4
  10. package/dist/components/Form/Form/Form.svelte +2 -1
  11. package/dist/components/Form/Form/FormFieldRenderer.svelte +57 -11
  12. package/dist/components/Form/Form/FormFieldRenderer.svelte.d.ts +3 -2
  13. package/dist/components/Form/Form/form.d.ts +52 -8
  14. package/dist/components/Form/Form/form.mcp.d.ts +1 -1
  15. package/dist/components/Form/Form/form.mcp.js +66 -0
  16. package/dist/components/Form/Form/form.registry.d.ts +28 -0
  17. package/dist/components/Form/Form/form.registry.js +62 -0
  18. package/dist/components/Form/Form/form.state.svelte.d.ts +49 -8
  19. package/dist/components/Form/Form/form.state.svelte.js +3 -2
  20. package/dist/components/Form/Form/index.d.ts +2 -1
  21. package/dist/components/Form/Form/index.js +1 -0
  22. package/dist/components/Form/Form/visibility.d.ts +32 -30
  23. package/dist/components/Form/MultiStepForm/multiStepForm.state.svelte.d.ts +28 -0
  24. package/dist/components/Marquee/Marquee.svelte +8 -6
  25. package/dist/components/Marquee/marquee.mcp.d.ts +1 -1
  26. package/dist/components/Marquee/marquee.mcp.js +11 -11
  27. package/dist/components/Marquee/marquee.theme.js +17 -8
  28. package/dist/components/Sidebar/SidebarMenuItem.svelte +9 -1
  29. package/dist/components/Sidebar/SidebarMenuSubItem.svelte +71 -6
  30. package/dist/components/Sidebar/SidebarMenuSubItem.svelte.d.ts +4 -1
  31. package/dist/components/Sidebar/sidebar.mcp.d.ts +1 -1
  32. package/dist/components/Sidebar/sidebar.mcp.js +62 -0
  33. package/dist/components/Sidebar/sidebar.props.d.ts +18 -0
  34. package/dist/components/Sidebar/sidebar.theme.d.ts +60 -0
  35. package/dist/components/Sidebar/sidebar.theme.js +40 -2
  36. package/dist/generated/componentContract.d.ts +1 -1
  37. package/dist/generated/componentContract.js +6 -0
  38. package/dist/generated/componentMcpRegistry.d.ts +4 -4
  39. package/package.json +1 -1
@@ -2,17 +2,24 @@ import { setComponentTheme, useComponentTheme } from '../../utils/cva/index.js';
2
2
  import { cva } from '../../utils/cva/index.js';
3
3
  // `--marquee-animation-*` is INTERNAL: the `pauseOnHover` and `reverse` variants use it to
4
4
  // reach the track's animation. Consumers use the props, not the properties.
5
+ //
6
+ // `--gap` is the marquee's one spacing value. The size variant sets it from the spacing scale on
7
+ // the root; the track spaces its copies with it, every copy spaces its items with it, and the loop
8
+ // steps by one copy plus that same gap. Declaring it once is what keeps the loop seamless at every
9
+ // size and theme spacing factor — a copy that redeclared it would step by a different distance
10
+ // than the gap it is drawn with, and jump once per cycle. Respace a marquee by setting `--gap` on
11
+ // the root (`class="[--gap:…]"`), never on the copies.
5
12
  const defaultMarquee = cva({
6
- base: 'group flex overflow-hidden relative',
13
+ base: 'group flex overflow-hidden relative gap-(--gap)',
7
14
  variants: {
8
15
  direction: {
9
16
  left: 'flex-row',
10
17
  up: 'flex-col'
11
18
  },
12
19
  size: {
13
- small: 'gap-md',
14
- normal: 'gap-xl',
15
- large: 'gap-layout-md'
20
+ small: '[--gap:var(--space-md)]',
21
+ normal: '[--gap:var(--space-xl)]',
22
+ large: '[--gap:var(--layout-space-md)]'
16
23
  },
17
24
  fade: {
18
25
  true: 'scroll-fade-static',
@@ -30,16 +37,18 @@ const defaultMarquee = cva({
30
37
  }
31
38
  });
32
39
  const defaultInner = cva({
33
- base: 'flex shrink-0 justify-around [--gap:1rem] whitespace-nowrap',
40
+ base: 'flex shrink-0 justify-around gap-(--gap) whitespace-nowrap',
34
41
  variants: {
35
42
  direction: {
36
43
  left: 'flex-row animate-marquee-left',
37
44
  up: 'flex-col animate-marquee-up'
38
45
  },
46
+ // The copy inherits the root's `--gap`; the variant stays so per-size theme overrides of
47
+ // the copy keep a slot to land in.
39
48
  size: {
40
- small: 'gap-md',
41
- normal: 'gap-xl',
42
- large: 'gap-layout-md'
49
+ small: '',
50
+ normal: '',
51
+ large: ''
43
52
  },
44
53
  pauseOnHover: {
45
54
  true: 'group-hover:[--marquee-animation-play-state:paused]',
@@ -255,7 +255,15 @@
255
255
  out:slideTransition={collapseMotion.out}
256
256
  >
257
257
  {#each item.items ?? [] as sub, index (sub.label + index)}
258
- <SidebarMenuSubItem {sub} {size} {activeVariant} {density} {theme} />
258
+ <SidebarMenuSubItem
259
+ {sub}
260
+ {api}
261
+ {size}
262
+ {activeVariant}
263
+ {density}
264
+ iconCollapsed={isIconCollapsed}
265
+ {theme}
266
+ />
259
267
  {/each}
260
268
  </ul>
261
269
  {/snippet}
@@ -1,29 +1,54 @@
1
1
  <script lang="ts">
2
+ import { tooltip } from '../Tooltip/tooltip.attachment.svelte.js';
2
3
  import type {
3
4
  SidebarActiveVariant,
5
+ SidebarApi,
4
6
  SidebarDensity,
5
7
  SidebarMenuSubEntry,
6
8
  SidebarSize
7
9
  } from './sidebar.props.js';
10
+ import SidebarAction from './SidebarAction.svelte';
8
11
  import SidebarIcon from './SidebarIcon.svelte';
9
12
  import { useSidebarTheme, type SidebarThemeProps } from './sidebar.theme.js';
10
13
 
11
14
  let {
12
15
  sub,
16
+ api,
13
17
  size,
14
18
  activeVariant,
15
19
  density,
20
+ iconCollapsed = false,
16
21
  theme
17
22
  }: {
18
23
  sub: SidebarMenuSubEntry;
24
+ api: SidebarApi;
19
25
  size: SidebarSize;
20
26
  activeVariant: SidebarActiveVariant;
21
27
  density: SidebarDensity;
28
+ /** The Sidebar is collapsed to icons: a pinned action must not be reachable then. */
29
+ iconCollapsed?: boolean;
22
30
  theme?: SidebarThemeProps;
23
31
  } = $props();
24
32
 
25
33
  const classes = $derived(useSidebarTheme(theme));
26
34
  const subSize = $derived(sub.size ?? size);
35
+ // A bare glyph stays bare: the wrapper only appears when the entry asks for a role tint or a
36
+ // tile, so an untinted row never inherits the ambient `data-color`.
37
+ const hasIconSurface = $derived(!!sub.icon && (sub.iconVariant === 'tile' || !!sub.iconColor));
38
+ const hasBadge = $derived(sub.badge != null);
39
+ // The label is always visible on a submenu row, so the tooltip is extra information: shown
40
+ // whenever one is set, never on mobile, where hover does not exist.
41
+ const tooltipContent = $derived(sub.tooltip && !api.isMobile ? sub.tooltip : undefined);
42
+ const buttonClass = $derived(
43
+ classes.subButton({
44
+ size: subSize,
45
+ itemSize: sub.size,
46
+ activeVariant,
47
+ density,
48
+ reserveEnd: !!sub.action || hasBadge,
49
+ className: sub.class
50
+ })
51
+ );
27
52
 
28
53
  function handleClick(event: MouseEvent) {
29
54
  if (sub.disabled) {
@@ -35,6 +60,25 @@
35
60
  }
36
61
  </script>
37
62
 
63
+ {#snippet entryContent()}
64
+ {#if hasIconSurface}
65
+ <span
66
+ data-slot="sidebar-menu-icon"
67
+ data-color={sub.iconColor}
68
+ class={classes.menuIcon({
69
+ variant: sub.iconVariant ?? 'bare',
70
+ size: subSize,
71
+ placement: 'sub'
72
+ })}
73
+ >
74
+ <SidebarIcon icon={sub.icon} />
75
+ </span>
76
+ {:else}
77
+ <SidebarIcon icon={sub.icon} />
78
+ {/if}
79
+ <span>{sub.label}</span>
80
+ {/snippet}
81
+
38
82
  <li
39
83
  data-slot="sidebar-menu-sub-item"
40
84
  data-sidebar="menu-sub-item"
@@ -53,11 +97,11 @@
53
97
  aria-current={sub.isActive ? 'page' : undefined}
54
98
  aria-disabled={sub.disabled || undefined}
55
99
  tabindex={sub.disabled ? -1 : undefined}
56
- class={classes.subButton({ size: subSize, itemSize: sub.size, activeVariant, density })}
100
+ class={buttonClass}
101
+ {@attach tooltipContent ? tooltip({ content: tooltipContent, position: 'right' }) : undefined}
57
102
  onclick={handleClick}
58
103
  >
59
- <SidebarIcon icon={sub.icon} />
60
- <span>{sub.label}</span>
104
+ {@render entryContent()}
61
105
  </a>
62
106
  <!-- eslint-enable svelte/no-navigation-without-resolve -->
63
107
  {:else}
@@ -69,11 +113,32 @@
69
113
  data-active={sub.isActive ? 'true' : undefined}
70
114
  data-active-variant={activeVariant}
71
115
  disabled={sub.disabled || undefined}
72
- class={classes.subButton({ size: subSize, itemSize: sub.size, activeVariant, density })}
116
+ class={buttonClass}
117
+ {@attach tooltipContent ? tooltip({ content: tooltipContent, position: 'right' }) : undefined}
73
118
  onclick={handleClick}
74
119
  >
75
- <SidebarIcon icon={sub.icon} />
76
- <span>{sub.label}</span>
120
+ {@render entryContent()}
77
121
  </button>
78
122
  {/if}
123
+
124
+ {#if hasBadge}
125
+ <div
126
+ data-slot="sidebar-menu-sub-badge"
127
+ data-sidebar="menu-sub-badge"
128
+ class={classes.badge({ size: subSize, density })}
129
+ >
130
+ {sub.badge}
131
+ </div>
132
+ {/if}
133
+ {#if sub.action}
134
+ <div
135
+ data-slot="sidebar-menu-sub-action"
136
+ data-sidebar="menu-sub-action"
137
+ inert={iconCollapsed ? true : undefined}
138
+ aria-hidden={iconCollapsed ? 'true' : undefined}
139
+ class={classes.subAction({ size: subSize, density })}
140
+ >
141
+ <SidebarAction action={sub.action} {api} size={subSize} {theme} />
142
+ </div>
143
+ {/if}
79
144
  </li>
@@ -1,10 +1,13 @@
1
- import type { SidebarActiveVariant, SidebarDensity, SidebarMenuSubEntry, SidebarSize } from './sidebar.props.js';
1
+ import type { SidebarActiveVariant, SidebarApi, SidebarDensity, SidebarMenuSubEntry, SidebarSize } from './sidebar.props.js';
2
2
  import { type SidebarThemeProps } from './sidebar.theme.js';
3
3
  type $$ComponentProps = {
4
4
  sub: SidebarMenuSubEntry;
5
+ api: SidebarApi;
5
6
  size: SidebarSize;
6
7
  activeVariant: SidebarActiveVariant;
7
8
  density: SidebarDensity;
9
+ /** The Sidebar is collapsed to icons: a pinned action must not be reachable then. */
10
+ iconCollapsed?: boolean;
8
11
  theme?: SidebarThemeProps;
9
12
  };
10
13
  declare const SidebarMenuSubItem: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -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]> - Top-right group actions. 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.\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.\n- **tooltip**: string - Entasis Tooltip content in icon mode. Defaults to label.\n- **items**: SidebarMenuSubEntry[] - Inline nested menu.\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- **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\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.\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 renders as a horizontal row at the top of the drawer.\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 y-axis slide shared by collapsible groups, inline submenus, and\n tree branches. Takes `in` / `out` slide params plus a `duration` / `easing` motion token.\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## 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- Nested groups, tree branches, and inline submenus use reversible height transitions for open, close, and sidebar-collapse changes.\n- Tree roots use menu-row styling and nested tree nodes use submenu-row styling. In desktop icon mode, root folders open a PopupMenu and descendants remain navigable through recursive Menu submenu popovers; root leaves retain direct navigation and tooltips.\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]> - Top-right group actions. 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.\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.\n- **tooltip**: string - Entasis Tooltip content in icon mode. Defaults to label.\n- **items**: SidebarMenuSubEntry[] - Inline nested menu.\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- **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\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.\n- **tooltip**: string - Tooltip text. Submenus never show in icon-collapsed mode, so a sub row shows its tooltip whenever one is set (never on mobile).\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\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.\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 renders as a horizontal row at the top of the drawer.\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 y-axis slide shared by collapsible groups, inline submenus, and\n tree branches. Takes `in` / `out` slide params plus a `duration` / `easing` motion token.\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## 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 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- Nested groups, tree branches, and inline submenus use reversible height transitions for open, close, and sidebar-collapse changes.\n- Tree roots use menu-row styling and nested tree nodes use submenu-row styling. In desktop icon mode, root folders open a PopupMenu and descendants remain navigable through recursive Menu submenu popovers; root leaves retain direct navigation and tooltips.\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";
@@ -86,6 +86,67 @@ recursive tree groups, header/footer rows, search, actions, and snippet escape h
86
86
  - **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.
87
87
  - **action**: SidebarMenuActionDescriptor | Snippet<[SidebarApi]> - Hover/focus trailing action.
88
88
 
89
+ ### SidebarMenuSubEntry
90
+ A row of an inline submenu (\`items\` on a menu entry). It carries the same row features as a
91
+ top-level entry, so nested navigation (pages filed in folders) keeps its menus, pins and tints
92
+ without flattening the tree. Every field below \`size\` is optional, and a sub entry that sets none
93
+ of them renders exactly as a plain one.
94
+ - **label**: string - Visible row label.
95
+ - **href** / **onclick** - Render as an anchor, or a button with a native click handler.
96
+ - **icon**: SidebarIcon - Leading icon.
97
+ - **isActive** / **disabled** / **size** - As on a top-level entry; \`size\` defaults to the Sidebar size.
98
+ - **iconColor**: Colors - Role tint for the leading icon, applied through \`data-color\`.
99
+ - **iconVariant**: 'bare' | 'tile' - \`tile\` paints the same tinted rounded square as a top-level row.
100
+ - **badge**: string | number - Trailing count or status; the label truncates before it.
101
+ - **tooltip**: string - Tooltip text. Submenus never show in icon-collapsed mode, so a sub row shows its tooltip whenever one is set (never on mobile).
102
+ - **class**: string - Classes applied to the row button.
103
+ - **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.
104
+
105
+ \`\`\`svelte
106
+ <script lang="ts">
107
+ import { Sidebar, type SidebarGroup } from './index.ts';
108
+ import { folderIcon } from 'entasis/icons/folder';
109
+ import { fileTextIcon } from 'entasis/icons/fileText';
110
+ import { pencilSimpleIcon } from 'entasis/icons/pencilSimple';
111
+ import { pushPinIcon } from 'entasis/icons/pushPin';
112
+ import { trashIcon } from 'entasis/icons/trash';
113
+
114
+ const items: SidebarGroup[] = [
115
+ {
116
+ label: 'Pages',
117
+ items: [
118
+ {
119
+ label: 'Planning',
120
+ icon: folderIcon,
121
+ defaultOpen: true,
122
+ items: [
123
+ {
124
+ label: 'Q3 roadmap',
125
+ href: '/pages/q3-roadmap',
126
+ icon: fileTextIcon,
127
+ iconColor: 'success',
128
+ iconVariant: 'tile',
129
+ badge: 2,
130
+ action: {
131
+ label: 'More options for Q3 roadmap',
132
+ menu: [
133
+ { type: 'option', title: 'Rename', prefix: pencilSimpleIcon },
134
+ { type: 'option', title: 'Pin', prefix: pushPinIcon },
135
+ { type: 'option', title: 'Delete', prefix: trashIcon, color: 'danger' }
136
+ ]
137
+ }
138
+ },
139
+ { label: 'Retro notes', href: '/pages/retro-notes', icon: fileTextIcon }
140
+ ]
141
+ }
142
+ ]
143
+ }
144
+ ];
145
+ </script>
146
+
147
+ <Sidebar {items} />
148
+ \`\`\`
149
+
89
150
  ### SidebarView
90
151
  One named panel content in \`views\`.
91
152
  - **label**: string - The view's name, shown on the back row of the views nested under it.
@@ -213,6 +274,7 @@ default verbatim, prefixes included.
213
274
  - Mobile drawer includes a backdrop button labelled "Close Sidebar".
214
275
  - Icon-collapsed rows keep their labels mounted and visually fade them, preserving accessible names and stable icon geometry.
215
276
  - Search, group controls, actions, and nested rows become inert before collapse can remove or hide them; focus returns to the owning visible row.
277
+ - 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.
216
278
  - Nested groups, tree branches, and inline submenus use reversible height transitions for open, close, and sidebar-collapse changes.
217
279
  - Tree roots use menu-row styling and nested tree nodes use submenu-row styling. In desktop icon mode, root folders open a PopupMenu and descendants remain navigable through recursive Menu submenu popovers; root leaves retain direct navigation and tooltips.
218
280
  - When \`rail\` and \`resizable\` are both enabled, one edge control owns click-to-toggle, drag resize, and keyboard resize without overlapping hitboxes.
@@ -168,6 +168,24 @@ export type SidebarMenuSubEntry = {
168
168
  disabled?: boolean;
169
169
  /** Submenu row geometry scale. Defaults to the Sidebar size. */
170
170
  size?: Sizes;
171
+ /** Role tint applied to the leading icon, through `data-color`. */
172
+ iconColor?: Colors;
173
+ /** Leading icon treatment. 'tile' paints a tinted rounded square around the glyph. */
174
+ iconVariant?: SidebarIconVariant;
175
+ /** Trailing badge. */
176
+ badge?: string | number;
177
+ /**
178
+ * Tooltip text. Submenus never show in icon-collapsed mode, where the label is hidden, so a
179
+ * submenu row shows its tooltip whenever one is set (never on mobile).
180
+ */
181
+ tooltip?: string;
182
+ /** Classes applied to the row button. */
183
+ class?: string;
184
+ /**
185
+ * Secondary action pinned to the row's end, like a top-level entry's: shown on hover or focus
186
+ * from `md` up and while its menu is open, always on touch-sized screens.
187
+ */
188
+ action?: Snippet<[SidebarApi]> | SidebarMenuActionDescriptor;
171
189
  };
172
190
  type SidebarMenuEntryBase = {
173
191
  /** Visible label. */
@@ -234,6 +234,10 @@ export declare const sidebarTheme: {
234
234
  normal: string;
235
235
  large: string;
236
236
  };
237
+ reserveEnd: {
238
+ true: string;
239
+ false: string;
240
+ };
237
241
  }> & import("../../utils/cva/types.js").CVAClassProp) | undefined) => string;
238
242
  menuAction: (props?: (import("../../utils/cva/types.js").CVAVariantSchema<{
239
243
  size: {
@@ -247,6 +251,18 @@ export declare const sidebarTheme: {
247
251
  comfortable: string;
248
252
  };
249
253
  }> & import("../../utils/cva/types.js").CVAClassProp) | undefined) => string;
254
+ subAction: (props?: (import("../../utils/cva/types.js").CVAVariantSchema<{
255
+ size: {
256
+ small: string;
257
+ normal: string;
258
+ large: string;
259
+ };
260
+ density: {
261
+ compact: string;
262
+ normal: string;
263
+ comfortable: string;
264
+ };
265
+ }> & import("../../utils/cva/types.js").CVAClassProp) | undefined) => string;
250
266
  actionSlot: (props?: (import("../../utils/cva/types.js").CVAVariantSchema<{
251
267
  size: {
252
268
  small: string;
@@ -271,6 +287,10 @@ export declare const sidebarTheme: {
271
287
  normal: string;
272
288
  large: string;
273
289
  };
290
+ placement: {
291
+ row: string;
292
+ sub: string;
293
+ };
274
294
  }> & import("../../utils/cva/types.js").CVAClassProp) | undefined) => string;
275
295
  buttonRow: (props?: import("../../utils/cva/types.js").CVAClassProp | undefined) => string;
276
296
  badge: (props?: (import("../../utils/cva/types.js").CVAVariantSchema<{
@@ -745,6 +765,10 @@ export declare const setSidebarTheme: (theme: InferComponentTheme<{
745
765
  normal: string;
746
766
  large: string;
747
767
  };
768
+ reserveEnd: {
769
+ true: string;
770
+ false: string;
771
+ };
748
772
  }> & import("../../utils/cva/types.js").CVAClassProp) | undefined) => string;
749
773
  menuAction: (props?: (import("../../utils/cva/types.js").CVAVariantSchema<{
750
774
  size: {
@@ -758,6 +782,18 @@ export declare const setSidebarTheme: (theme: InferComponentTheme<{
758
782
  comfortable: string;
759
783
  };
760
784
  }> & import("../../utils/cva/types.js").CVAClassProp) | undefined) => string;
785
+ subAction: (props?: (import("../../utils/cva/types.js").CVAVariantSchema<{
786
+ size: {
787
+ small: string;
788
+ normal: string;
789
+ large: string;
790
+ };
791
+ density: {
792
+ compact: string;
793
+ normal: string;
794
+ comfortable: string;
795
+ };
796
+ }> & import("../../utils/cva/types.js").CVAClassProp) | undefined) => string;
761
797
  actionSlot: (props?: (import("../../utils/cva/types.js").CVAVariantSchema<{
762
798
  size: {
763
799
  small: string;
@@ -782,6 +818,10 @@ export declare const setSidebarTheme: (theme: InferComponentTheme<{
782
818
  normal: string;
783
819
  large: string;
784
820
  };
821
+ placement: {
822
+ row: string;
823
+ sub: string;
824
+ };
785
825
  }> & import("../../utils/cva/types.js").CVAClassProp) | undefined) => string;
786
826
  buttonRow: (props?: import("../../utils/cva/types.js").CVAClassProp | undefined) => string;
787
827
  badge: (props?: (import("../../utils/cva/types.js").CVAVariantSchema<{
@@ -1254,6 +1294,10 @@ export declare const useSidebarTheme: import("../../utils/cva/theme.js").UseComp
1254
1294
  normal: string;
1255
1295
  large: string;
1256
1296
  };
1297
+ reserveEnd: {
1298
+ true: string;
1299
+ false: string;
1300
+ };
1257
1301
  }> & import("../../utils/cva/types.js").CVAClassProp) | undefined) => string;
1258
1302
  menuAction: (props?: (import("../../utils/cva/types.js").CVAVariantSchema<{
1259
1303
  size: {
@@ -1267,6 +1311,18 @@ export declare const useSidebarTheme: import("../../utils/cva/theme.js").UseComp
1267
1311
  comfortable: string;
1268
1312
  };
1269
1313
  }> & import("../../utils/cva/types.js").CVAClassProp) | undefined) => string;
1314
+ subAction: (props?: (import("../../utils/cva/types.js").CVAVariantSchema<{
1315
+ size: {
1316
+ small: string;
1317
+ normal: string;
1318
+ large: string;
1319
+ };
1320
+ density: {
1321
+ compact: string;
1322
+ normal: string;
1323
+ comfortable: string;
1324
+ };
1325
+ }> & import("../../utils/cva/types.js").CVAClassProp) | undefined) => string;
1270
1326
  actionSlot: (props?: (import("../../utils/cva/types.js").CVAVariantSchema<{
1271
1327
  size: {
1272
1328
  small: string;
@@ -1291,6 +1347,10 @@ export declare const useSidebarTheme: import("../../utils/cva/theme.js").UseComp
1291
1347
  normal: string;
1292
1348
  large: string;
1293
1349
  };
1350
+ placement: {
1351
+ row: string;
1352
+ sub: string;
1353
+ };
1294
1354
  }> & import("../../utils/cva/types.js").CVAClassProp) | undefined) => string;
1295
1355
  buttonRow: (props?: import("../../utils/cva/types.js").CVAClassProp | undefined) => string;
1296
1356
  badge: (props?: (import("../../utils/cva/types.js").CVAVariantSchema<{
@@ -333,13 +333,20 @@ const defaultSubButton = cva({
333
333
  small: 'text-xs',
334
334
  normal: '',
335
335
  large: ''
336
+ },
337
+ // Room at the row's end for a pinned action or badge, so the label truncates before it
338
+ // instead of running under it. Off adds nothing: a plain row keeps its exact classes.
339
+ reserveEnd: {
340
+ true: 'pr-layout-lg',
341
+ false: ''
336
342
  }
337
343
  },
338
344
  defaultVariants: {
339
345
  activeVariant: 'soft',
340
346
  size: 'normal',
341
347
  density: 'normal',
342
- itemSize: 'normal'
348
+ itemSize: 'normal',
349
+ reserveEnd: false
343
350
  }
344
351
  });
345
352
  const defaultMenuAction = cva({
@@ -361,6 +368,28 @@ const defaultMenuAction = cva({
361
368
  density: 'normal'
362
369
  }
363
370
  });
371
+ // A submenu row's pinned action: the `menuAction` recipe keyed to the sub row's own
372
+ // `group/menu-sub-item`, one step smaller to sit in the shorter row. Hidden until the row is
373
+ // hovered or focused from `md` up, kept while its menu is open, always shown below `md`.
374
+ const defaultSubAction = cva({
375
+ base: 'state-layer text-neutral hover:text-neutral absolute top-1/2 flex aspect-square -translate-y-1/2 items-center justify-center rounded-sm p-0 opacity-100 outline-none transition group-data-[collapsible=icon]:hidden focus-visible:ring-2 focus-visible:ring-focus/50 md:opacity-0 group-focus-within/menu-sub-item:opacity-100 group-hover/menu-sub-item:opacity-100 has-[[aria-expanded=true]]:opacity-100 [&>svg]:shrink-0 [&>svg]:size-[var(--sidebar-icon-size)]',
376
+ variants: {
377
+ size: {
378
+ small: 'size-4',
379
+ normal: 'size-4.5',
380
+ large: 'size-5'
381
+ },
382
+ density: {
383
+ compact: 'right-0.5',
384
+ normal: 'right-1',
385
+ comfortable: 'right-1.5'
386
+ }
387
+ },
388
+ defaultVariants: {
389
+ size: 'normal',
390
+ density: 'normal'
391
+ }
392
+ });
364
393
  // One icon-only ghost button box. `size` follows the descriptor, defaulting to the Sidebar size,
365
394
  // so a group's `+` lands at row scale instead of at the group label's scale.
366
395
  const defaultActionSlot = cva({
@@ -389,16 +418,24 @@ const defaultMenuIcon = cva({
389
418
  small: '',
390
419
  normal: '',
391
420
  large: ''
421
+ },
422
+ // Where the icon sits. A top-level row sizes every nested glyph itself; a submenu row only
423
+ // sizes its direct glyph, so a bare wrapped icon there carries the row's icon size.
424
+ placement: {
425
+ row: '',
426
+ sub: ''
392
427
  }
393
428
  },
394
429
  compoundVariants: [
430
+ { variant: 'bare', placement: 'sub', class: '[&_svg]:size-[var(--sidebar-icon-size)]' },
395
431
  { variant: 'tile', size: 'small', class: 'size-icon-lg [&_svg]:size-icon-xs' },
396
432
  { variant: 'tile', size: 'normal', class: 'size-icon-xl [&_svg]:size-icon-sm' },
397
433
  { variant: 'tile', size: 'large', class: 'size-icon-xl [&_svg]:size-icon-md' }
398
434
  ],
399
435
  defaultVariants: {
400
436
  variant: 'bare',
401
- size: 'normal'
437
+ size: 'normal',
438
+ placement: 'row'
402
439
  }
403
440
  });
404
441
  // A large menu row that carries its own trailing action: the row and the action are siblings, so
@@ -1008,6 +1045,7 @@ export const sidebarTheme = {
1008
1045
  treeSubMenu: defaultTreeSubMenu,
1009
1046
  subButton: defaultSubButton,
1010
1047
  menuAction: defaultMenuAction,
1048
+ subAction: defaultSubAction,
1011
1049
  actionSlot: defaultActionSlot,
1012
1050
  actionTrigger: defaultActionTrigger,
1013
1051
  menuIcon: defaultMenuIcon,