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

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 (76) 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 +1366 -671
  11. package/dist/suite.mjs +1304 -610
  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.test.ts +20 -0
  20. package/src/suite/components/ai-panel.tsx +494 -103
  21. package/src/suite/components/suite-app-layout.tsx +48 -28
  22. package/src/suite/components/suite-layout.tsx +84 -40
  23. package/src/suite/components/suite-mobile-drawer.tsx +35 -22
  24. package/src/suite/components/suite-notification-bell.tsx +9 -3
  25. package/src/suite/components/suite-settings-layout.tsx +82 -0
  26. package/src/suite/components/suite-settings-mobile-nav.tsx +18 -17
  27. package/src/suite/components/suite-skeleton.tsx +3 -3
  28. package/src/suite/components/today-calibrating.tsx +11 -35
  29. package/src/suite/components/today-page-frame.tsx +19 -11
  30. package/src/suite/components/today-ui.tsx +276 -19
  31. package/src/suite/index.ts +41 -25
  32. package/src/suite/lib/apps.ts +32 -2
  33. package/src/suite/lib/use-sidebar-width.ts +114 -0
  34. package/src/components/sections/AccordionShowcase.tsx +0 -45
  35. package/src/components/sections/AlertShowcase.tsx +0 -40
  36. package/src/components/sections/AvatarShowcase.tsx +0 -80
  37. package/src/components/sections/BadgeShowcase.tsx +0 -65
  38. package/src/components/sections/ButtonShowcase.tsx +0 -308
  39. package/src/components/sections/CalendarShowcase.tsx +0 -35
  40. package/src/components/sections/CardShowcase.tsx +0 -159
  41. package/src/components/sections/CodeMockupShowcase.tsx +0 -56
  42. package/src/components/sections/ColorPalette.tsx +0 -117
  43. package/src/components/sections/CommandPaletteShowcase.tsx +0 -48
  44. package/src/components/sections/CountdownShowcase.tsx +0 -43
  45. package/src/components/sections/DiffShowcase.tsx +0 -70
  46. package/src/components/sections/DrawerShowcase.tsx +0 -97
  47. package/src/components/sections/DropdownShowcase.tsx +0 -98
  48. package/src/components/sections/EmptyStateShowcase.tsx +0 -50
  49. package/src/components/sections/FilterChipsShowcase.tsx +0 -98
  50. package/src/components/sections/FormShowcase.tsx +0 -127
  51. package/src/components/sections/HoverCardShowcase.tsx +0 -50
  52. package/src/components/sections/IconShowcase.tsx +0 -121
  53. package/src/components/sections/IndicatorShowcase.tsx +0 -52
  54. package/src/components/sections/KbdShowcase.tsx +0 -57
  55. package/src/components/sections/ModalShowcase.tsx +0 -211
  56. package/src/components/sections/NavigationShowcase.tsx +0 -199
  57. package/src/components/sections/NewComponentsShowcase.tsx +0 -1052
  58. package/src/components/sections/ProductLayoutsShowcase.tsx +0 -78
  59. package/src/components/sections/ProgressShowcase.tsx +0 -82
  60. package/src/components/sections/QuillChartShowcase.tsx +0 -92
  61. package/src/components/sections/QuillDashboardShowcase.tsx +0 -149
  62. package/src/components/sections/SegmentedControlShowcase.tsx +0 -49
  63. package/src/components/sections/SetupThemeShowcase.tsx +0 -262
  64. package/src/components/sections/SidebarShowcase.tsx +0 -152
  65. package/src/components/sections/StackShowcase.tsx +0 -33
  66. package/src/components/sections/StepperShowcase.tsx +0 -37
  67. package/src/components/sections/SuiteShowcase.tsx +0 -669
  68. package/src/components/sections/SwapShowcase.tsx +0 -99
  69. package/src/components/sections/TabShowcase.tsx +0 -84
  70. package/src/components/sections/TableShowcase.tsx +0 -142
  71. package/src/components/sections/TimelineShowcase.tsx +0 -83
  72. package/src/components/sections/ToastShowcase.tsx +0 -108
  73. package/src/components/sections/TooltipShowcase.tsx +0 -38
  74. package/src/components/sections/UtilityShowcase.tsx +0 -81
  75. package/src/styles/excel-themes.css +0 -110
  76. package/src/styles/lemon-primitives.css +0 -550
