@xenide-io/the-old-ui-theme 0.4.4 → 0.4.6

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 (75) hide show
  1. package/dist/{chunk-54ZR4VL5.mjs → chunk-RZXHZWUB.mjs} +44 -17
  2. package/dist/{index-B5NOyoKS.d.mts → index-Od4pIP4F.d.mts} +7 -1
  3. package/dist/{index-B5NOyoKS.d.ts → index-Od4pIP4F.d.ts} +7 -1
  4. package/dist/index.d.mts +2 -2
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.js +37 -10
  7. package/dist/index.mjs +1 -1
  8. package/dist/suite.d.mts +152 -41
  9. package/dist/suite.d.ts +152 -41
  10. package/dist/suite.js +1184 -661
  11. package/dist/suite.mjs +1122 -600
  12. package/dist/ui.d.mts +1 -1
  13. package/dist/ui.d.ts +1 -1
  14. package/dist/ui.js +37 -10
  15. package/dist/ui.mjs +1 -1
  16. package/package.json +7 -3
  17. package/src/components/ui/Modal.tsx +70 -26
  18. package/src/styles/suite-skin.css +284 -24
  19. package/src/suite/components/ai-panel.tsx +269 -103
  20. package/src/suite/components/suite-app-layout.tsx +48 -28
  21. package/src/suite/components/suite-layout.tsx +84 -40
  22. package/src/suite/components/suite-mobile-drawer.tsx +35 -22
  23. package/src/suite/components/suite-notification-bell.tsx +9 -3
  24. package/src/suite/components/suite-settings-layout.tsx +82 -0
  25. package/src/suite/components/suite-settings-mobile-nav.tsx +18 -17
  26. package/src/suite/components/suite-skeleton.tsx +3 -3
  27. package/src/suite/components/today-calibrating.tsx +11 -35
  28. package/src/suite/components/today-page-frame.tsx +19 -11
  29. package/src/suite/components/today-ui.tsx +276 -19
  30. package/src/suite/index.ts +41 -25
  31. package/src/suite/lib/apps.ts +32 -2
  32. package/src/suite/lib/use-sidebar-width.ts +114 -0
  33. package/src/components/sections/AccordionShowcase.tsx +0 -45
  34. package/src/components/sections/AlertShowcase.tsx +0 -40
  35. package/src/components/sections/AvatarShowcase.tsx +0 -80
  36. package/src/components/sections/BadgeShowcase.tsx +0 -65
  37. package/src/components/sections/ButtonShowcase.tsx +0 -308
  38. package/src/components/sections/CalendarShowcase.tsx +0 -35
  39. package/src/components/sections/CardShowcase.tsx +0 -159
  40. package/src/components/sections/CodeMockupShowcase.tsx +0 -56
  41. package/src/components/sections/ColorPalette.tsx +0 -117
  42. package/src/components/sections/CommandPaletteShowcase.tsx +0 -48
  43. package/src/components/sections/CountdownShowcase.tsx +0 -43
  44. package/src/components/sections/DiffShowcase.tsx +0 -70
  45. package/src/components/sections/DrawerShowcase.tsx +0 -97
  46. package/src/components/sections/DropdownShowcase.tsx +0 -98
  47. package/src/components/sections/EmptyStateShowcase.tsx +0 -50
  48. package/src/components/sections/FilterChipsShowcase.tsx +0 -98
  49. package/src/components/sections/FormShowcase.tsx +0 -127
  50. package/src/components/sections/HoverCardShowcase.tsx +0 -50
  51. package/src/components/sections/IconShowcase.tsx +0 -121
  52. package/src/components/sections/IndicatorShowcase.tsx +0 -52
  53. package/src/components/sections/KbdShowcase.tsx +0 -57
  54. package/src/components/sections/ModalShowcase.tsx +0 -211
  55. package/src/components/sections/NavigationShowcase.tsx +0 -199
  56. package/src/components/sections/NewComponentsShowcase.tsx +0 -1052
  57. package/src/components/sections/ProductLayoutsShowcase.tsx +0 -78
  58. package/src/components/sections/ProgressShowcase.tsx +0 -82
  59. package/src/components/sections/QuillChartShowcase.tsx +0 -92
  60. package/src/components/sections/QuillDashboardShowcase.tsx +0 -149
  61. package/src/components/sections/SegmentedControlShowcase.tsx +0 -49
  62. package/src/components/sections/SetupThemeShowcase.tsx +0 -262
  63. package/src/components/sections/SidebarShowcase.tsx +0 -152
  64. package/src/components/sections/StackShowcase.tsx +0 -33
  65. package/src/components/sections/StepperShowcase.tsx +0 -37
  66. package/src/components/sections/SuiteShowcase.tsx +0 -669
  67. package/src/components/sections/SwapShowcase.tsx +0 -99
  68. package/src/components/sections/TabShowcase.tsx +0 -84
  69. package/src/components/sections/TableShowcase.tsx +0 -142
  70. package/src/components/sections/TimelineShowcase.tsx +0 -83
  71. package/src/components/sections/ToastShowcase.tsx +0 -108
  72. package/src/components/sections/TooltipShowcase.tsx +0 -38
  73. package/src/components/sections/UtilityShowcase.tsx +0 -81
  74. package/src/styles/excel-themes.css +0 -110
  75. package/src/styles/lemon-primitives.css +0 -550
