@happyvertical/smrt-svelte 0.51.39 → 0.53.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 (143) hide show
  1. package/AGENTS.md +3 -0
  2. package/README.md +214 -1
  3. package/agents/components.md +8 -1
  4. package/agents/workspace.md +24 -0
  5. package/dist/Provider.svelte +19 -15
  6. package/dist/Provider.svelte.d.ts +2 -2
  7. package/dist/Provider.svelte.d.ts.map +1 -1
  8. package/dist/__tests__/provider-mode-harness.svelte +14 -0
  9. package/dist/__tests__/provider-mode-harness.svelte.d.ts +11 -0
  10. package/dist/__tests__/provider-mode-harness.svelte.d.ts.map +1 -0
  11. package/dist/__tests__/provider-mode-probe.svelte +13 -0
  12. package/dist/__tests__/provider-mode-probe.svelte.d.ts +8 -0
  13. package/dist/__tests__/provider-mode-probe.svelte.d.ts.map +1 -0
  14. package/dist/browser-ai/adapters/stt/browser-speech.d.ts +11 -0
  15. package/dist/browser-ai/adapters/stt/browser-speech.d.ts.map +1 -1
  16. package/dist/browser-ai/adapters/stt/browser-speech.js +20 -4
  17. package/dist/browser-ai/adapters/stt/browser-speech.test.js +113 -0
  18. package/dist/browser-ai/adapters/stt/dictation-source.d.ts +3 -0
  19. package/dist/browser-ai/adapters/stt/dictation-source.d.ts.map +1 -0
  20. package/dist/browser-ai/adapters/stt/dictation-source.js +38 -0
  21. package/dist/browser-ai/adapters/stt/dictation-source.test.js +52 -0
  22. package/dist/browser-ai/adapters/stt/index.d.ts +2 -1
  23. package/dist/browser-ai/adapters/stt/index.d.ts.map +1 -1
  24. package/dist/browser-ai/adapters/stt/index.js +2 -1
  25. package/dist/components/forms/FileUpload.svelte +120 -16
  26. package/dist/components/forms/FileUpload.svelte.d.ts +7 -1
  27. package/dist/components/forms/FileUpload.svelte.d.ts.map +1 -1
  28. package/dist/components/forms/Form.svelte +118 -8
  29. package/dist/components/forms/Form.svelte.d.ts +18 -2
  30. package/dist/components/forms/Form.svelte.d.ts.map +1 -1
  31. package/dist/components/forms/FormScope.svelte +202 -0
  32. package/dist/components/forms/FormScope.svelte.d.ts +22 -0
  33. package/dist/components/forms/FormScope.svelte.d.ts.map +1 -0
  34. package/dist/components/forms/__tests__/FileUpload.native-form.test.js +249 -0
  35. package/dist/components/forms/__tests__/FileUploadReset.fixture.svelte +24 -0
  36. package/dist/components/forms/__tests__/FileUploadReset.fixture.svelte.d.ts +10 -0
  37. package/dist/components/forms/__tests__/FileUploadReset.fixture.svelte.d.ts.map +1 -0
  38. package/dist/components/forms/__tests__/Form.ui-controls.test.js +119 -0
  39. package/dist/components/forms/__tests__/Form.webmcp.test.js +5 -3
  40. package/dist/components/forms/__tests__/FormScope.test.js +39 -0
  41. package/dist/components/forms/__tests__/form-scope.fixture.svelte +30 -0
  42. package/dist/components/forms/__tests__/form-scope.fixture.svelte.d.ts +10 -0
  43. package/dist/components/forms/__tests__/form-scope.fixture.svelte.d.ts.map +1 -0
  44. package/dist/components/forms/__tests__/form-with-ui-controls.fixture.svelte +84 -0
  45. package/dist/components/forms/__tests__/form-with-ui-controls.fixture.svelte.d.ts +14 -0
  46. package/dist/components/forms/__tests__/form-with-ui-controls.fixture.svelte.d.ts.map +1 -0
  47. package/dist/components/forms/index.d.ts +1 -0
  48. package/dist/components/forms/index.d.ts.map +1 -1
  49. package/dist/components/forms/index.js +1 -0
  50. package/dist/components/workspace/README.md +11 -0
  51. package/dist/components/workspace/__tests__/AdminShell.responsive.test.js +597 -0
  52. package/dist/components/workspace/__tests__/AdminShell.test.js +257 -1
  53. package/dist/components/workspace/__tests__/TenantNav.test.js +115 -0
  54. package/dist/components/workspace/__tests__/WorkspaceAccountMenu.test.js +6 -0
  55. package/dist/components/workspace/__tests__/admin-shell-header-overflow.test.js +25 -0
  56. package/dist/components/workspace/__tests__/admin-shell-mobile.test.js +217 -0
  57. package/dist/components/workspace/__tests__/admin-shell-page-contracts.test.js +30 -0
  58. package/dist/components/workspace/__tests__/admin-shell-responsive-state.test.js +184 -0
  59. package/dist/components/workspace/__tests__/admin-shell-state.test.js +25 -0
  60. package/dist/components/workspace/__tests__/admin-shell-theme-hooks.test.js +36 -0
  61. package/dist/components/workspace/__tests__/shell-chrome-components.test.js +108 -0
  62. package/dist/components/workspace/admin-shell/AdminShell.svelte +1099 -59
  63. package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts +44 -1
  64. package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts.map +1 -1
  65. package/dist/components/workspace/admin-shell/PhoneBottomBar.svelte +170 -0
  66. package/dist/components/workspace/admin-shell/PhoneBottomBar.svelte.d.ts +11 -0
  67. package/dist/components/workspace/admin-shell/PhoneBottomBar.svelte.d.ts.map +1 -0
  68. package/dist/components/workspace/admin-shell/PhoneTopBar.svelte +93 -0
  69. package/dist/components/workspace/admin-shell/PhoneTopBar.svelte.d.ts +11 -0
  70. package/dist/components/workspace/admin-shell/PhoneTopBar.svelte.d.ts.map +1 -0
  71. package/dist/components/workspace/admin-shell/ShellNavToggle.svelte +88 -0
  72. package/dist/components/workspace/admin-shell/ShellNavToggle.svelte.d.ts +14 -0
  73. package/dist/components/workspace/admin-shell/ShellNavToggle.svelte.d.ts.map +1 -0
  74. package/dist/components/workspace/admin-shell/ShellTitle.svelte +42 -0
  75. package/dist/components/workspace/admin-shell/ShellTitle.svelte.d.ts +11 -0
  76. package/dist/components/workspace/admin-shell/ShellTitle.svelte.d.ts.map +1 -0
  77. package/dist/components/workspace/admin-shell/TenantNav.svelte +116 -13
  78. package/dist/components/workspace/admin-shell/TenantNav.svelte.d.ts +8 -2
  79. package/dist/components/workspace/admin-shell/TenantNav.svelte.d.ts.map +1 -1
  80. package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte +16 -2
  81. package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte.d.ts +2 -0
  82. package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte.d.ts.map +1 -1
  83. package/dist/components/workspace/admin-shell/mobile-shell-dom.d.ts +37 -0
  84. package/dist/components/workspace/admin-shell/mobile-shell-dom.d.ts.map +1 -0
  85. package/dist/components/workspace/admin-shell/mobile-shell-dom.js +150 -0
  86. package/dist/components/workspace/admin-shell/mobile-shell.d.ts +191 -0
  87. package/dist/components/workspace/admin-shell/mobile-shell.d.ts.map +1 -0
  88. package/dist/components/workspace/admin-shell/mobile-shell.js +165 -0
  89. package/dist/components/workspace/admin-shell/settings.d.ts +27 -2
  90. package/dist/components/workspace/admin-shell/settings.d.ts.map +1 -1
  91. package/dist/components/workspace/admin-shell/settings.js +84 -2
  92. package/dist/components/workspace/admin-shell/state.svelte.d.ts +57 -1
  93. package/dist/components/workspace/admin-shell/state.svelte.d.ts.map +1 -1
  94. package/dist/components/workspace/admin-shell/state.svelte.js +156 -6
  95. package/dist/components/workspace/admin-shell/types.d.ts +115 -1
  96. package/dist/components/workspace/admin-shell/types.d.ts.map +1 -1
  97. package/dist/components/workspace/admin-shell/types.js +13 -0
  98. package/dist/components/workspace/index.d.ts +11 -4
  99. package/dist/components/workspace/index.d.ts.map +1 -1
  100. package/dist/components/workspace/index.js +12 -3
  101. package/dist/i18n/strings.forms.d.ts +3 -0
  102. package/dist/i18n/strings.forms.d.ts.map +1 -1
  103. package/dist/i18n/strings.forms.js +3 -0
  104. package/dist/i18n/strings.workspace.d.ts +14 -0
  105. package/dist/i18n/strings.workspace.d.ts.map +1 -1
  106. package/dist/i18n/strings.workspace.js +14 -0
  107. package/dist/index.d.ts +1 -1
  108. package/dist/index.d.ts.map +1 -1
  109. package/dist/index.js +1 -1
  110. package/dist/provider-mode.test.js +86 -0
  111. package/dist/state/app-state.svelte.d.ts.map +1 -1
  112. package/dist/state/app-state.svelte.js +8 -8
  113. package/dist/web/__tests__/assistant-dock.integration.svelte.test.js +2 -2
  114. package/dist/web/__tests__/list-sort-surface.fixture.svelte +41 -0
  115. package/dist/web/__tests__/list-sort-surface.fixture.svelte.d.ts +15 -0
  116. package/dist/web/__tests__/list-sort-surface.fixture.svelte.d.ts.map +1 -0
  117. package/dist/web/__tests__/list-sort-surface.svelte.test.js +59 -0
  118. package/dist/web/__tests__/surfaces.child.fixture.svelte +69 -0
  119. package/dist/web/__tests__/surfaces.child.fixture.svelte.d.ts +16 -0
  120. package/dist/web/__tests__/surfaces.child.fixture.svelte.d.ts.map +1 -0
  121. package/dist/web/__tests__/surfaces.fixture.svelte +26 -0
  122. package/dist/web/__tests__/surfaces.fixture.svelte.d.ts +18 -0
  123. package/dist/web/__tests__/surfaces.fixture.svelte.d.ts.map +1 -0
  124. package/dist/web/__tests__/surfaces.svelte.test.js +140 -0
  125. package/dist/web/__tests__/webmcp-ui.test.js +18 -0
  126. package/dist/web/index.d.ts +4 -1
  127. package/dist/web/index.d.ts.map +1 -1
  128. package/dist/web/index.js +4 -1
  129. package/dist/web/link-surface.svelte.d.ts +14 -0
  130. package/dist/web/link-surface.svelte.d.ts.map +1 -0
  131. package/dist/web/link-surface.svelte.js +49 -0
  132. package/dist/web/list-surface.svelte.d.ts +80 -0
  133. package/dist/web/list-surface.svelte.d.ts.map +1 -0
  134. package/dist/web/list-surface.svelte.js +309 -0
  135. package/dist/web/step-surface.svelte.d.ts +17 -0
  136. package/dist/web/step-surface.svelte.d.ts.map +1 -0
  137. package/dist/web/step-surface.svelte.js +56 -0
  138. package/dist/web/webmcp-ui-context.d.ts +7 -0
  139. package/dist/web/webmcp-ui-context.d.ts.map +1 -1
  140. package/dist/web/webmcp-ui-context.js +10 -0
  141. package/dist/web/webmcp-ui.d.ts.map +1 -1
  142. package/dist/web/webmcp-ui.js +16 -3
  143. package/package.json +17 -17