@@ -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
  );
@@ -1,19 +1,22 @@
1
1
  'use client';
2
2
 
3
- import type { ComponentType, ElementType, ReactNode, SVGProps } from 'react';
3
+ import {
4
+ useEffect,
5
+ useRef,
6
+ useState,
7
+ type ElementType,
8
+ type ReactNode,
9
+ } from 'react';
4
10
  import { cn } from '../lib/cn';
5
11
  import { TodayTimeIcon } from './today-ui';
6
12
 
7
- type LucideIcon = ComponentType<SVGProps<SVGSVGElement>>;
8
-
9
- const WIDTHS = {
10
- full: 'max-w-none',
11
- wide: 'max-w-7xl',
12
- content: 'max-w-6xl',
13
- narrow: 'max-w-4xl',
14
- } as const;
15
-
16
- export type SuitePageWidth = keyof typeof WIDTHS;
13
+ /** Width tokens — applied as data-width; CSS in suite-skin.css owns max-width. */
14
+ export type SuitePageWidth =
15
+ | 'full'
16
+ | 'wide'
17
+ | 'content'
18
+ | 'narrow'
19
+ | 'reading';
17
20
 
18
21
  export interface SuiteBreadcrumbsProps {
19
22
  items: { label: ReactNode; href?: string }[];
@@ -62,7 +65,7 @@ export function SuiteBreadcrumbs({ items, className }: SuiteBreadcrumbsProps) {
62
65
 
63
66
  export function SuitePage({
64
67
  children,
65
- width = 'wide',
68
+ width = 'full',
66
69
  inset = true,
67
70
  className,
68
71
  as: Component = 'div',
@@ -85,13 +88,12 @@ export function SuitePage({
85
88
  <Component
86
89
  id={id}
87
90
  data-test={dataTest}
91
+ data-width={width}
88
92
  role={role}
89
93
  aria-live={ariaLive}
90
94
  className={cn(
91
- 'mx-auto flex w-full min-w-0 flex-col',
92
- WIDTHS[width],
93
- // Tight inset keeps page titles aligned without excessive whitespace.
94
- inset && 'px-4 pb-5 pt-2 sm:px-6 sm:pb-6 sm:pt-3 lg:px-8',
95
+ 'suite-page-stage flex w-full min-w-0 flex-col',
96
+ inset && 'suite-page-inset',
95
97
  className,
96
98
  )}
97
99
  >
@@ -117,6 +119,8 @@ export function SuitePageHeader({
117
119
  badge,
118
120
  /** Breadcrumb line rendered above the title. */
119
121
  breadcrumb,
122
+ /** Stick to the top of the scrollport (desktop page titles). */
123
+ sticky = false,
120
124
  }: {
121
125
  title: ReactNode;
122
126
  description?: ReactNode;
@@ -130,11 +134,40 @@ export function SuitePageHeader({
130
134
  todayIconClassName?: string;
131
135
  badge?: ReactNode;
132
136
  breadcrumb?: ReactNode;
137
+ sticky?: boolean;
133
138
  }) {
139
+ const headerRef = useRef<HTMLElement>(null);
140
+ const [stuck, setStuck] = useState(false);
141
+
142
+ useEffect(() => {
143
+ if (!sticky) return;
144
+ const el = headerRef.current;
145
+ if (!el) return;
146
+
147
+ // Sentinel just above the header — when it leaves the scrollport, we're stuck.
148
+ const sentinel = document.createElement('div');
149
+ sentinel.setAttribute('aria-hidden', 'true');
150
+ sentinel.style.cssText =
151
+ 'position:relative;height:1px;width:100%;margin:0;padding:0;pointer-events:none';
152
+ el.parentElement?.insertBefore(sentinel, el);
153
+
154
+ const observer = new IntersectionObserver(
155
+ ([entry]) => {
156
+ setStuck(!entry.isIntersecting);
157
+ },
158
+ { threshold: [1], root: null },
159
+ );
160
+ observer.observe(sentinel);
161
+ return () => {
162
+ observer.disconnect();
163
+ sentinel.remove();
164
+ };
165
+ }, [sticky]);
166
+
134
167
  const resolvedIcon = todayIcon ? (
135
168
  <TodayTimeIcon
136
169
  className={cn(
137
- 'h-6 w-6 sm:h-7 sm:w-7 text-amber-500',
170
+ 'h-7 w-7 text-amber-500 sm:h-8 sm:w-8',
138
171
  todayIconClassName,
139
172
  )}
140
173
  />
@@ -144,36 +177,42 @@ export function SuitePageHeader({
144
177
 
145
178
  return (
146
179
  <header
180
+ ref={headerRef}
147
181
  data-test={dataTest}
182
+ data-stuck={sticky ? (stuck ? 'true' : 'false') : undefined}
148
183
  className={cn(
149
184
  'flex items-start justify-between gap-4 pb-2 sm:pb-3',
185
+ // No extra pt here — page inset sets the shared title baseline.
186
+ // Stuck state adds pad via .suite-page-header-sticky[data-stuck].
187
+ sticky && 'suite-page-header-sticky',
150
188
  className,
151
189
  )}
152
190
  >
153
- <div className="flex min-w-0 items-start gap-3">
154
- {resolvedIcon ? (
155
- <div
156
- className="hidden shrink-0 pt-1 sm:block"
157
- aria-hidden="true"
158
- >
159
- {resolvedIcon}
191
+ <div className="min-w-0 flex-1">
192
+ {eyebrow ? (
193
+ <div className="mb-0.5 flex min-w-0 items-center gap-1.5 text-xs text-ph-subtle">
194
+ {eyebrow}
160
195
  </div>
161
196
  ) : null}
162
- <div className="flex min-w-0 flex-col gap-1">
163
- {eyebrow ? (
164
- <div className="mb-0.5 flex min-w-0 items-center gap-1.5 text-xs text-ph-subtle">
165
- {eyebrow}
166
- </div>
167
- ) : null}
168
- {breadcrumb ? (
169
- <div className="mb-0.5 flex min-w-0 items-center gap-1.5 text-xs text-ph-subtle">
170
- {breadcrumb}
197
+ {breadcrumb ? (
198
+ <div className="mb-0.5 flex min-w-0 items-center gap-1.5 text-xs text-ph-subtle">
199
+ {breadcrumb}
200
+ </div>
201
+ ) : null}
202
+ {/* Icon aligns with the title line only — not the description. */}
203
+ <div className="flex min-w-0 items-center gap-3">
204
+ {resolvedIcon ? (
205
+ <div
206
+ className="hidden shrink-0 sm:flex sm:items-center [&_svg]:h-7 [&_svg]:w-7 sm:[&_svg]:h-8 sm:[&_svg]:w-8"
207
+ aria-hidden="true"
208
+ >
209
+ {resolvedIcon}
171
210
  </div>
172
211
  ) : null}
173
212
  <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
174
213
  <h1
175
214
  className={cn(
176
- 'font-display text-[1.625rem] font-bold leading-tight tracking-tight text-ph-ink sm:text-[2.25rem]',
215
+ 'font-display text-[1.625rem] font-bold leading-none tracking-tight text-ph-ink sm:text-[2.25rem]',
177
216
  titleClassName,
178
217
  )}
179
218
  >
@@ -185,15 +224,20 @@ export function SuitePageHeader({
185
224
  </span>
186
225
  ) : null}
187
226
  </div>
188
- {description ? (
189
- <div className="mt-1 max-w-2xl text-pretty text-sm leading-5 text-ph-subtle">
190
- {description}
191
- </div>
192
- ) : null}
193
227
  </div>
228
+ {description ? (
229
+ <div
230
+ className={cn(
231
+ 'mt-1.5 max-w-2xl text-pretty text-sm leading-5 text-ph-subtle',
232
+ resolvedIcon && 'sm:pl-11',
233
+ )}
234
+ >
235
+ {description}
236
+ </div>
237
+ ) : null}
194
238
  </div>
195
239
  {actions ? (
196
- <div className="flex shrink-0 items-center gap-2 pt-1">
240
+ <div className="flex shrink-0 items-center gap-2 self-center">
197
241
  {actions}
198
242
  </div>
199
243
  ) : null}
@@ -1,9 +1,9 @@
1
- 'use client';
1
+ "use client";
2
2
 
3
- import { useEffect, useRef, useState, type ReactNode } from 'react';
4
- import { Xmark as X } from 'iconoir-react';
3
+ import { useEffect, useRef, useState, type ReactNode } from "react";
4
+ import { Xmark as X } from "iconoir-react";
5
5
 
6
- import { cn } from '../lib/cn';
6
+ import { cn } from "../lib/cn";
7
7
 
8
8
  /**
9
9
  * Suite mobile navigation drawer. Backdrop fade + panel slide, Escape to
@@ -14,11 +14,12 @@ export function SuiteMobileDrawer({
14
14
  open,
15
15
  onClose,
16
16
  children,
17
- side = 'left',
17
+ side = "left",
18
18
  showCloseButton = false,
19
- closeLabel = 'Close navigation',
20
- backdropLabel = 'Close navigation',
21
- dialogLabel = 'Navigation',
19
+ title,
20
+ closeLabel = "Close navigation",
21
+ backdropLabel = "Close navigation",
22
+ dialogLabel = "Navigation",
22
23
  durationMs = 200,
23
24
  dataTest,
24
25
  panelDataTest,
@@ -28,9 +29,11 @@ export function SuiteMobileDrawer({
28
29
  onClose: () => void;
29
30
  children: ReactNode;
30
31
  /** Edge the panel slides in from. */
31
- side?: 'left' | 'right';
32
+ side?: "left" | "right";
32
33
  /** Standard close row (h-14, trailing X button) above the content. */
33
34
  showCloseButton?: boolean;
35
+ /** Optional label in the standard close row. */
36
+ title?: ReactNode;
34
37
  closeLabel?: string;
35
38
  backdropLabel?: string;
36
39
  /** Accessible name for the dialog panel. */
@@ -76,12 +79,12 @@ export function SuiteMobileDrawer({
76
79
  if (!open) return;
77
80
 
78
81
  const onKeyDown = (event: KeyboardEvent) => {
79
- if (event.key === 'Escape') onCloseRef.current();
82
+ if (event.key === "Escape") onCloseRef.current();
80
83
  };
81
- window.addEventListener('keydown', onKeyDown);
84
+ window.addEventListener("keydown", onKeyDown);
82
85
 
83
86
  const previousOverflow = document.body.style.overflow;
84
- document.body.style.overflow = 'hidden';
87
+ document.body.style.overflow = "hidden";
85
88
 
86
89
  const previouslyFocused =
87
90
  document.activeElement instanceof HTMLElement
@@ -90,7 +93,7 @@ export function SuiteMobileDrawer({
90
93
  const frame = requestAnimationFrame(() => panelRef.current?.focus());
91
94
 
92
95
  return () => {
93
- window.removeEventListener('keydown', onKeyDown);
96
+ window.removeEventListener("keydown", onKeyDown);
94
97
  document.body.style.overflow = previousOverflow;
95
98
  cancelAnimationFrame(frame);
96
99
  if (previouslyFocused?.isConnected) previouslyFocused.focus();
@@ -105,8 +108,8 @@ export function SuiteMobileDrawer({
105
108
  type="button"
106
109
  tabIndex={-1}
107
110
  className={cn(
108
- 'absolute inset-0 bg-black/25 backdrop-blur-sm motion-safe:transition-opacity motion-safe:ease-spring-subtle',
109
- shown ? 'opacity-100' : 'opacity-0',
111
+ "absolute inset-0 bg-black/25 backdrop-blur-sm motion-safe:transition-opacity motion-safe:ease-spring-subtle",
112
+ shown ? "opacity-100" : "opacity-0",
110
113
  )}
111
114
  style={{ transitionDuration: `${durationMs}ms` }}
112
115
  onClick={onClose}
@@ -120,19 +123,29 @@ export function SuiteMobileDrawer({
120
123
  tabIndex={-1}
121
124
  data-test={panelDataTest}
122
125
  className={cn(
123
- 'suite-scroll-lock relative flex h-full w-[86%] max-w-64 flex-col overflow-hidden bg-ph-surface shadow-xl outline-none motion-safe:transition-transform motion-safe:ease-spring-fast',
124
- side === 'right' && 'ml-auto',
126
+ "suite-scroll-lock relative flex h-full w-[86%] max-w-72 flex-col overflow-hidden bg-ph-surface shadow-xl outline-none motion-safe:transition-transform motion-safe:ease-spring-fast",
127
+ side === "right" && "ml-auto",
125
128
  shown
126
- ? 'translate-x-0'
127
- : side === 'right'
128
- ? 'translate-x-full'
129
- : '-translate-x-full',
129
+ ? "translate-x-0"
130
+ : side === "right"
131
+ ? "translate-x-full"
132
+ : "-translate-x-full",
130
133
  panelClassName,
131
134
  )}
132
135
  style={{ transitionDuration: `${durationMs}ms` }}
133
136
  >
134
137
  {showCloseButton ? (
135
- <div className="flex h-14 shrink-0 items-center justify-end border-b border-ph-border px-3">
138
+ <div
139
+ className={cn(
140
+ "flex h-14 shrink-0 items-center border-b border-ph-border px-3",
141
+ title ? "justify-between" : "justify-end",
142
+ )}
143
+ >
144
+ {title ? (
145
+ <span className="truncate text-sm font-semibold text-ph-ink">
146
+ {title}
147
+ </span>
148
+ ) : null}
136
149
  <button
137
150
  type="button"
138
151
  onClick={onClose}
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
5
5
  import { Bell } from 'iconoir-react';
6
6
 
7
7
  import type { SuiteDropdownMenuComponent } from '../lib/injected';
8
+ import { resolveSuiteNotificationHref } from '../lib/apps';
8
9
 
9
10
  const POLL_MS = 60_000;
10
11
 
@@ -134,9 +135,14 @@ export function SuiteNotificationBell({
134
135
  // ignore
135
136
  }
136
137
  }
137
- if (!n.href) return;
138
- if (/^https?:\/\//i.test(n.href)) window.location.assign(n.href);
139
- else router.push(n.href);
138
+ const target = resolveSuiteNotificationHref(n.href, n.source_app);
139
+ if (!target) return;
140
+ // Cross-app notifications must leave this product origin.
141
+ if (/^https?:\/\//i.test(target)) {
142
+ window.location.assign(target);
143
+ return;
144
+ }
145
+ router.push(target);
140
146
  },
141
147
  [router, markRead],
142
148
  );
@@ -0,0 +1,82 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+
5
+ import { cn } from "../lib/cn";
6
+ import { SuitePage, SuitePageHeader } from "./suite-layout";
7
+
8
+ export interface SuiteSettingsLayoutProps {
9
+ children: ReactNode;
10
+ sidebar: ReactNode;
11
+ mobileNav?: ReactNode;
12
+ loading?: ReactNode;
13
+ title?: ReactNode;
14
+ description?: ReactNode;
15
+ header?: ReactNode;
16
+ dataTest?: string;
17
+ headerDataTest?: string;
18
+ className?: string;
19
+ contentClassName?: string;
20
+ }
21
+
22
+ /**
23
+ * Canonical settings geometry for every suite app.
24
+ * Apps own nav data and content; the suite owns spacing, scrolling and breakpoints.
25
+ */
26
+ export function SuiteSettingsLayout({
27
+ children,
28
+ sidebar,
29
+ mobileNav,
30
+ loading,
31
+ title = "Settings",
32
+ description,
33
+ header,
34
+ dataTest = "settings-page",
35
+ headerDataTest = "settings-page-header",
36
+ className,
37
+ contentClassName,
38
+ }: SuiteSettingsLayoutProps) {
39
+ return (
40
+ <SuitePage
41
+ dataTest={dataTest}
42
+ className={cn("flex h-full flex-col overflow-hidden", className)}
43
+ >
44
+ {header ?? (
45
+ <SuitePageHeader
46
+ sticky
47
+ dataTest={headerDataTest}
48
+ title={title}
49
+ description={description}
50
+ />
51
+ )}
52
+
53
+ {loading ?? (
54
+ <div className="suite-page-body mt-6 flex min-h-0 flex-1 flex-col">
55
+ {mobileNav ? (
56
+ <div className="sticky top-0 z-20 mb-2 shrink-0 bg-ph-canvas/95 backdrop-blur lg:hidden">
57
+ {mobileNav}
58
+ </div>
59
+ ) : null}
60
+
61
+ <div className="flex min-h-0 flex-1 flex-col gap-6 lg:flex-row lg:gap-8">
62
+ <aside
63
+ aria-label="Settings"
64
+ className="hidden w-56 shrink-0 bg-transparent lg:block lg:self-stretch lg:overflow-y-auto lg:overscroll-contain"
65
+ >
66
+ {sidebar}
67
+ </aside>
68
+
69
+ <div
70
+ className={cn(
71
+ "min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-contain pb-6",
72
+ contentClassName,
73
+ )}
74
+ >
75
+ {children}
76
+ </div>
77
+ </div>
78
+ </div>
79
+ )}
80
+ </SuitePage>
81
+ );
82
+ }
@@ -1,12 +1,12 @@
1
- 'use client';
1
+ "use client";
2
2
 
3
- import { isValidElement, type ComponentType, type ReactNode } from 'react';
4
- import { cn } from '../lib/cn';
3
+ import { isValidElement, type ElementType, type ReactNode } from "react";
4
+ import { cn } from "../lib/cn";
5
5
 
6
6
  export interface SuiteSettingsNavItem {
7
7
  label: string;
8
8
  href: string;
9
- icon: ComponentType<{ className?: string; 'aria-hidden'?: boolean }> | ReactNode;
9
+ icon: ElementType | ReactNode;
10
10
  testId?: string;
11
11
  }
12
12
 
@@ -15,6 +15,8 @@ interface SuiteSettingsMobileNavProps {
15
15
  activeHref: string;
16
16
  onSelect: (href: string) => void;
17
17
  dataTest?: string;
18
+ ariaLabel?: string;
19
+ className?: string;
18
20
  }
19
21
 
20
22
  /**
@@ -28,19 +30,18 @@ export function SuiteSettingsMobileNav({
28
30
  activeHref,
29
31
  onSelect,
30
32
  dataTest,
33
+ ariaLabel = "Settings sections",
34
+ className,
31
35
  }: SuiteSettingsMobileNavProps) {
32
36
  return (
33
37
  <nav
34
38
  data-test={dataTest}
35
- aria-label="Settings sections"
36
- className="-mx-4 px-4 lg:hidden"
39
+ aria-label={ariaLabel}
40
+ className={cn("suite-settings-mobile-nav lg:hidden", className)}
37
41
  >
38
42
  <div className="flex snap-x gap-2 overflow-x-auto pb-3 pt-1 scrollbar-hide [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
39
43
  {items.map((item) => {
40
- const Icon = item.icon as ComponentType<{
41
- className?: string;
42
- 'aria-hidden'?: boolean;
43
- }>;
44
+ const Icon = item.icon as ElementType;
44
45
  const active =
45
46
  activeHref === item.href || activeHref.startsWith(`${item.href}/`);
46
47
  const iconNode = isValidElement(item.icon) ? (
@@ -48,8 +49,8 @@ export function SuiteSettingsMobileNav({
48
49
  ) : (
49
50
  <Icon
50
51
  className={cn(
51
- 'h-4 w-4 shrink-0 transition',
52
- active ? 'text-ph-surface' : 'text-ph-mutedtext',
52
+ "h-4 w-4 shrink-0 transition",
53
+ active ? "text-ph-surface" : "text-ph-mutedtext",
53
54
  )}
54
55
  aria-hidden
55
56
  />
@@ -60,13 +61,13 @@ export function SuiteSettingsMobileNav({
60
61
  type="button"
61
62
  data-test={item.testId}
62
63
  onClick={() => onSelect(item.href)}
63
- aria-current={active ? 'page' : undefined}
64
+ aria-current={active ? "page" : undefined}
64
65
  className={cn(
65
- 'group relative flex shrink-0 snap-start items-center gap-2 rounded-full px-3.5 py-2.5 text-sm font-medium transition',
66
- 'min-h-[44px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-brand',
66
+ "group relative flex shrink-0 snap-start items-center gap-2 rounded-full px-3.5 py-2.5 text-sm font-medium transition-colors",
67
+ "min-h-[44px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-brand",
67
68
  active
68
- ? 'bg-ph-ink text-ph-surface shadow-sm'
69
- : 'bg-ph-surface text-ph-subtle ring-1 ring-ph-border hover:bg-ph-muted hover:text-ph-ink',
69
+ ? "bg-ph-ink text-ph-surface shadow-sm"
70
+ : "bg-ph-surface text-ph-subtle ring-1 ring-ph-border hover:bg-ph-muted hover:text-ph-ink",
70
71
  )}
71
72
  >
72
73
  {iconNode}
@@ -65,7 +65,7 @@ export function SuiteSkeletonCard({
65
65
  return (
66
66
  <div
67
67
  className={cn(
68
- 'ph-skeleton-card animate-[skeleton-stagger_0.5s_ease-out_both] rounded-2xl border border-ph-border/70 bg-ph-surface p-6 shadow-sm',
68
+ 'ph-skeleton-card animate-[skeleton-stagger_0.5s_ease-out_both] rounded-2xl border border-ph-border bg-ph-surface p-6 shadow-sm',
69
69
  className,
70
70
  )}
71
71
  aria-hidden
@@ -115,7 +115,7 @@ export function SuiteSkeletonList({
115
115
  className="animate-[skeleton-stagger_0.6s_ease-out_both]"
116
116
  style={{ animationDelay: `${index * 100}ms` }}
117
117
  >
118
- <div className="flex items-center gap-3 rounded-xl border border-ph-border/60 bg-ph-surface p-4">
118
+ <div className="flex items-center gap-3 rounded-xl border border-ph-border bg-ph-surface p-4">
119
119
  <div className="ph-skeleton h-10 w-10 rounded-lg" />
120
120
  <div className="min-w-0 flex-1 space-y-2">
121
121
  <div className="ph-skeleton h-3.5 w-3/4" />
@@ -152,7 +152,7 @@ export function SuiteEmptyState({
152
152
  return (
153
153
  <div
154
154
  className={cn(
155
- 'relative overflow-hidden rounded-2xl border border-ph-border/80 bg-ph-surface text-center',
155
+ 'relative overflow-hidden rounded-2xl border border-ph-border bg-ph-surface text-center',
156
156
  size === 'sm' ? 'px-5 py-8' : 'px-6 py-12 sm:px-10 sm:py-16',
157
157
  className,
158
158
  )}