@@ -1,60 +1,157 @@
1
1
  'use client';
2
2
 
3
- import { useState, type ComponentType, type ReactNode } from 'react';
4
- import { Check, Copy, Xmark as X } from 'iconoir-react';
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useRef,
7
+ useState,
8
+ type ComponentType,
9
+ type ReactNode,
10
+ } from 'react';
11
+ import { Check, Copy, Sparks, Xmark as X } from 'iconoir-react';
12
+
13
+ export const SUITE_OPEN_ASK_AI_EVENT = 'shellstack:open-ask-ai';
14
+
15
+ export type SuiteAskAiOpenDetail = {
16
+ prompt?: string;
17
+ };
18
+
19
+ /** Open the suite Ask AI panel from anywhere (Today card, command palette, …). */
20
+ export function openSuiteAskAi(detail?: SuiteAskAiOpenDetail): void {
21
+ if (typeof window === 'undefined') return;
22
+ window.dispatchEvent(
23
+ new CustomEvent<SuiteAskAiOpenDetail>(SUITE_OPEN_ASK_AI_EVENT, {
24
+ detail: detail ?? {},
25
+ }),
26
+ );
27
+ }
5
28
 
6
29
  export interface SuiteAiPreset {
7
30
  label: string;
8
31
  prompt: string;
9
32
  }
10
33
 
34
+ export interface SuiteAiChatMessage {
35
+ role: 'user' | 'assistant';
36
+ content: string;
37
+ created_at?: string;
38
+ }
39
+
11
40
  /**
12
- * Thin ShellStack AI slide-over shared by suite apps.
41
+ * Suite-wide Ask AI slide-over Notion-style continuous chat.
13
42
  *
14
- * One-shot completions via the shared `/api/ai/complete` endpoint. Apps
15
- * inject their API helpers, preset prompts, brand icon and spinner. Renders
16
- * its own floating launcher so it can be mounted once in the authenticated
17
- * app shell.
43
+ * One thread per workspace (persisted via injected fetch/send/clear helpers).
44
+ * Not Kraken Deep Research. Apps mount this once in the authenticated shell.
18
45
  */