@@ -1,5 +1,5 @@
1
1
  import { createRawSnippet, flushSync, mount, tick, unmount } from 'svelte';
2
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
3
  import AdminShell from '../admin-shell/AdminShell.svelte';
4
4
  import adminShellSource from '../admin-shell/AdminShell.svelte?raw';
5
5
  import { createShellState } from '../admin-shell/state.svelte.js';
@@ -15,13 +15,184 @@ function activeToolSnippet() {
15
15
  }
16
16
  let container;
17
17
  beforeEach(() => {
18
+ vi.stubGlobal('matchMedia', () => ({
19
+ matches: false,
20
+ addEventListener() { },
21
+ removeEventListener() { },
22
+ }));
18
23
  container = document.createElement('div');
19
24
  document.body.appendChild(container);
20
25
  });
21
26
  afterEach(() => {
27
+ vi.unstubAllGlobals();
22
28
  container.remove();
23
29
  });
24
30
  describe('AdminShell', () => {
31
+ it('keeps a single tenant footer reachable when the left edge collapses or hides', async () => {
32
+ const state = createShellState({
33
+ config: { left: { initial: 'collapsed' } },
34
+ });
35
+ const component = mount(AdminShell, {
36
+ target: container,
37
+ props: {
38
+ state,
39
+ tenantFooter: textSnippet('Dana account'),
40
+ children: textSnippet('main'),
41
+ },
42
+ });
43
+ try {
44
+ await tick();
45
+ expect(container.querySelector('header')?.textContent).toContain('Dana account');
46
+ state.setPanelState('left', 'expanded');
47
+ flushSync();
48
+ expect(container.querySelector('header')?.textContent).not.toContain('Dana account');
49
+ expect(container.querySelector('.smrt-admin-shell__tenant-footer')
50
+ ?.textContent).toContain('Dana account');
51
+ state.setPanelState('left', 'hidden');
52
+ flushSync();
53
+ expect(container.querySelector('header')?.textContent).toContain('Dana account');
54
+ expect(container.textContent?.match(/Dana account/g)).toHaveLength(1);
55
+ }
56
+ finally {
57
+ await unmount(component);
58
+ }
59
+ });
60
+ it('keeps an explicit account slot in a custom app bar regardless of left state', async () => {
61
+ const state = createShellState({
62
+ config: { left: { initial: 'expanded' } },
63
+ });
64
+ const component = mount(AdminShell, {
65
+ target: container,
66
+ props: {
67
+ state,
68
+ account: textSnippet('Dana account'),
69
+ appBar: textSnippet('Custom title'),
70
+ children: textSnippet('main'),
71
+ },
72
+ });
73
+ try {
74
+ await tick();
75
+ expect(container.querySelector('header')?.textContent).toContain('Dana account');
76
+ }
77
+ finally {
78
+ await unmount(component);
79
+ }
80
+ });
81
+ it('keeps the left collapse control with supplied navigation and permits opt-out', async () => {
82
+ for (const showTenantToggle of [true, false]) {
83
+ const state = createShellState({
84
+ config: { left: { initial: 'expanded' } },
85
+ });
86
+ const component = mount(AdminShell, {
87
+ target: container,
88
+ props: {
89
+ state,
90
+ showTenantToggle,
91
+ tenantPanel: textSnippet('supplied nav'),
92
+ children: textSnippet('main'),
93
+ },
94
+ });
95
+ try {
96
+ await tick();
97
+ const toggle = container.querySelector('.smrt-admin-shell__edge--left .smrt-admin-shell__edge-toggle');
98
+ expect(!!toggle).toBe(showTenantToggle);
99
+ if (toggle) {
100
+ toggle.click();
101
+ flushSync();
102
+ expect(state.panels.left).toBe('collapsed');
103
+ }
104
+ }
105
+ finally {
106
+ await unmount(component);
107
+ }
108
+ }
109
+ });
110
+ it('preserves the system toggle when a custom system bar is supplied', async () => {
111
+ const component = mount(AdminShell, {
112
+ target: container,
113
+ props: {
114
+ systemBar: textSnippet('custom status'),
115
+ children: textSnippet('main'),
116
+ },
117
+ });
118
+ try {
119
+ await tick();
120
+ expect(container.querySelector('footer .smrt-admin-shell__edge-toggle')).not.toBeNull();
121
+ }
122
+ finally {
123
+ unmount(component);
124
+ }
125
+ });
126
+ it('links full and compact branding home with an accessible title', async () => {
127
+ const state = createShellState({
128
+ config: { left: { initial: 'collapsed' } },
129
+ });
130
+ const component = mount(AdminShell, {
131
+ target: container,
132
+ props: {
133
+ state,
134
+ title: 'Shop',
135
+ homeHref: '/home',
136
+ children: textSnippet('main'),
137
+ },
138
+ });
139
+ try {
140
+ await tick();
141
+ const links = container.querySelectorAll('.smrt-admin-shell__brand-link');
142
+ expect(links).toHaveLength(2);
143
+ expect([...links].map((link) => link.getAttribute('href'))).toEqual([
144
+ '/home',
145
+ '/home',
146
+ ]);
147
+ expect([...links].map((link) => link.getAttribute('aria-label'))).toEqual(['Shop', 'Shop']);
148
+ expect(links[1].textContent?.trim()).toBe('S');
149
+ }
150
+ finally {
151
+ unmount(component);
152
+ }
153
+ });
154
+ it('renders a logo and keeps default branding unlinked without homeHref', async () => {
155
+ const component = mount(AdminShell, {
156
+ target: container,
157
+ props: {
158
+ title: 'Shop',
159
+ logoSrc: '/shop.svg',
160
+ logoAlt: 'Shop mark',
161
+ children: textSnippet('main'),
162
+ },
163
+ });
164
+ try {
165
+ await tick();
166
+ expect(container.querySelector('img')?.getAttribute('src')).toBe('/shop.svg');
167
+ expect(container.querySelector('img')?.getAttribute('alt')).toBe('Shop mark');
168
+ expect(container.querySelector('.smrt-admin-shell__brand-link')).toBeNull();
169
+ }
170
+ finally {
171
+ unmount(component);
172
+ }
173
+ });
174
+ it('passes full and compact context to custom brand snippets', async () => {
175
+ const brand = createRawSnippet((context) => ({
176
+ render: () => `<span>${context().compact ? 'Compact mark' : 'Full mark'}</span>`,
177
+ }));
178
+ const component = mount(AdminShell, {
179
+ target: container,
180
+ props: {
181
+ title: 'Shop',
182
+ homeHref: '/',
183
+ brand,
184
+ children: textSnippet('main'),
185
+ },
186
+ });
187
+ try {
188
+ await tick();
189
+ expect(container.textContent).toContain('Full mark');
190
+ expect(container.textContent).toContain('Compact mark');
191
+ }
192
+ finally {
193
+ unmount(component);
194
+ }
195
+ });
25
196
  it('renders the four edge shell and body content', () => {
26
197
  const component = mount(AdminShell, {
27
198
  target: container,
@@ -141,6 +312,91 @@ describe('AdminShell', () => {
141
312
  unmount(component);
142
313
  }
143
314
  });
315
+ it('keeps a keepMounted focus panel (and its draft) mounted while collapsed', async () => {
316
+ const state = createShellState({
317
+ config: { right: { initial: 'expanded', keepMounted: true } },
318
+ });
319
+ const component = mount(AdminShell, {
320
+ target: container,
321
+ props: {
322
+ state,
323
+ children: textSnippet('main work'),
324
+ focusPanel: createRawSnippet(() => ({
325
+ render: () => '<textarea aria-label="Message"></textarea>',
326
+ })),
327
+ },
328
+ });
329
+ try {
330
+ const draft = container.querySelector('textarea');
331
+ if (!draft)
332
+ throw new Error('focus panel did not render');
333
+ draft.value = 'half-typed question';
334
+ state.collapsePanel('right');
335
+ await tick();
336
+ const panel = container.querySelector('.smrt-admin-shell__panel--right');
337
+ expect(panel?.hidden).toBe(true);
338
+ expect(container.querySelector('textarea')).toBe(draft);
339
+ state.expandPanel('right');
340
+ await tick();
341
+ expect(panel?.hidden).toBe(false);
342
+ expect(container.querySelector('textarea')).toBe(draft);
343
+ expect(draft.value).toBe('half-typed question');
344
+ }
345
+ finally {
346
+ unmount(component);
347
+ }
348
+ });
349
+ it('unmounts a collapsed panel without keepMounted', async () => {
350
+ const state = createShellState({
351
+ config: { right: { initial: 'expanded' } },
352
+ });
353
+ const component = mount(AdminShell, {
354
+ target: container,
355
+ props: {
356
+ state,
357
+ children: textSnippet('main work'),
358
+ focusPanel: textSnippet('focus panel'),
359
+ },
360
+ });
361
+ try {
362
+ expect(container.querySelector('.smrt-admin-shell__panel--right')).not.toBeNull();
363
+ state.collapsePanel('right');
364
+ await tick();
365
+ expect(container.querySelector('.smrt-admin-shell__panel--right')).toBeNull();
366
+ }
367
+ finally {
368
+ unmount(component);
369
+ }
370
+ });
371
+ it('keeps a keepMounted tenant panel beside the collapsed rail', async () => {
372
+ const state = createShellState({
373
+ config: { left: { initial: 'collapsed', keepMounted: true } },
374
+ });
375
+ const component = mount(AdminShell, {
376
+ target: container,
377
+ props: {
378
+ state,
379
+ children: textSnippet('main work'),
380
+ tenantRail: textSnippet('tenant rail'),
381
+ tenantPanel: textSnippet('tenant navigation'),
382
+ },
383
+ });
384
+ try {
385
+ const stack = container.querySelector('.smrt-admin-shell__tenant-stack');
386
+ expect(stack?.hidden).toBe(true);
387
+ expect(stack?.textContent).toContain('tenant navigation');
388
+ const rail = container.querySelector('.smrt-admin-shell__rail');
389
+ expect(rail?.textContent).toContain('tenant rail');
390
+ state.expandPanel('left');
391
+ await tick();
392
+ expect(container.querySelector('.smrt-admin-shell__tenant-stack')).toBe(stack);
393
+ expect(stack?.hidden).toBe(false);
394
+ expect(rail?.textContent).not.toContain('tenant rail');
395
+ }
396
+ finally {
397
+ unmount(component);
398
+ }
399
+ });
144
400
  it('pins a tenant footer below the scrolling tenant panel', () => {
145
401
  const state = createShellState({
146
402
  config: { left: { initial: 'expanded' } },
@@ -11,6 +11,72 @@ afterEach(() => {
11
11
  container.remove();
12
12
  });
13
13
  describe('TenantNav', () => {
14
+ it('renders named groups alongside flat links and keeps grouped active children', () => {
15
+ const component = mount(TenantNav, {
16
+ target: container,
17
+ props: {
18
+ 'aria-label': 'Shop navigation',
19
+ currentHref: '/orders/open',
20
+ items: [{ href: '/home', label: 'Home' }],
21
+ groups: [
22
+ {
23
+ heading: 'Floor',
24
+ items: [
25
+ {
26
+ href: '/orders',
27
+ label: 'Orders',
28
+ children: [{ href: '/orders/open', label: 'Open orders' }],
29
+ },
30
+ ],
31
+ },
32
+ { heading: 'Office', items: [] },
33
+ ],
34
+ },
35
+ });
36
+ try {
37
+ expect(container.querySelector('nav')).toHaveAttribute('aria-label', 'Shop navigation');
38
+ expect(container.querySelector('details[aria-label="Floor"]')).not.toBeNull();
39
+ expect(container.querySelectorAll('details')).toHaveLength(2);
40
+ expect(container.querySelector('a[href="/orders/open"]')).toHaveAttribute('aria-current', 'page');
41
+ expect(container.querySelectorAll('a')).toHaveLength(3);
42
+ }
43
+ finally {
44
+ unmount(component);
45
+ }
46
+ });
47
+ it('keeps collapsed group summaries named and independent of navigation links', () => {
48
+ const component = mount(TenantNav, {
49
+ target: container,
50
+ props: {
51
+ items: [],
52
+ collapsed: true,
53
+ groups: [
54
+ { heading: 'Floor', items: [{ href: '/tasks', label: 'Tasks' }] },
55
+ ],
56
+ },
57
+ });
58
+ try {
59
+ const summary = container.querySelector('summary');
60
+ expect(summary?.textContent).toContain('Floor');
61
+ expect(container.querySelector('details')).toHaveAttribute('open');
62
+ expect(container.querySelectorAll('a')).toHaveLength(1);
63
+ }
64
+ finally {
65
+ unmount(component);
66
+ }
67
+ });
68
+ it('accepts per-instance touch density while leaving the default inherited', () => {
69
+ const component = mount(TenantNav, {
70
+ target: container,
71
+ props: { density: 'touch', items: [{ href: '/tasks', label: 'Tasks' }] },
72
+ });
73
+ try {
74
+ expect(container.querySelector('nav')).toHaveAttribute('data-density', 'touch');
75
+ }
76
+ finally {
77
+ unmount(component);
78
+ }
79
+ });
14
80
  it('renders custom icon components for nav items', () => {
15
81
  const component = mount(TenantNav, {
16
82
  target: container,
@@ -35,6 +101,55 @@ describe('TenantNav', () => {
35
101
  unmount(component);
36
102
  }
37
103
  });
104
+ it('marks items that need attention with a labelled dot', () => {
105
+ const component = mount(TenantNav, {
106
+ target: container,
107
+ props: {
108
+ currentHref: '/admin/tasks',
109
+ items: [
110
+ { href: '/admin/tasks', label: 'Tasks' },
111
+ {
112
+ href: '/admin/jobs',
113
+ label: 'Jobs',
114
+ attention: 'A background job failed recently',
115
+ },
116
+ { href: '/admin/health', label: 'Health', attention: true },
117
+ ],
118
+ },
119
+ });
120
+ try {
121
+ const links = [...container.querySelectorAll('a')];
122
+ expect(links[0].querySelector('[data-attention]')).toBeNull();
123
+ expect(links[1].querySelector('[data-attention]')).not.toBeNull();
124
+ expect(links[1].querySelector('[data-attention]')?.getAttribute('aria-hidden')).toBe('true');
125
+ expect(links[1].textContent).toContain('(A background job failed recently)');
126
+ expect(links[2].textContent).toContain('(Needs attention)');
127
+ }
128
+ finally {
129
+ unmount(component);
130
+ }
131
+ });
132
+ it('keeps the attention dot on a collapsed icon link', () => {
133
+ const component = mount(TenantNav, {
134
+ target: container,
135
+ props: {
136
+ collapsed: true,
137
+ iconComponent: TestIcon,
138
+ items: [
139
+ { href: '/admin/jobs', icon: 'jobs', label: 'Jobs', attention: true },
140
+ ],
141
+ },
142
+ });
143
+ try {
144
+ const link = container.querySelector('a');
145
+ expect(link?.querySelector('[data-attention]')).not.toBeNull();
146
+ expect(link?.textContent).toContain('Jobs');
147
+ expect(link?.textContent).toContain('(Needs attention)');
148
+ }
149
+ finally {
150
+ unmount(component);
151
+ }
152
+ });
38
153
  it('uses top-level icon links when collapsed', () => {
39
154
  const component = mount(TenantNav, {
40
155
  target: container,
@@ -8,6 +8,12 @@ const tenants = [
8
8
  { id: 'tenant-b', label: 'Beta', roleLabel: 'Member' },
9
9
  ];
10
10
  describe('WorkspaceAccountMenu', () => {
11
+ it('exposes typed touch density without changing account actions', () => {
12
+ const { container } = render(WorkspaceAccountMenu, {
13
+ props: { userName: 'Dana', density: 'touch', onSignOut: vi.fn() },
14
+ });
15
+ expect(container.querySelector('.smrt-workspace-account-menu')).toHaveAttribute('data-density', 'touch');
16
+ });
11
17
  it('switches tenant and signs out through app-owned callbacks', async () => {
12
18
  const user = userEvent.setup();
13
19
  const onTenantSelect = vi.fn();
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The header row hosts actions that open menus (a notifications bell, an
3
+ * account menu). Its overflow must clip sideways only, or every dropdown
4
+ * opened from it is cut off at the header's bottom edge.
5
+ */
6
+ import { readFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { describe, expect, it } from 'vitest';
9
+ const packageRoot = process.cwd().endsWith('packages/smrt-svelte')
10
+ ? process.cwd()
11
+ : join(process.cwd(), 'packages/smrt-svelte');
12
+ const source = readFileSync(join(packageRoot, 'src/components/workspace/admin-shell/AdminShell.svelte'), 'utf8');
13
+ function ruleBody(selector) {
14
+ const start = source.indexOf(`${selector} {`);
15
+ expect(start).toBeGreaterThan(-1);
16
+ return source.slice(start, source.indexOf('}', start));
17
+ }
18
+ describe('AdminShell header overflow', () => {
19
+ it('lets header menus drop below the header row', () => {
20
+ const header = ruleBody('.smrt-admin-shell__header');
21
+ expect(header).not.toMatch(/(^|\s)overflow:\s*hidden/);
22
+ expect(header).toMatch(/overflow-x:\s*clip/);
23
+ expect(header).toMatch(/overflow-y:\s*visible/);
24
+ });
25
+ });
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Pure phone-chrome decisions behind AdminShell: hide-on-scroll, keyboard
3
+ * heuristic, viewport classes, the phone top-bar model, and bottom-bar mode.
4
+ */
5
+ import { describe, expect, it } from 'vitest';
6
+ import { bottomBarMode, findShellNavTrail, keyboardLikelyOpen, normalizeShellPath, phoneTopBarFor, ScrollChrome, shellPageTrailFor, viewportFor, } from '../admin-shell/mobile-shell.js';
7
+ describe('ScrollChrome (hide on scroll)', () => {
8
+ function run(chrome, ys, extra = {}) {
9
+ return ys.map((y) => chrome.update({ y, ...extra }));
10
+ }
11
+ it('stays shown near the top of the page', () => {
12
+ expect(run(new ScrollChrome(), [0, 20, 40, 56])).toEqual([
13
+ false,
14
+ false,
15
+ false,
16
+ false,
17
+ ]);
18
+ });
19
+ it('hides after scrolling down and comes back on the first scroll up', () => {
20
+ const chrome = new ScrollChrome();
21
+ expect(run(chrome, [0, 100, 120, 300])).toEqual([false, true, true, true]);
22
+ expect(chrome.update({ y: 295 })).toBe(false);
23
+ expect(run(chrome, [300, 306, 310])).toEqual([false, false, true]);
24
+ });
25
+ it('ignores jitter below the thresholds', () => {
26
+ const chrome = new ScrollChrome();
27
+ run(chrome, [0, 200, 400]);
28
+ expect(chrome.update({ y: 398 })).toBe(true);
29
+ });
30
+ it('always shows at the top and while pinned', () => {
31
+ const chrome = new ScrollChrome();
32
+ run(chrome, [0, 200, 400]);
33
+ expect(chrome.update({ y: 10 })).toBe(false);
34
+ run(chrome, [200, 400]);
35
+ expect(chrome.update({ y: 600, pinned: true })).toBe(false);
36
+ expect(chrome.update({ y: 800, pinned: true })).toBe(false);
37
+ });
38
+ it('does not treat iOS overscroll bounce as a scroll up', () => {
39
+ const chrome = new ScrollChrome();
40
+ run(chrome, [0, 300, 500], { maxY: 500 });
41
+ expect(chrome.update({ y: 540, maxY: 500 })).toBe(true);
42
+ expect(chrome.update({ y: 500, maxY: 500 })).toBe(true);
43
+ });
44
+ it('honours custom tuning and reset', () => {
45
+ const chrome = new ScrollChrome({ revealWithin: 0, hideAfter: 50 });
46
+ expect(run(chrome, [10, 40])).toEqual([false, false]);
47
+ expect(chrome.update({ y: 70 })).toBe(true);
48
+ chrome.reset();
49
+ expect(chrome.hidden).toBe(false);
50
+ });
51
+ });
52
+ describe('viewportFor', () => {
53
+ it('classifies phone, tablet and desktop', () => {
54
+ expect(viewportFor({ phone: true, desktop: false })).toBe('phone');
55
+ expect(viewportFor({ phone: false, desktop: false })).toBe('tablet');
56
+ expect(viewportFor({ phone: false, desktop: true })).toBe('desktop');
57
+ });
58
+ });
59
+ describe('keyboardLikelyOpen', () => {
60
+ it('is open when the visual viewport is much shorter', () => {
61
+ expect(keyboardLikelyOpen({ layoutHeight: 844, visualHeight: 500 })).toBe(true);
62
+ expect(keyboardLikelyOpen({ layoutHeight: 844, visualHeight: 780 })).toBe(false);
63
+ });
64
+ it('does not mistake pinch zoom for a keyboard', () => {
65
+ expect(keyboardLikelyOpen({ layoutHeight: 844, visualHeight: 422, scale: 2 })).toBe(false);
66
+ });
67
+ });
68
+ describe('nav paths', () => {
69
+ const nav = [
70
+ { href: '/', label: 'Home' },
71
+ {
72
+ href: '/settings',
73
+ label: 'Settings',
74
+ children: [{ href: '/settings/health', label: 'Health' }],
75
+ },
76
+ ];
77
+ it('normalizes trailing slashes', () => {
78
+ expect(normalizeShellPath('/a/b//')).toBe('/a/b');
79
+ expect(normalizeShellPath('/')).toBe('/');
80
+ });
81
+ it('finds the trail to the deepest matching item without the root swallowing everything', () => {
82
+ expect(findShellNavTrail(nav, '/settings/health/x').map((item) => item.label)).toEqual(['Settings', 'Health']);
83
+ expect(findShellNavTrail(nav, '/other')).toEqual([]);
84
+ });
85
+ });
86
+ describe('phoneTopBarFor', () => {
87
+ const navItems = [
88
+ { href: '/sites/alpha/articles', label: 'Articles' },
89
+ {
90
+ href: '/sites/alpha/settings',
91
+ label: 'Settings',
92
+ children: [{ href: '/sites/alpha/settings/health', label: 'Health' }],
93
+ },
94
+ ];
95
+ const base = { homeHref: '/sites/alpha', homeTitle: 'Alpha Times', navItems };
96
+ it('shows the workspace name on section homes', () => {
97
+ expect(phoneTopBarFor({ ...base, path: '/sites/alpha' })).toEqual({
98
+ kind: 'home',
99
+ title: 'Alpha Times',
100
+ });
101
+ expect(phoneTopBarFor({ ...base, path: '/sites/alpha/articles/' }).kind).toBe('home');
102
+ });
103
+ it('gives a nested nav page a back arrow to its parent nav item', () => {
104
+ expect(phoneTopBarFor({ ...base, path: '/sites/alpha/settings/health' })).toEqual({
105
+ kind: 'detail',
106
+ title: 'Health',
107
+ backHref: '/sites/alpha/settings',
108
+ backLabel: 'Settings',
109
+ });
110
+ });
111
+ it('goes back to the last page-given parent', () => {
112
+ expect(phoneTopBarFor({
113
+ ...base,
114
+ path: '/sites/alpha/articles/a1/video/create',
115
+ pageTitle: 'Create video',
116
+ parents: [
117
+ { label: 'Fair opens Friday', href: '/sites/alpha/articles/a1' },
118
+ { label: 'Videos', href: '/sites/alpha/articles/a1/videos' },
119
+ ],
120
+ })).toEqual({
121
+ kind: 'detail',
122
+ title: 'Create video',
123
+ backHref: '/sites/alpha/articles/a1/videos',
124
+ backLabel: 'Videos',
125
+ });
126
+ });
127
+ it('shows back to the parent list and the page title on detail pages', () => {
128
+ expect(phoneTopBarFor({
129
+ ...base,
130
+ path: '/sites/alpha/articles/a1/images',
131
+ pageTitle: 'Fair opens Friday',
132
+ })).toEqual({
133
+ kind: 'detail',
134
+ title: 'Fair opens Friday',
135
+ backHref: '/sites/alpha/articles',
136
+ backLabel: 'Articles',
137
+ });
138
+ });
139
+ it('falls back to the parent name, then to home', () => {
140
+ expect(phoneTopBarFor({ ...base, path: '/sites/alpha/articles/a1' })).toMatchObject({ title: 'Articles', backHref: '/sites/alpha/articles' });
141
+ expect(phoneTopBarFor({ ...base, path: '/sites/alpha/other/thing' })).toMatchObject({ kind: 'detail', backHref: '/sites/alpha' });
142
+ });
143
+ it('uses a page-provided parent and back label', () => {
144
+ expect(phoneTopBarFor({
145
+ ...base,
146
+ path: '/sites/alpha/articles/a1/video/create',
147
+ pageTitle: 'Create video',
148
+ backHref: '/sites/alpha/articles/a1/videos',
149
+ backLabel: 'Videos',
150
+ })).toEqual({
151
+ kind: 'detail',
152
+ title: 'Create video',
153
+ backHref: '/sites/alpha/articles/a1/videos',
154
+ backLabel: 'Videos',
155
+ });
156
+ });
157
+ });
158
+ describe('shellPageTrailFor', () => {
159
+ const navItems = [
160
+ { href: '/sites/alpha/articles', label: 'Content' },
161
+ {
162
+ href: '/sites/alpha/settings',
163
+ label: 'Settings',
164
+ children: [{ href: '/sites/alpha/health', label: 'Health' }],
165
+ },
166
+ ];
167
+ const base = { homeHref: '/sites/alpha', homeTitle: 'Alpha Times', navItems };
168
+ const labels = (path, parents) => shellPageTrailFor({ ...base, path, parents }).crumbs.map((c) => c.label);
169
+ it('has no crumbs on section homes', () => {
170
+ expect(shellPageTrailFor({ ...base, path: '/sites/alpha' })).toMatchObject({
171
+ sectionHome: true,
172
+ crumbs: [],
173
+ });
174
+ expect(shellPageTrailFor({ ...base, path: '/sites/alpha/articles/' })).toMatchObject({
175
+ sectionHome: true,
176
+ crumbs: [],
177
+ current: { label: 'Content' },
178
+ });
179
+ });
180
+ it('shows an ancestor once when the page also names it as a parent', () => {
181
+ expect(labels('/sites/alpha/articles/create', [
182
+ { label: 'Content', href: '/sites/alpha/articles/' },
183
+ ])).toEqual(['Alpha Times', 'Content']);
184
+ });
185
+ it('lists ancestors only, never the current page', () => {
186
+ expect(labels('/sites/alpha/articles/a1')).toEqual([
187
+ 'Alpha Times',
188
+ 'Content',
189
+ ]);
190
+ // A nested nav page: its parent item is an ancestor even when the URL
191
+ // is not below it.
192
+ expect(labels('/sites/alpha/health')).toEqual(['Alpha Times', 'Settings']);
193
+ expect(labels('/sites/alpha/health/job-1')).toEqual([
194
+ 'Alpha Times',
195
+ 'Settings',
196
+ 'Health',
197
+ ]);
198
+ expect(labels('/sites/alpha/other')).toEqual(['Alpha Times']);
199
+ });
200
+ it('appends page-given parents after the nav trail', () => {
201
+ expect(labels('/sites/alpha/articles/a1/videos', [
202
+ { label: 'Fair opens Friday', href: '/sites/alpha/articles/a1' },
203
+ ])).toEqual(['Alpha Times', 'Content', 'Fair opens Friday']);
204
+ // A parent on a section home makes it a child page.
205
+ expect(labels('/sites/alpha/articles', [
206
+ { label: 'Somewhere', href: '/sites/alpha/somewhere' },
207
+ ])).toEqual(['Alpha Times', 'Somewhere']);
208
+ });
209
+ });
210
+ describe('bottomBarMode', () => {
211
+ it('swaps the nav bar for a form bar and hides both with the keyboard', () => {
212
+ expect(bottomBarMode({ formActions: false, keyboardOpen: false })).toBe('nav');
213
+ expect(bottomBarMode({ formActions: true, keyboardOpen: false })).toBe('form');
214
+ expect(bottomBarMode({ formActions: true, keyboardOpen: true })).toBe('none');
215
+ expect(bottomBarMode({ formActions: false, keyboardOpen: true })).toBe('none');
216
+ });
217
+ });