@happyvertical/smrt-svelte 0.38.26 → 0.39.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 (48) hide show
  1. package/AGENTS.md +22 -0
  2. package/README.md +9 -7
  3. package/dist/components/forms/Form.svelte +112 -2
  4. package/dist/components/forms/Form.svelte.d.ts +12 -1
  5. package/dist/components/forms/Form.svelte.d.ts.map +1 -1
  6. package/dist/components/settings/SettingsCatalog.svelte +283 -0
  7. package/dist/components/settings/SettingsCatalog.svelte.d.ts +52 -0
  8. package/dist/components/settings/SettingsCatalog.svelte.d.ts.map +1 -0
  9. package/dist/components/settings/__tests__/SettingsCatalog.test.js +69 -0
  10. package/dist/components/settings/__tests__/catalog.test.js +46 -0
  11. package/dist/components/settings/catalog.d.ts +11 -0
  12. package/dist/components/settings/catalog.d.ts.map +1 -0
  13. package/dist/components/settings/catalog.js +40 -0
  14. package/dist/components/settings/index.d.ts +4 -0
  15. package/dist/components/settings/index.d.ts.map +1 -0
  16. package/dist/components/settings/index.js +2 -0
  17. package/dist/components/settings/types.d.ts +25 -0
  18. package/dist/components/settings/types.d.ts.map +1 -0
  19. package/dist/components/settings/types.js +1 -0
  20. package/dist/components/workspace/README.md +11 -1
  21. package/dist/components/workspace/__tests__/AdminShell.test.js +82 -0
  22. package/dist/components/workspace/__tests__/WorkspaceAccountMenu.test.js +73 -0
  23. package/dist/components/workspace/admin-shell/AdminShell.svelte +66 -15
  24. package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts +5 -2
  25. package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts.map +1 -1
  26. package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte +188 -0
  27. package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte.d.ts +19 -0
  28. package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte.d.ts.map +1 -0
  29. package/dist/components/workspace/admin-shell/types.d.ts +7 -0
  30. package/dist/components/workspace/admin-shell/types.d.ts.map +1 -1
  31. package/dist/components/workspace/index.d.ts +2 -1
  32. package/dist/components/workspace/index.d.ts.map +1 -1
  33. package/dist/components/workspace/index.js +1 -0
  34. package/dist/hooks/useTheme.d.ts +1 -1
  35. package/dist/hooks/useTheme.d.ts.map +1 -1
  36. package/dist/hooks/useTheme.js +1 -1
  37. package/dist/i18n/strings.settings.d.ts +12 -0
  38. package/dist/i18n/strings.settings.d.ts.map +1 -0
  39. package/dist/i18n/strings.settings.js +12 -0
  40. package/dist/i18n/strings.workspace.d.ts +3 -0
  41. package/dist/i18n/strings.workspace.d.ts.map +1 -1
  42. package/dist/i18n/strings.workspace.js +3 -0
  43. package/dist/state/form-context.d.ts +19 -0
  44. package/dist/state/form-context.d.ts.map +1 -1
  45. package/dist/web/__tests__/update-available-harness.svelte +4 -1
  46. package/dist/web/__tests__/update-available-harness.svelte.d.ts.map +1 -1
  47. package/package.json +23 -16
  48. package/LICENSE +0 -7