19
46
  export function SuiteAiPanel({
20
- presets,
21
- emptyState,
22
- fetchStatus,
23
- complete,
47
+ presets = [],
48
+ emptyState = 'Ask anything about your workspace, or look something up online.',
49
+ title = 'Ask AI',
50
+ fetchChat,
51
+ sendMessage,
52
+ clearChat,
24
53
  brandIcon: BrandIcon,
25
54
  spinner: Spinner,
55
+ hideLauncher = false,
26
56
  }: {
27
- presets: SuiteAiPreset[];
28
- /** Idle-state hint copy shown before the first request. */
29
- emptyState: ReactNode;
30
- fetchStatus: () => Promise<{ configured: boolean }>;
31
- complete: (params: { prompt: string }) => Promise<{ text: string }>;
32
- brandIcon: ComponentType<{ className?: string }>;
57
+ presets?: SuiteAiPreset[];
58
+ emptyState?: ReactNode;
59
+ title?: string;
60
+ fetchChat: () => Promise<{
61
+ messages: SuiteAiChatMessage[];
62
+ configured: boolean;
63
+ }>;
64
+ sendMessage: (params: {
65
+ prompt: string;
66
+ }) => Promise<{ messages: SuiteAiChatMessage[]; reply: string }>;
67
+ clearChat: () => Promise<void>;
68
+ brandIcon?: ComponentType<{ className?: string }>;
33
69
  spinner: ComponentType<{ className?: string }>;
70
+ /** When true, only Today / events open the panel (no floating button). */
71
+ hideLauncher?: boolean;
34
72
  }) {
35
73
  const [open, setOpen] = useState(false);
36
74
  const [prompt, setPrompt] = useState('');
75
+ const [messages, setMessages] = useState<SuiteAiChatMessage[]>([]);
37
76
  const [loading, setLoading] = useState(false);
38
- const [answer, setAnswer] = useState('');
77
+ const [hydrating, setHydrating] = useState(false);
39
78
  const [error, setError] = useState('');
40
- const [copied, setCopied] = useState(false);
79
+ const [copiedId, setCopiedId] = useState<number | null>(null);
80
+ const scrollerRef = useRef<HTMLDivElement>(null);
81
+ const pendingPromptRef = useRef<string | null>(null);
82
+
83
+ const scrollToBottom = useCallback(() => {
84
+ const el = scrollerRef.current;
85
+ if (!el) return;
86
+ el.scrollTop = el.scrollHeight;
87
+ }, []);
88
+
89
+ const hydrate = useCallback(async () => {
90
+ setHydrating(true);
91
+ setError('');
92
+ try {
93
+ const data = await fetchChat();
94
+ setMessages(data.messages ?? []);
95
+ if (!data.configured) {
96
+ setError('ShellStack AI is not configured for this workspace yet.');
97
+ }
98
+ } catch (err) {
99
+ setError(
100
+ err instanceof Error ? err.message : 'Could not load your Ask AI chat.',
101
+ );
102
+ } finally {
103
+ setHydrating(false);
104
+ }
105
+ }, [fetchChat]);
106
+
107
+ useEffect(() => {
108
+ function onOpen(event: Event) {
109
+ const detail = (event as CustomEvent<SuiteAskAiOpenDetail>).detail;
110
+ setOpen(true);
111
+ if (detail?.prompt?.trim()) {
112
+ pendingPromptRef.current = detail.prompt.trim();
113
+ setPrompt(detail.prompt.trim());
114
+ }
115
+ }
116
+ window.addEventListener(SUITE_OPEN_ASK_AI_EVENT, onOpen);
117
+ return () => window.removeEventListener(SUITE_OPEN_ASK_AI_EVENT, onOpen);
118
+ }, []);
119
+
120
+ useEffect(() => {
121
+ if (!open) return;
122
+ void hydrate().then(() => {
123
+ const pending = pendingPromptRef.current;
124
+ pendingPromptRef.current = null;
125
+ if (pending) {
126
+ // Leave prompt filled; user can send, or auto-send on next tick.
127
+ setPrompt(pending);
128
+ }
129
+ });
130
+ }, [open, hydrate]);
131
+
132
+ useEffect(() => {
133
+ if (open) scrollToBottom();
134
+ }, [messages, loading, open, scrollToBottom]);
41
135
 
42
136
  async function ask(text: string) {
43
137
  const trimmed = text.trim();
44
138
  if (!trimmed || loading) return;
45
139
  setLoading(true);
46
140
  setError('');
47
- setAnswer('');
48
- setCopied(false);
141
+ setPrompt('');
142
+ // Optimistic user bubble — replaced by server messages on success.
143
+ setMessages((prev) => [...prev, { role: 'user', content: trimmed }]);
49
144
  try {
50
- const status = await fetchStatus();
51
- if (!status.configured) {
52
- setError('ShellStack AI is not configured for this workspace yet.');
53
- return;
54
- }
55
- const result = await complete({ prompt: trimmed });
56
- setAnswer(result.text);
145
+ const result = await sendMessage({ prompt: trimmed });
146
+ setMessages(result.messages ?? []);
57
147
  } catch (err) {
148
+ setMessages((prev) =>
149
+ prev[prev.length - 1]?.role === 'user' &&
150
+ prev[prev.length - 1]?.content === trimmed
151
+ ? prev.slice(0, -1)
152
+ : prev,
153
+ );
154
+ setPrompt(trimmed);
58
155
  setError(
59
156
  err instanceof Error
60
157
  ? err.message
@@ -65,107 +162,174 @@ export function SuiteAiPanel({
65
162
  }
66
163
  }
67
164
 
68
- async function copyAnswer() {
69
- if (!answer) return;
165
+ async function handleClear() {
166
+ if (loading) return;
167
+ try {
168
+ await clearChat();
169
+ setMessages([]);
170
+ setError('');
171
+ } catch (err) {
172
+ setError(
173
+ err instanceof Error ? err.message : 'Could not clear this chat.',
174
+ );
175
+ }
176
+ }
177
+
178
+ async function copyMessage(index: number, content: string) {
70
179
  try {
71
- await navigator.clipboard.writeText(answer);
72
- setCopied(true);
73
- window.setTimeout(() => setCopied(false), 1500);
180
+ await navigator.clipboard.writeText(content);
181
+ setCopiedId(index);
182
+ window.setTimeout(() => setCopiedId(null), 1500);
74
183
  } catch {
75
- // Clipboard unavailable; ignore.
184
+ // Clipboard unavailable.
76
185
  }
77
186
  }
78
187
 
188
+ const Icon = BrandIcon ?? Sparks;
189
+
79
190
  return (
80
191
  <>
81
- <button
82
- type="button"
83
- data-test="ai-panel-launcher"
84
- onClick={() => setOpen(true)}
85
- className="fixed bottom-5 right-5 z-30 flex items-center gap-2 rounded-full border border-ph-border bg-ph-surface px-4 py-2.5 text-sm font-medium text-ph-ink shadow-ph hover:bg-ph-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-brand"
86
- aria-label="Open ShellStack AI"
87
- >
88
- <BrandIcon className="h-4 w-4 text-ph-brand" />
89
- Ask AI
90
- </button>
91
-
92
- {open && (
192
+ {!hideLauncher ? (
193
+ <button
194
+ type="button"
195
+ data-test="ai-panel-launcher"
196
+ onClick={() => setOpen(true)}
197
+ className="fixed bottom-5 right-5 z-30 flex items-center gap-2 rounded-full border border-ph-border bg-ph-surface px-4 py-2.5 text-sm font-medium text-ph-ink shadow-ph hover:bg-ph-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-brand"
198
+ aria-label="Open Ask AI"
199
+ >
200
+ <Icon className="h-4 w-4 text-ph-brand" />
201
+ Ask AI
202
+ </button>
203
+ ) : null}
204
+
205
+ {open ? (
93
206
  <div className="fixed inset-0 z-40" role="dialog" aria-modal="true">
94
207
  <button
95
208
  type="button"
96
209
  className="absolute inset-0 bg-black/25"
97
210
  onClick={() => setOpen(false)}
98
- aria-label="Close AI panel"
211
+ aria-label="Close Ask AI"
99
212
  />
100
- <aside className="absolute inset-y-0 right-0 flex w-full max-w-md flex-col border-l border-ph-border bg-ph-surface shadow-2xl">
213
+ <aside
214
+ className="absolute inset-y-0 right-0 flex w-full max-w-md flex-col border-l border-ph-border bg-ph-surface shadow-2xl"
215
+ data-test="suite-ask-ai-panel"
216
+ >
101
217
  <div className="flex items-center justify-between border-b border-ph-border px-4 py-3">
102
- <div className="flex items-center gap-2">
103
- <BrandIcon className="h-4 w-4 text-ph-brand" />
104
- <span className="text-sm font-semibold text-ph-ink">
105
- ShellStack AI
106
- </span>
218
+ <div className="min-w-0">
219
+ <div className="flex items-center gap-2">
220
+ <Icon className="h-4 w-4 shrink-0 text-ph-brand" />
221
+ <span className="text-sm font-semibold text-ph-ink">
222
+ {title}
223
+ </span>
224
+ </div>
225
+ <p className="mt-0.5 text-[11px] text-ph-mutedtext">
226
+ Shared across your suite · same chat in every app
227
+ </p>
107
228
  </div>
108
- <button
109
- type="button"
110
- onClick={() => setOpen(false)}
111
- className="rounded p-1 hover:bg-ph-muted"
112
- aria-label="Close"
113
- >
114
- <X className="h-4 w-4" />
115
- </button>
116
- </div>
117
-
118
- <div className="flex flex-wrap gap-1.5 border-b border-ph-border px-4 py-2">
119
- {presets.map((preset) => (
229
+ <div className="flex items-center gap-1">
230
+ {messages.length > 0 ? (
231
+ <button
232
+ type="button"
233
+ onClick={() => void handleClear()}
234
+ disabled={loading}
235
+ className="rounded-md px-2 py-1 text-xs text-ph-mutedtext hover:bg-ph-muted hover:text-ph-ink disabled:opacity-50"
236
+ data-test="ask-ai-clear"
237
+ >
238
+ Clear
239
+ </button>
240
+ ) : null}
120
241
  <button
121
- key={preset.label}
122
242
  type="button"
123
- onClick={() => {
124
- setPrompt(preset.prompt);
125
- void ask(preset.prompt);
126
- }}
127
- disabled={loading}
128
- className="rounded-md border border-ph-border px-2 py-1 text-xs text-ph-ink hover:bg-ph-muted disabled:opacity-50"
243
+ onClick={() => setOpen(false)}
244
+ className="rounded p-1 hover:bg-ph-muted"
245
+ aria-label="Close"
129
246
  >
130
- {preset.label}
247
+ <X className="h-4 w-4" />
131
248
  </button>
132
- ))}
249
+ </div>
133
250
  </div>
134
251
 
135
- <div className="flex-1 space-y-3 overflow-y-auto p-4">
136
- {error && (
252
+ {presets.length > 0 && messages.length === 0 ? (
253
+ <div className="flex flex-wrap gap-1.5 border-b border-ph-border px-4 py-2">
254
+ {presets.map((preset) => (
255
+ <button
256
+ key={preset.label}
257
+ type="button"
258
+ onClick={() => void ask(preset.prompt)}
259
+ disabled={loading || hydrating}
260
+ className="rounded-full border border-ph-border px-2.5 py-1 text-xs text-ph-ink hover:bg-ph-muted disabled:opacity-50"
261
+ >
262
+ {preset.label}
263
+ </button>
264
+ ))}
265
+ </div>
266
+ ) : null}
267
+
268
+ <div
269
+ ref={scrollerRef}
270
+ className="flex-1 space-y-3 overflow-y-auto p-4"
271
+ >
272
+ {error ? (
137
273
  <p className="bg-ph-danger/10 rounded-lg px-3 py-2 text-sm text-ph-danger">
138
274
  {error}
139
275
  </p>
140
- )}
141
- {loading && (
276
+ ) : null}
277
+
278
+ {hydrating && messages.length === 0 ? (
142
279
  <div className="flex items-center gap-2 text-sm text-ph-mutedtext">
143
280
  <Spinner className="h-4 w-4 animate-spin" />
144
- Thinking
281
+ Loading chat
145
282
  </div>
146
- )}
147
- {answer && !loading && (
148
- <div className="rounded-lg bg-ph-muted px-3 py-2 text-sm text-ph-ink">
149
- <p className="whitespace-pre-wrap">{answer}</p>
150
- <button
151
- type="button"
152
- onClick={() => void copyAnswer()}
153
- className="mt-2 inline-flex items-center gap-1 text-xs text-ph-mutedtext hover:text-ph-ink"
283
+ ) : null}
284
+
285
+ {!hydrating && messages.length === 0 && !error ? (
286
+ <div className="mt-10 px-2 text-center">
287
+ <p className="font-display text-base font-semibold text-ph-ink">
288
+ How can I help?
289
+ </p>
290
+ <p className="mt-2 text-sm leading-relaxed text-ph-subtle">
291
+ {emptyState}
292
+ </p>
293
+ </div>
294
+ ) : null}
295
+
296
+ {messages.map((message, index) => {
297
+ const isUser = message.role === 'user';
298
+ return (
299
+ <div
300
+ key={`${message.role}-${index}-${message.content.slice(0, 24)}`}
301
+ className={
302
+ isUser
303
+ ? 'ml-8 rounded-2xl bg-ph-brand/10 px-3.5 py-2.5 text-sm text-ph-ink'
304
+ : 'mr-4 rounded-2xl bg-ph-muted px-3.5 py-2.5 text-sm text-ph-ink'
305
+ }
306
+ data-test={isUser ? 'ask-ai-user' : 'ask-ai-assistant'}
154
307
  >
155
- {copied ? (
156
- <Check className="h-3 w-3" />
157
- ) : (
158
- <Copy className="h-3 w-3" />
159
- )}
160
- {copied ? 'Copied' : 'Copy'}
161
- </button>
308
+ <p className="whitespace-pre-wrap">{message.content}</p>
309
+ {!isUser ? (
310
+ <button
311
+ type="button"
312
+ onClick={() => void copyMessage(index, message.content)}
313
+ className="mt-2 inline-flex items-center gap-1 text-xs text-ph-mutedtext hover:text-ph-ink"
314
+ >
315
+ {copiedId === index ? (
316
+ <Check className="h-3 w-3" />
317
+ ) : (
318
+ <Copy className="h-3 w-3" />
319
+ )}
320
+ {copiedId === index ? 'Copied' : 'Copy'}
321
+ </button>
322
+ ) : null}
323
+ </div>
324
+ );
325
+ })}
326
+
327
+ {loading ? (
328
+ <div className="flex items-center gap-2 text-sm text-ph-mutedtext">
329
+ <Spinner className="h-4 w-4 animate-spin" />
330
+ Thinking…
162
331
  </div>
163
- )}
164
- {!answer && !loading && !error && (
165
- <p className="mt-8 text-center text-xs text-ph-mutedtext">
166
- {emptyState}
167
- </p>
168
- )}
332
+ ) : null}
169
333
  </div>
170
334
 
171
335
  <div className="border-t border-ph-border p-3">
@@ -182,13 +346,15 @@ export function SuiteAiPanel({
182
346
  placeholder="Ask anything…"
183
347
  rows={2}
184
348
  disabled={loading}
185
- className="flex-1 resize-none rounded-lg border border-ph-border bg-ph-canvas p-2 text-sm text-ph-ink outline-none focus:ring-1 focus:ring-ph-brand"
349
+ data-test="ask-ai-input"
350
+ className="flex-1 resize-none rounded-xl border border-ph-border bg-ph-canvas p-2.5 text-sm text-ph-ink outline-none focus:ring-1 focus:ring-ph-brand"
186
351
  />
187
352
  <button
188
353
  type="button"
189
354
  onClick={() => void ask(prompt)}
190
355
  disabled={loading || !prompt.trim()}
191
- className="self-end rounded-lg bg-ph-brand px-3 py-2 text-sm font-medium text-white disabled:opacity-50"
356
+ data-test="ask-ai-send"
357
+ className="self-end rounded-xl bg-ph-brand px-3.5 py-2 text-sm font-medium text-white disabled:opacity-50"
192
358
  >
193
359
  Ask
194
360
  </button>
@@ -196,7 +362,7 @@ export function SuiteAiPanel({
196
362
  </div>
197
363
  </aside>
198
364
  </div>
199
- )}
365
+ ) : null}
200
366
  </>
201
367
  );
202
368
  }
@@ -13,6 +13,11 @@ export interface SuiteAppLayoutProps {
13
13
  sidebarWidth?: number;
14
14
  /** True when the sidebar is collapsed to the icon rail. */
15
15
  collapsed?: boolean;
16
+ /**
17
+ * When true, `#main-content` does not scroll — the page owns an internal
18
+ * scrollport (e.g. Deep Research: fixed header + composer, chat scrolls).
19
+ */
20
+ lockMainScroll?: boolean;
16
21
  /** Drag this to resize the sidebar. */
17
22
  onStartResize?: (e: PointerEvent<HTMLDivElement>) => void;
18
23
  /** Double-click to reset sidebar width. */
@@ -21,10 +26,9 @@ export interface SuiteAppLayoutProps {
21
26
  }
22
27
 
23
28
  /**
24
- * Responsive ShellStack app shell. Desktop: resizable fixed sidebar +
25
- * scrollable content with an optional drag handle. Mobile/tablet: mobile
26
- * header + content + bottom nav. This is the single layout primitive every
27
- * app should use.
29
+ * Responsive ShellStack app shell. One content tree (no duplicate children),
30
+ * one `#main-content` landmark. Desktop: fixed sidebar + scrollable main.
31
+ * Mobile: header + main + bottom nav.
28
32
  */
29
33
  export function SuiteAppLayout({
30
34
  sidebar,
@@ -33,36 +37,52 @@ export function SuiteAppLayout({
33
37
  bottomNav,
34
38
  sidebarWidth = 240,
35
39
  collapsed = false,
40
+ lockMainScroll = false,
36
41
  onStartResize,
37
42
  onResetWidth,
38
43
  className,
39
44
  }: SuiteAppLayoutProps) {
40
45
  return (
41
- <div data-test="app-shell" className={cn('min-h-screen', className)}>
42
- <div className="hidden lg:flex h-screen overflow-hidden">
43
- <aside
44
- className="relative flex shrink-0 flex-col border-r border-ph-border transition-[width] duration-200 ease-out"
45
- style={{ width: `${sidebarWidth}px` }}
46
- >
47
- {sidebar}
48
- {onStartResize ? (
49
- <div
50
- role="separator"
51
- aria-orientation="vertical"
52
- aria-label="Resize sidebar"
53
- onPointerDown={onStartResize}
54
- onDoubleClick={onResetWidth}
55
- className="absolute inset-y-0 right-0 z-10 w-1 cursor-col-resize transition-colors hover:bg-[color-mix(in_oklab,var(--ph-accent)_30%,transparent)]"
56
- />
57
- ) : null}
58
- </aside>
59
- <main className="min-w-0 flex-1 overflow-y-auto bg-ph-canvas">{children}</main>
60
- </div>
46
+ <div
47
+ data-test="app-shell"
48
+ className={cn('flex min-h-dvh lg:h-screen lg:overflow-hidden', className)}
49
+ >
50
+ <aside
51
+ className="relative hidden shrink-0 flex-col overflow-hidden border-r border-ph-border transition-[width] duration-200 ease-out lg:flex"
52
+ style={{ width: `${sidebarWidth}px` }}
53
+ data-collapsed={collapsed ? 'true' : 'false'}
54
+ >
55
+ {sidebar}
56
+ {onStartResize ? (
57
+ <div
58
+ role="separator"
59
+ aria-orientation="vertical"
60
+ aria-label="Resize sidebar"
61
+ onPointerDown={onStartResize}
62
+ onDoubleClick={onResetWidth}
63
+ className="absolute inset-y-0 right-0 z-10 w-1 cursor-col-resize transition-colors hover:bg-[color-mix(in_oklab,var(--ph-accent)_30%,transparent)]"
64
+ />
65
+ ) : null}
66
+ </aside>
61
67
 
62
- <div className="flex min-h-screen flex-col lg:hidden">
63
- {mobileHeader ? <div className="shrink-0">{mobileHeader}</div> : null}
64
- <main className="min-w-0 flex-1 overflow-y-auto bg-ph-canvas">{children}</main>
65
- {bottomNav ? <div className="shrink-0">{bottomNav}</div> : null}
68
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col">
69
+ {mobileHeader ? (
70
+ <div className="shrink-0 lg:hidden">{mobileHeader}</div>
71
+ ) : null}
72
+ <main
73
+ id="main-content"
74
+ tabIndex={-1}
75
+ data-lock-scroll={lockMainScroll ? 'true' : undefined}
76
+ className={cn(
77
+ 'flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden overscroll-contain bg-ph-canvas focus:outline-none',
78
+ lockMainScroll ? 'overflow-hidden' : 'overflow-y-auto',
79
+ )}
80
+ >
81
+ {children}
82
+ </main>
83
+ {bottomNav ? (
84
+ <div className="shrink-0 lg:hidden">{bottomNav}</div>
85
+ ) : null}
66
86
  </div>
67
87
  </div>
68
88
  );