@@ -0,0 +1,69 @@
1
+ import { expectNoA11yViolations } from '@happyvertical/smrt-ui/test-support/a11y';
2
+ import { render, screen } from '@testing-library/svelte';
3
+ import { createRawSnippet } from 'svelte';
4
+ import { describe, expect, it } from 'vitest';
5
+ import SettingsCatalog from '../SettingsCatalog.svelte';
6
+ const first = {
7
+ id: 'first',
8
+ label: 'First setting',
9
+ description: 'First description',
10
+ eyebrow: 'General',
11
+ status: 'Default',
12
+ template: 'First template',
13
+ };
14
+ const second = {
15
+ id: 'second',
16
+ label: 'Second setting',
17
+ description: 'Second description',
18
+ eyebrow: 'Advanced',
19
+ status: 'Override',
20
+ template: 'Second template',
21
+ };
22
+ function detailSnippet() {
23
+ return createRawSnippet((context) => ({
24
+ render: () => `<h2>${context().item.label} editor</h2><p>${context().item.template}</p>`,
25
+ }));
26
+ }
27
+ describe('SettingsCatalog', () => {
28
+ it('renders a compact page, selected detail, and query-preserving links', () => {
29
+ const { container } = render((SettingsCatalog), {
30
+ props: {
31
+ baseUrl: '/settings/prompts',
32
+ detail: detailSnippet(),
33
+ preservedParams: { locale: 'fr-CA' },
34
+ page: {
35
+ items: [first, second],
36
+ selected: second,
37
+ query: 'tenant copy',
38
+ page: 2,
39
+ pageSize: 2,
40
+ total: 6,
41
+ },
42
+ },
43
+ });
44
+ expect(screen.getByRole('searchbox')).toHaveValue('tenant copy');
45
+ expect(screen.getByRole('heading', { name: 'Second setting editor' })).toBeInTheDocument();
46
+ expect(container.querySelector('a[aria-current="true"]')).toHaveAttribute('href', '/settings/prompts?locale=fr-CA&q=tenant+copy&page=2&selected=second');
47
+ expect(screen.getByRole('link', { name: 'Previous' })).toHaveAttribute('href', '/settings/prompts?locale=fr-CA&q=tenant+copy');
48
+ expect(screen.getByRole('link', { name: 'Next' })).toHaveAttribute('href', '/settings/prompts?locale=fr-CA&q=tenant+copy&page=3');
49
+ });
50
+ it('renders an empty state and remains axe-clean', async () => {
51
+ const { container } = render((SettingsCatalog), {
52
+ props: {
53
+ baseUrl: '/settings/prompts',
54
+ detail: detailSnippet(),
55
+ page: {
56
+ items: [],
57
+ selected: null,
58
+ query: 'missing',
59
+ page: 1,
60
+ pageSize: 50,
61
+ total: 0,
62
+ },
63
+ },
64
+ });
65
+ expect(screen.getByText('No settings match this search.')).toBeInTheDocument();
66
+ expect(screen.getByText('Select a setting to edit.')).toBeInTheDocument();
67
+ await expectNoA11yViolations(container);
68
+ });
69
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { paginateSettingsCatalog } from '../catalog.js';
3
+ const items = Array.from({ length: 125 }, (_, index) => ({
4
+ id: `setting-${index + 1}`,
5
+ label: `Setting ${index + 1}`,
6
+ description: index % 2 === 0 ? 'Alpha group' : 'Beta group',
7
+ }));
8
+ describe('paginateSettingsCatalog', () => {
9
+ it('filters before paging and keeps the DOM-sized slice bounded', () => {
10
+ const page = paginateSettingsCatalog(items, {
11
+ query: 'alpha',
12
+ page: 2,
13
+ pageSize: 20,
14
+ });
15
+ expect(page.total).toBe(63);
16
+ expect(page.items).toHaveLength(20);
17
+ expect(page.page).toBe(2);
18
+ expect(page.items.every((item) => item.description === 'Alpha group')).toBe(true);
19
+ });
20
+ it('clamps invalid pages and page sizes', () => {
21
+ const page = paginateSettingsCatalog(items, {
22
+ page: 999,
23
+ pageSize: 1_000,
24
+ });
25
+ expect(page.pageSize).toBe(100);
26
+ expect(page.page).toBe(2);
27
+ expect(page.items).toHaveLength(25);
28
+ });
29
+ it('selects an explicit item or falls back to the first visible item', () => {
30
+ expect(paginateSettingsCatalog(items, { selectedId: 'setting-73' }).selected?.id).toBe('setting-73');
31
+ expect(paginateSettingsCatalog(items).selected?.id).toBe('setting-1');
32
+ });
33
+ it('keeps a very large code-first registry to one bounded result page', () => {
34
+ const largeRegistry = Array.from({ length: 10_000 }, (_, index) => ({
35
+ id: `large-${index}`,
36
+ label: `Large setting ${index}`,
37
+ }));
38
+ const page = paginateSettingsCatalog(largeRegistry, {
39
+ page: 100,
40
+ pageSize: 50,
41
+ });
42
+ expect(page.total).toBe(10_000);
43
+ expect(page.items).toHaveLength(50);
44
+ expect(page.items[0]?.id).toBe('large-4950');
45
+ });
46
+ });
@@ -0,0 +1,11 @@
1
+ import type { PaginateSettingsCatalogOptions, SettingsCatalogItem, SettingsCatalogPage } from './types.js';
2
+ /**
3
+ * Search and page an in-memory settings registry before it reaches the DOM.
4
+ *
5
+ * Applications backed by a database can construct {@link SettingsCatalogPage}
6
+ * directly from their server-side query instead. This helper is for code-first
7
+ * registries that already exist in memory, such as prompt and language
8
+ * definitions.
9
+ */
10
+ export declare function paginateSettingsCatalog<T extends SettingsCatalogItem>(source: readonly T[], options?: PaginateSettingsCatalogOptions<T>): SettingsCatalogPage<T>;
11
+ //# sourceMappingURL=catalog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../../../src/components/settings/catalog.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,8BAA8B,EAC9B,mBAAmB,EACnB,mBAAmB,EACpB,MAAM,YAAY,CAAC;AAKpB;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,CAAC,SAAS,mBAAmB,EACnE,MAAM,EAAE,SAAS,CAAC,EAAE,EACpB,OAAO,GAAE,8BAA8B,CAAC,CAAC,CAAM,GAC9C,mBAAmB,CAAC,CAAC,CAAC,CA4BxB"}
@@ -0,0 +1,40 @@
1
+ const DEFAULT_PAGE_SIZE = 50;
2
+ const MAX_PAGE_SIZE = 100;
3
+ /**
4
+ * Search and page an in-memory settings registry before it reaches the DOM.
5
+ *
6
+ * Applications backed by a database can construct {@link SettingsCatalogPage}
7
+ * directly from their server-side query instead. This helper is for code-first
8
+ * registries that already exist in memory, such as prompt and language
9
+ * definitions.
10
+ */
11
+ export function paginateSettingsCatalog(source, options = {}) {
12
+ const query = options.query?.trim() ?? '';
13
+ const needle = query.toLocaleLowerCase();
14
+ const getSearchText = options.getSearchText ?? defaultSearchText;
15
+ const filtered = needle
16
+ ? source.filter((item) => getSearchText(item).toLocaleLowerCase().includes(needle))
17
+ : [...source];
18
+ const pageSize = clampInteger(options.pageSize, 1, MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE);
19
+ const total = filtered.length;
20
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
21
+ const page = clampInteger(options.page, 1, totalPages, 1);
22
+ const offset = (page - 1) * pageSize;
23
+ const items = filtered.slice(offset, offset + pageSize);
24
+ const selected = (options.selectedId
25
+ ? filtered.find((item) => item.id === options.selectedId)
26
+ : null) ??
27
+ items[0] ??
28
+ null;
29
+ return { items, selected, query, page, pageSize, total };
30
+ }
31
+ function defaultSearchText(item) {
32
+ return [item.label, item.description, item.eyebrow, item.status]
33
+ .filter(Boolean)
34
+ .join(' ');
35
+ }
36
+ function clampInteger(value, minimum, maximum, fallback) {
37
+ if (!Number.isFinite(value))
38
+ return fallback;
39
+ return Math.min(maximum, Math.max(minimum, Math.trunc(value)));
40
+ }
@@ -0,0 +1,4 @@
1
+ export { paginateSettingsCatalog } from './catalog.js';
2
+ export { default as SettingsCatalog } from './SettingsCatalog.svelte';
3
+ export type { PaginateSettingsCatalogOptions, SettingsCatalogItem, SettingsCatalogPage, } from './types.js';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/settings/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,MAAM,cAAc,CAAC;AACvD,OAAO,EAAE,OAAO,IAAI,eAAe,EAAE,MAAM,0BAA0B,CAAC;AACtE,YAAY,EACV,8BAA8B,EAC9B,mBAAmB,EACnB,mBAAmB,GACpB,MAAM,YAAY,CAAC"}
@@ -0,0 +1,2 @@
1
+ export { paginateSettingsCatalog } from './catalog.js';
2
+ export { default as SettingsCatalog } from './SettingsCatalog.svelte';
@@ -0,0 +1,25 @@
1
+ export interface SettingsCatalogItem {
2
+ id: string;
3
+ label: string;
4
+ description?: string;
5
+ eyebrow?: string;
6
+ status?: string;
7
+ }
8
+ export interface SettingsCatalogPage<T extends SettingsCatalogItem, D extends {
9
+ id: string;
10
+ } = T> {
11
+ items: T[];
12
+ selected: D | null;
13
+ query: string;
14
+ page: number;
15
+ pageSize: number;
16
+ total: number;
17
+ }
18
+ export interface PaginateSettingsCatalogOptions<T extends SettingsCatalogItem> {
19
+ query?: string | null;
20
+ page?: number | null;
21
+ pageSize?: number | null;
22
+ selectedId?: string | null;
23
+ getSearchText?: (item: T) => string;
24
+ }
25
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/components/settings/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,mBAAmB,CAClC,CAAC,SAAS,mBAAmB,EAC7B,CAAC,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,GAAG,CAAC;IAE5B,KAAK,EAAE,CAAC,EAAE,CAAC;IACX,QAAQ,EAAE,CAAC,GAAG,IAAI,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,8BAA8B,CAAC,CAAC,SAAS,mBAAmB;IAC3E,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,MAAM,CAAC;CACrC"}
@@ -0,0 +1 @@
1
+ export {};
@@ -20,11 +20,21 @@ SvelteKit route APIs; apps pass data, endpoints, and permission-filtered nav in.
20
20
  - `AdminShell`
21
21
  - `ShellState` / `createShellState`
22
22
  - `ShellSettingsPanel`, `HotkeyInput`, `ShortcutsOverlay`
23
- - `ShellDockTool`, `TenantNav`
23
+ - `ShellCorner`, `ShellDockTool`, `TenantNav`, `WorkspaceAccountMenu`
24
24
  - `ActivityBadge`, `ActivityList`, `ActivityItem`, `ActivityToasts`
25
25
  - `AppScopePanel`, `SystemStatusChips`, `SystemScopePanel`
26
26
  - `tenantNavFromManifest`
27
27
 
28
+ Use `AdminShell`'s `tenantFooter` snippet with `WorkspaceAccountMenu` to keep a
29
+ single-line tenant/account control pinned below scrolling tenant navigation.
30
+ Tenant switching and sign-out stay app-owned callbacks, so the workspace core
31
+ remains independent of SvelteKit and `smrt-users` route conventions.
32
+
33
+ Keep the `appBar` snippet for content in the center header band. Side-aligned
34
+ identity and action content belongs in `topLeftCorner` / `topRightCorner`,
35
+ wrapped in `ShellCorner side="left"` or `ShellCorner side="right"` so it follows
36
+ the corresponding shell track as that edge expands and collapses.
37
+
28
38
  ## Migration
29
39
 
30
40
  See [MIGRATION.md](./MIGRATION.md) for the first-generation workspace migration
@@ -1,12 +1,18 @@
1
1
  import { createRawSnippet, flushSync, mount, tick, unmount } from 'svelte';
2
2
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3
3
  import AdminShell from '../admin-shell/AdminShell.svelte';
4
+ import adminShellSource from '../admin-shell/AdminShell.svelte?raw';
4
5
  import { createShellState } from '../admin-shell/state.svelte.js';
5
6
  function textSnippet(text) {
6
7
  return createRawSnippet(() => ({
7
8
  render: () => `<span>${text}</span>`,
8
9
  }));
9
10
  }
11
+ function activeToolSnippet() {
12
+ return createRawSnippet((context) => ({
13
+ render: () => `<span>${context().tool?.id ?? 'none'} panel</span>`,
14
+ }));
15
+ }
10
16
  let container;
11
17
  beforeEach(() => {
12
18
  container = document.createElement('div');
@@ -34,6 +40,9 @@ describe('AdminShell', () => {
34
40
  unmount(component);
35
41
  }
36
42
  });
43
+ it('pins the shell to the viewport so long workspace content scrolls inside main', () => {
44
+ expect(adminShellSource).toMatch(/\.smrt-admin-shell\s*\{[^}]*\bblock-size:\s*100svh\s*;/);
45
+ });
37
46
  it('toggles a physical-code panel hotkey', async () => {
38
47
  const component = mount(AdminShell, {
39
48
  target: container,
@@ -53,6 +62,24 @@ describe('AdminShell', () => {
53
62
  unmount(component);
54
63
  }
55
64
  });
65
+ it('hides hotkey badges when shell hotkeys are disabled', () => {
66
+ const state = createShellState({
67
+ settings: { hotkeysEnabled: false },
68
+ });
69
+ const component = mount(AdminShell, {
70
+ target: container,
71
+ props: {
72
+ state,
73
+ children: textSnippet('main work'),
74
+ },
75
+ });
76
+ try {
77
+ expect(container.querySelector('.smrt-admin-shell__edge-toggle-kbd')).toBeNull();
78
+ }
79
+ finally {
80
+ unmount(component);
81
+ }
82
+ });
56
83
  it('keeps the focus rail visible when the right panel is expanded', () => {
57
84
  const state = createShellState({
58
85
  config: {
@@ -82,6 +109,61 @@ describe('AdminShell', () => {
82
109
  unmount(component);
83
110
  }
84
111
  });
112
+ it('renders the active registered focus tool after switching tools', async () => {
113
+ const state = createShellState({
114
+ config: { right: { initial: 'expanded' } },
115
+ });
116
+ state.registerFocusTool({
117
+ id: 'usage',
118
+ label: 'Usage',
119
+ });
120
+ state.registerFocusTool({
121
+ id: 'tasks',
122
+ label: 'Tasks',
123
+ });
124
+ const component = mount(AdminShell, {
125
+ target: container,
126
+ props: {
127
+ state,
128
+ children: textSnippet('main work'),
129
+ focusPanel: activeToolSnippet(),
130
+ },
131
+ });
132
+ try {
133
+ const panel = container.querySelector('.smrt-admin-shell__panel--right');
134
+ expect(panel?.textContent).toContain('usage panel');
135
+ state.openFocusTool('tasks');
136
+ await tick();
137
+ expect(panel?.textContent).toContain('tasks panel');
138
+ expect(panel?.textContent).not.toContain('usage panel');
139
+ }
140
+ finally {
141
+ unmount(component);
142
+ }
143
+ });
144
+ it('pins a tenant footer below the scrolling tenant panel', () => {
145
+ const state = createShellState({
146
+ config: { left: { initial: 'expanded' } },
147
+ });
148
+ const component = mount(AdminShell, {
149
+ target: container,
150
+ props: {
151
+ state,
152
+ children: textSnippet('main work'),
153
+ tenantPanel: textSnippet('tenant navigation'),
154
+ tenantFooter: textSnippet('account menu'),
155
+ },
156
+ });
157
+ try {
158
+ expect(container.querySelector('.smrt-admin-shell__tenant-content')
159
+ ?.textContent).toContain('tenant navigation');
160
+ expect(container.querySelector('.smrt-admin-shell__tenant-footer')
161
+ ?.textContent).toContain('account menu');
162
+ }
163
+ finally {
164
+ unmount(component);
165
+ }
166
+ });
85
167
  it('publishes collapsed side sizes for stable top and bottom chrome', () => {
86
168
  const state = createShellState({
87
169
  config: {
@@ -0,0 +1,73 @@
1
+ import { expectNoA11yViolations } from '@happyvertical/smrt-ui/test-support/a11y';
2
+ import { render, screen } from '@testing-library/svelte';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import WorkspaceAccountMenu from '../admin-shell/WorkspaceAccountMenu.svelte';
6
+ const tenants = [
7
+ { id: 'tenant-a', label: 'Acme', roleLabel: 'Owner' },
8
+ { id: 'tenant-b', label: 'Beta', roleLabel: 'Member' },
9
+ ];
10
+ describe('WorkspaceAccountMenu', () => {
11
+ it('switches tenant and signs out through app-owned callbacks', async () => {
12
+ const user = userEvent.setup();
13
+ const onTenantSelect = vi.fn();
14
+ const onSignOut = vi.fn();
15
+ render(WorkspaceAccountMenu, {
16
+ props: {
17
+ userName: 'Ada Lovelace',
18
+ userLabel: 'ada@example.com',
19
+ tenantLabel: 'Acme',
20
+ roleLabel: 'Owner',
21
+ currentTenantId: 'tenant-a',
22
+ tenants,
23
+ onTenantSelect,
24
+ onSignOut,
25
+ },
26
+ });
27
+ const trigger = screen.getByRole('button', {
28
+ name: /Open account menu ada@example.com Acme · Owner/,
29
+ });
30
+ await user.click(trigger);
31
+ expect(screen.getByRole('menuitem', { name: 'Acme — current — Owner' })).toBeDisabled();
32
+ await user.click(screen.getByRole('menuitem', { name: 'Beta — Member' }));
33
+ expect(onTenantSelect).toHaveBeenCalledWith('tenant-b');
34
+ await user.click(trigger);
35
+ await user.click(screen.getByRole('menuitem', { name: 'Sign out' }));
36
+ expect(onSignOut).toHaveBeenCalledOnce();
37
+ });
38
+ it('keeps tenant switching out of the menu when only one tenant is available', async () => {
39
+ const user = userEvent.setup();
40
+ render(WorkspaceAccountMenu, {
41
+ props: {
42
+ userName: 'Ada Lovelace',
43
+ userLabel: 'ada@example.com',
44
+ tenantLabel: 'Acme',
45
+ roleLabel: 'Owner',
46
+ currentTenantId: 'tenant-a',
47
+ tenants: tenants.slice(0, 1),
48
+ onTenantSelect: vi.fn(),
49
+ onSignOut: vi.fn(),
50
+ },
51
+ });
52
+ const trigger = screen.getByRole('button', {
53
+ name: /Open account menu ada@example.com Acme · Owner/,
54
+ });
55
+ await user.click(trigger);
56
+ expect(screen.queryByRole('menuitem', { name: /Acme/ })).toBeNull();
57
+ expect(screen.getByRole('menuitem', { name: 'Sign out' })).toBeVisible();
58
+ });
59
+ it('is axe-clean', async () => {
60
+ const { container } = render(WorkspaceAccountMenu, {
61
+ props: {
62
+ userName: 'Ada Lovelace',
63
+ userLabel: 'ada@example.com',
64
+ tenantLabel: 'Acme',
65
+ currentTenantId: 'tenant-a',
66
+ tenants,
67
+ onTenantSelect: vi.fn(),
68
+ onSignOut: vi.fn(),
69
+ },
70
+ });
71
+ await expectNoA11yViolations(container);
72
+ });
73
+ });
@@ -62,8 +62,9 @@ function buildLayoutStyle(shell: ModuleShellState): string {
62
62
  appPanel?: Snippet;
63
63
  tenantRail?: Snippet;
64
64
  tenantPanel?: Snippet;
65
+ tenantFooter?: Snippet;
65
66
  focusRail?: Snippet;
66
- focusPanel?: Snippet;
67
+ focusPanel?: Snippet<[{ tool: ShellFocusTool | null }]>;
67
68
  systemBar?: Snippet;
68
69
  systemPanel?: Snippet;
69
70
  topLeftCorner?: Snippet;
@@ -86,6 +87,7 @@ function buildLayoutStyle(shell: ModuleShellState): string {
86
87
  appPanel,
87
88
  tenantRail,
88
89
  tenantPanel,
90
+ tenantFooter,
89
91
  focusRail,
90
92
  focusPanel,
91
93
  systemBar,
@@ -112,11 +114,13 @@ function buildLayoutStyle(shell: ModuleShellState): string {
112
114
  setAdminShell(shell);
113
115
 
114
116
  let shortcutsOpen = $state(false);
115
- const activeFocusTool = $derived(
116
- shell.focusTools.find((tool) => tool.id === shell.activeFocusToolId) ??
117
+ function resolveActiveFocusTool(): ShellFocusTool | null {
118
+ return (
119
+ shell.focusTools.find((tool) => tool.id === shell.activeFocusToolId) ??
117
120
  shell.focusTools[0] ??
118
- null,
119
- );
121
+ null
122
+ );
123
+ }
120
124
  const shortcutEdges: PanelEdge[] = ['top', 'left', 'bottom', 'right'];
121
125
 
122
126
  onMount(() => {
@@ -171,6 +175,13 @@ function buildLayoutStyle(shell: ModuleShellState): string {
171
175
  );
172
176
  }
173
177
 
178
+ function showsHotkeyFor(edge: PanelEdge): boolean {
179
+ return (
180
+ shell.settings.hotkeysEnabled !== false &&
181
+ resolveHotkey(edge, shell.config.panels[edge], shell.settings) !== null
182
+ );
183
+ }
184
+
174
185
  // Focus containment for the modal shortcuts dialog. Runs on mount of the
175
186
  // dialog node and tears down when it unmounts (Escape / close), restoring
176
187
  // focus to whatever opened it. Keeps `aria-modal="true"` honest.
@@ -226,14 +237,16 @@ function buildLayoutStyle(shell: ModuleShellState): string {
226
237
  onclick={() => shell.togglePanel(edge)}
227
238
  >
228
239
  <span>{labelFor(edge)}</span>
229
- <kbd class="smrt-admin-shell__edge-toggle-kbd">{hotkeyFor(edge)}</kbd>
240
+ {#if showsHotkeyFor(edge)}
241
+ <kbd class="smrt-admin-shell__edge-toggle-kbd">{hotkeyFor(edge)}</kbd>
242
+ {/if}
230
243
  <ActivityBadge {edge} />
231
244
  </Button>
232
245
  {/snippet}
233
246
 
234
247
  {#snippet focusContent(tool: ShellFocusTool | null)}
235
248
  {#if focusPanel}
236
- {@render focusPanel()}
249
+ {@render focusPanel({ tool })}
237
250
  {:else if tool?.render}
238
251
  {@render tool.render({ tool })}
239
252
  {:else if tool?.component}
@@ -312,13 +325,22 @@ function buildLayoutStyle(shell: ModuleShellState): string {
312
325
  >
313
326
  <div class="smrt-admin-shell__rail">
314
327
  {#if edgeExpanded('left')}
315
- {#if tenantPanel}
316
- {@render tenantPanel()}
317
- {:else if tenantRail}
318
- {@render tenantRail()}
319
- {:else}
320
- {@render edgeToggle('left')}
321
- {/if}
328
+ <div class="smrt-admin-shell__tenant-stack">
329
+ <div class="smrt-admin-shell__tenant-content">
330
+ {#if tenantPanel}
331
+ {@render tenantPanel()}
332
+ {:else if tenantRail}
333
+ {@render tenantRail()}
334
+ {:else}
335
+ {@render edgeToggle('left')}
336
+ {/if}
337
+ </div>
338
+ {#if tenantFooter}
339
+ <div class="smrt-admin-shell__tenant-footer">
340
+ {@render tenantFooter()}
341
+ </div>
342
+ {/if}
343
+ </div>
322
344
  {:else if tenantRail}
323
345
  {@render tenantRail()}
324
346
  {:else}
@@ -349,7 +371,9 @@ function buildLayoutStyle(shell: ModuleShellState): string {
349
371
  </div>
350
372
  {#if edgeExpanded('right')}
351
373
  <div class="smrt-admin-shell__panel smrt-admin-shell__panel--right">
352
- {@render focusContent(activeFocusTool)}
374
+ {#key shell.activeFocusToolId}
375
+ {@render focusContent(resolveActiveFocusTool())}
376
+ {/key}
353
377
  </div>
354
378
  {/if}
355
379
  </aside>
@@ -457,6 +481,7 @@ function buildLayoutStyle(shell: ModuleShellState): string {
457
481
  var(--smrt-admin-shell-top-track) minmax(0, 1fr)
458
482
  var(--smrt-admin-shell-bottom-track);
459
483
  min-block-size: 100svh;
484
+ block-size: 100svh;
460
485
  background: var(--smrt-color-surface);
461
486
  color: var(--smrt-color-on-surface);
462
487
  overflow: hidden;
@@ -561,6 +586,32 @@ function buildLayoutStyle(shell: ModuleShellState): string {
561
586
  padding: var(--smrt-spacing-3);
562
587
  }
563
588
 
589
+ .smrt-admin-shell__edge--left[data-state='expanded']
590
+ .smrt-admin-shell__rail {
591
+ overflow: hidden;
592
+ }
593
+
594
+ .smrt-admin-shell__tenant-stack {
595
+ display: grid;
596
+ grid-template-rows: minmax(0, 1fr) auto;
597
+ gap: var(--smrt-spacing-3);
598
+ min-width: 0;
599
+ min-height: 0;
600
+ block-size: 100%;
601
+ }
602
+
603
+ .smrt-admin-shell__tenant-content {
604
+ min-width: 0;
605
+ min-height: 0;
606
+ overflow: auto;
607
+ }
608
+
609
+ .smrt-admin-shell__tenant-footer {
610
+ min-width: 0;
611
+ padding-block-start: var(--smrt-spacing-3);
612
+ border-block-start: 1px solid var(--smrt-color-outline-variant);
613
+ }
614
+
564
615
  .smrt-admin-shell__edge--right[data-state='expanded']
565
616
  .smrt-admin-shell__rail {
566
617
  grid-column: 2;
@@ -1,14 +1,17 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import { type ShellState } from './state.svelte.js';
3
- import type { AdminShellProps } from './types.js';
3
+ import type { AdminShellProps, ShellFocusTool } from './types.js';
4
4
  interface Props extends AdminShellProps {
5
5
  state?: ShellState;
6
6
  appBar?: Snippet;
7
7
  appPanel?: Snippet;
8
8
  tenantRail?: Snippet;
9
9
  tenantPanel?: Snippet;
10
+ tenantFooter?: Snippet;
10
11
  focusRail?: Snippet;
11
- focusPanel?: Snippet;
12
+ focusPanel?: Snippet<[{
13
+ tool: ShellFocusTool | null;
14
+ }]>;
12
15
  systemBar?: Snippet;
13
16
  systemPanel?: Snippet;
14
17
  topLeftCorner?: Snippet;
@@ -1 +1 @@
1
- {"version":3,"file":"AdminShell.svelte.d.ts","sourceRoot":"","sources":["../../../../src/components/workspace/admin-shell/AdminShell.svelte.ts"],"names":[],"mappings":"AA2CA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAStC,OAAO,EAAoB,KAAK,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACtE,OAAO,KAAK,EACR,eAAe,EAGhB,MAAM,YAAY,CAAC;AAGpB,UAAU,KAAM,SAAQ,eAAe;IACrC,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,QAAQ,EAAE,OAAO,CAAC;CACnB;AA0UH,QAAA,MAAM,UAAU,2CAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
1
+ {"version":3,"file":"AdminShell.svelte.d.ts","sourceRoot":"","sources":["../../../../src/components/workspace/admin-shell/AdminShell.svelte.ts"],"names":[],"mappings":"AA2CA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAStC,OAAO,EAAoB,KAAK,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACtE,OAAO,KAAK,EACR,eAAe,EAEf,cAAc,EACf,MAAM,YAAY,CAAC;AAGpB,UAAU,KAAM,SAAQ,eAAe;IACrC,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC,CAAC;QAAE,IAAI,EAAE,cAAc,GAAG,IAAI,CAAA;KAAE,CAAC,CAAC,CAAC;IACxD,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,QAAQ,EAAE,OAAO,CAAC;CACnB;AAiWH,QAAA,MAAM,UAAU,2CAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}