@xenide-io/the-old-ui-theme 0.7.0 → 0.9.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 (109) hide show
  1. package/README.md +9 -10
  2. package/dist/{Chip-Cq3AiAji.d.mts → Chip-BzuNgJIa.d.mts} +81 -23
  3. package/dist/{Chip-Cq3AiAji.d.ts → Chip-BzuNgJIa.d.ts} +81 -23
  4. package/dist/{ai-message-blocknote-WABTHFY7.mjs → ai-message-blocknote-D4Z6DUCJ.mjs} +9 -3
  5. package/dist/chunk-2EO5VCXP.mjs +3912 -0
  6. package/dist/chunk-APKRZQRO.mjs +251 -0
  7. package/dist/chunk-BS7BZI5W.mjs +19 -0
  8. package/dist/chunk-IULORI3A.mjs +8 -0
  9. package/dist/index.d.mts +3 -3
  10. package/dist/index.d.ts +3 -3
  11. package/dist/index.js +997 -576
  12. package/dist/index.mjs +49 -45
  13. package/dist/suite.d.mts +157 -55
  14. package/dist/suite.d.ts +157 -55
  15. package/dist/suite.js +4249 -801
  16. package/dist/suite.mjs +2578 -594
  17. package/dist/theme-boot-P-oqKVZF.d.mts +74 -0
  18. package/dist/theme-boot-P-oqKVZF.d.ts +74 -0
  19. package/dist/theme-boot.d.mts +2 -0
  20. package/dist/theme-boot.d.ts +2 -0
  21. package/dist/theme-boot.js +44 -0
  22. package/dist/theme-boot.mjs +8 -0
  23. package/dist/ui.d.mts +5 -29
  24. package/dist/ui.d.ts +5 -29
  25. package/dist/ui.js +647 -352
  26. package/dist/ui.mjs +10 -12
  27. package/docs/README.md +34 -0
  28. package/docs/ai-quick-reference.md +56 -0
  29. package/docs/component-library-improvement-plan.md +751 -0
  30. package/docs/component-reference.md +81 -0
  31. package/docs/components.md +48 -0
  32. package/docs/demos.md +19 -0
  33. package/docs/installation.md +117 -0
  34. package/docs/suite-audit.md +34 -0
  35. package/docs/theme-token-reference.md +67 -0
  36. package/docs/themes.md +164 -0
  37. package/package.json +22 -21
  38. package/public/fonts/OpenRunde-Bold.woff2 +0 -0
  39. package/public/fonts/OpenRunde-Medium.woff2 +0 -0
  40. package/public/fonts/OpenRunde-Regular.woff2 +0 -0
  41. package/public/fonts/OpenRunde-Semibold.woff2 +0 -0
  42. package/scripts/package-smoke.mjs +43 -0
  43. package/src/app/globals.css +2 -1
  44. package/src/components/ui/Accordion.tsx +20 -6
  45. package/src/components/ui/Avatar.tsx +5 -3
  46. package/src/components/ui/Calendar.tsx +187 -63
  47. package/src/components/ui/Card.test.tsx +38 -0
  48. package/src/components/ui/Card.tsx +77 -14
  49. package/src/components/ui/CollapsibleSection.tsx +12 -4
  50. package/src/components/ui/CommandPalette.test.tsx +91 -4
  51. package/src/components/ui/CommandPalette.tsx +138 -128
  52. package/src/components/ui/EmptyState.tsx +2 -2
  53. package/src/components/ui/FormField.tsx +1 -1
  54. package/src/components/ui/Input.tsx +42 -18
  55. package/src/components/ui/Kbd.tsx +18 -3
  56. package/src/components/ui/Modal.tsx +3 -1
  57. package/src/components/ui/Progress.tsx +14 -5
  58. package/src/components/ui/SegmentedControl.tsx +2 -2
  59. package/src/components/ui/SettingsLayout.tsx +1 -1
  60. package/src/components/ui/Stat.tsx +3 -2
  61. package/src/components/ui/Table.tsx +8 -3
  62. package/src/components/ui/Tooltip.test.tsx +70 -1
  63. package/src/components/ui/Tooltip.tsx +53 -29
  64. package/src/components/ui/Typography.tsx +103 -49
  65. package/src/components/ui/avatar-stat.test.tsx +23 -0
  66. package/src/components/ui/calendar.test.tsx +88 -0
  67. package/src/components/ui/disclosure.test.tsx +39 -0
  68. package/src/components/ui/index.ts +44 -6
  69. package/src/components/ui/progress.test.tsx +15 -0
  70. package/src/components/ui/typography.test.tsx +26 -1
  71. package/src/styles/suite-skin.css +8 -2
  72. package/src/styles/themes.css +184 -68
  73. package/src/suite/components/ai-message-blocknote.tsx +13 -2
  74. package/src/suite/components/ai-panel.tsx +41 -10
  75. package/src/suite/components/app-switcher.test.tsx +21 -6
  76. package/src/suite/components/app-switcher.tsx +7 -11
  77. package/src/suite/components/suite-app-layout.tsx +12 -3
  78. package/src/suite/components/suite-bottom-nav.tsx +0 -1
  79. package/src/suite/components/suite-clients-projects-directory.test.tsx +71 -0
  80. package/src/suite/components/suite-clients-projects-directory.tsx +943 -0
  81. package/src/suite/components/suite-integration-picker.test.tsx +224 -0
  82. package/src/suite/components/suite-integration-picker.tsx +939 -0
  83. package/src/suite/components/suite-integration-rules.test.tsx +157 -0
  84. package/src/suite/components/suite-integration-rules.tsx +471 -0
  85. package/src/suite/components/suite-mobile-drawer.test.tsx +18 -2
  86. package/src/suite/components/suite-mobile-drawer.tsx +65 -98
  87. package/src/suite/components/suite-notification-bell.tsx +29 -4
  88. package/src/suite/components/suite-sidebar.tsx +25 -25
  89. package/src/suite/components/suite-skeleton.tsx +2 -2
  90. package/src/suite/components/suite-user-menu.tsx +9 -8
  91. package/src/suite/components/theme-provider.test.tsx +87 -3
  92. package/src/suite/components/theme-provider.tsx +144 -23
  93. package/src/suite/components/today-ui.tsx +79 -68
  94. package/src/suite/components/workspace-switcher.test.tsx +56 -0
  95. package/src/suite/components/workspace-switcher.tsx +3 -3
  96. package/src/suite/icons/app-accents.ts +12 -12
  97. package/src/suite/icons/glyphs.ts +6 -12
  98. package/src/suite/index.ts +27 -0
  99. package/src/suite/lib/apps.test.ts +9 -1
  100. package/src/suite/lib/apps.ts +14 -8
  101. package/src/suite/lib/cookies.ts +53 -0
  102. package/src/suite/lib/theme-boot.ts +32 -0
  103. package/src/suite/lib/theme-cookie.ts +7 -0
  104. package/src/suite/lib/use-sidebar-width.ts +5 -51
  105. package/dist/chunk-KDR6HI6F.mjs +0 -1075
  106. package/dist/chunk-NOI42JNZ.mjs +0 -151
  107. package/dist/chunk-VXISSXTB.mjs +0 -2564
  108. package/src/suite/lib/use-lock-mobile-page-zoom.test.tsx +0 -65
  109. package/src/suite/lib/use-lock-mobile-page-zoom.ts +0 -51
@@ -1,14 +1,15 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useRef, useState, type ReactNode } from "react";
3
+ import * as Dialog from "@radix-ui/react-dialog";
4
+ import { useEffect, useState, type ReactNode } from "react";
4
5
  import { Xmark as X } from "iconoir-react";
5
6
 
6
7
  import { cn } from "../lib/cn";
7
8
 
8
9
  /**
9
10
  * Suite mobile navigation drawer. Backdrop fade + panel slide, Escape to
10
- * close, body scroll-lock and focus hand-off while open. Nav content stays
11
- * app-side via `children`.
11
+ * close, body scroll-lock and focus containment while open. Nav content stays
12
+ * app-side via `children`; Radix owns the modal interaction model.
12
13
  */
13
14
  export function SuiteMobileDrawer({
14
15
  open,
@@ -57,13 +58,6 @@ export function SuiteMobileDrawer({
57
58
  // `shown` drives the slide/fade end-state.
58
59
  const [rendered, setRendered] = useState(open);
59
60
  const [shown, setShown] = useState(open);
60
- const panelRef = useRef<HTMLElement | null>(null);
61
- const onCloseRef = useRef(onClose);
62
-
63
- useEffect(() => {
64
- onCloseRef.current = onClose;
65
- }, [onClose]);
66
-
67
61
  useEffect(() => {
68
62
  if (open) {
69
63
  queueMicrotask(() => setRendered(true));
@@ -82,101 +76,74 @@ export function SuiteMobileDrawer({
82
76
  return () => window.clearTimeout(timeout);
83
77
  }, [open, durationMs]);
84
78
 
85
- // Escape to close, body scroll-lock, and focus inside while open.
86
- useEffect(() => {
87
- if (!open) return;
88
-
89
- const onKeyDown = (event: KeyboardEvent) => {
90
- if (event.key === "Escape") onCloseRef.current();
91
- };
92
- window.addEventListener("keydown", onKeyDown);
93
-
94
- const previousOverflow = document.body.style.overflow;
95
- document.body.style.overflow = "hidden";
96
-
97
- const previouslyFocused =
98
- document.activeElement instanceof HTMLElement
99
- ? document.activeElement
100
- : null;
101
- const frame = requestAnimationFrame(() => panelRef.current?.focus());
102
-
103
- return () => {
104
- window.removeEventListener("keydown", onKeyDown);
105
- document.body.style.overflow = previousOverflow;
106
- cancelAnimationFrame(frame);
107
- if (previouslyFocused?.isConnected) previouslyFocused.focus();
108
- };
109
- }, [open]);
110
-
111
79
  if (!rendered) return null;
112
80
 
113
81
  return (
114
- <div data-test={dataTest} className="no-print fixed inset-0 z-50 lg:hidden">
115
- <button
116
- type="button"
117
- id={backdropId}
118
- data-test={backdropDataTest}
119
- tabIndex={-1}
120
- className={cn(
121
- "absolute inset-0 bg-black/25 backdrop-blur-sm motion-safe:transition-opacity motion-safe:ease-spring-subtle",
122
- shown ? "opacity-100" : "opacity-0",
123
- )}
124
- style={{ transitionDuration: `${durationMs}ms` }}
125
- onClick={onClose}
126
- aria-label={backdropLabel}
127
- />
128
- <aside
129
- ref={panelRef}
130
- role="dialog"
131
- aria-modal="true"
132
- aria-label={dialogLabel}
133
- tabIndex={-1}
134
- data-test={panelDataTest}
135
- className={cn(
136
- "relative flex h-dvh max-h-dvh 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",
137
- side === "right" && "ml-auto",
138
- shown
139
- ? "translate-x-0"
140
- : side === "right"
141
- ? "translate-x-full"
142
- : "-translate-x-full",
143
- panelClassName,
144
- )}
145
- style={{ transitionDuration: `${durationMs}ms` }}
146
- >
147
- {showCloseButton ? (
148
- <div
82
+ <Dialog.Root open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
83
+ <Dialog.Portal forceMount>
84
+ <Dialog.Overlay
85
+ forceMount
86
+ id={backdropId}
87
+ data-test={backdropDataTest ?? dataTest}
88
+ aria-label={backdropLabel}
89
+ className={cn(
90
+ "no-print fixed inset-0 z-50 bg-black/25 backdrop-blur-sm motion-safe:transition-opacity motion-safe:ease-spring-subtle lg:hidden",
91
+ shown ? "opacity-100" : "opacity-0",
92
+ )}
93
+ style={{ transitionDuration: `${durationMs}ms` }}
94
+ />
95
+ <Dialog.Content forceMount asChild>
96
+ <aside
97
+ role="dialog"
98
+ data-test={panelDataTest}
99
+ aria-modal="true"
149
100
  className={cn(
150
- "flex h-14 shrink-0 items-center border-b border-ph-border px-3",
151
- title ? "justify-between" : "justify-end",
101
+ "fixed inset-y-0 left-0 z-50 flex h-dvh max-h-dvh 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 lg:hidden",
102
+ side === "right" && "left-auto right-0",
103
+ shown
104
+ ? "translate-x-0"
105
+ : side === "right"
106
+ ? "translate-x-full"
107
+ : "-translate-x-full",
108
+ panelClassName,
152
109
  )}
110
+ style={{ transitionDuration: `${durationMs}ms` }}
153
111
  >
154
- {title ? (
155
- <span className="truncate text-sm font-semibold text-ph-ink">
156
- {title}
157
- </span>
112
+ <Dialog.Title className="sr-only">{dialogLabel}</Dialog.Title>
113
+ {showCloseButton ? (
114
+ <div
115
+ className={cn(
116
+ "flex h-14 shrink-0 items-center border-b border-ph-border px-3",
117
+ title ? "justify-between" : "justify-end",
118
+ )}
119
+ >
120
+ {title ? (
121
+ <span className="truncate text-sm font-semibold text-ph-ink">
122
+ {title}
123
+ </span>
124
+ ) : null}
125
+ <Dialog.Close asChild>
126
+ <button
127
+ type="button"
128
+ id={closeButtonId}
129
+ data-test={closeButtonDataTest}
130
+ className="-mr-1.5 inline-flex h-11 w-11 items-center justify-center rounded-lg text-ph-mutedtext transition-colors hover:bg-ph-muted hover:text-ph-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-brand"
131
+ aria-label={closeLabel}
132
+ >
133
+ <X className="h-4 w-4" aria-hidden />
134
+ </button>
135
+ </Dialog.Close>
136
+ </div>
158
137
  ) : null}
159
- <button
160
- type="button"
161
- id={closeButtonId}
162
- data-test={closeButtonDataTest}
163
- onClick={onClose}
164
- className="-mr-1.5 inline-flex h-11 w-11 items-center justify-center rounded-lg text-ph-mutedtext transition-colors hover:bg-ph-muted hover:text-ph-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-brand"
165
- aria-label={closeLabel}
138
+ <div
139
+ data-test="suite-mobile-drawer-body"
140
+ className="suite-scroll-lock flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-[max(0.75rem,env(safe-area-inset-bottom))]"
166
141
  >
167
- <X className="h-4 w-4" aria-hidden />
168
- </button>
169
- </div>
170
- ) : null}
171
- {/* One column scroller, same as desktop SuiteSidebar body. Nested
172
- flex-1 overflow-y-auto regions trap the tree in a leftover strip. */}
173
- <div
174
- data-test="suite-mobile-drawer-body"
175
- className="suite-scroll-lock flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-[max(0.75rem,env(safe-area-inset-bottom))]"
176
- >
177
- {children}
178
- </div>
179
- </aside>
180
- </div>
142
+ {children}
143
+ </div>
144
+ </aside>
145
+ </Dialog.Content>
146
+ </Dialog.Portal>
147
+ </Dialog.Root>
181
148
  );
182
149
  }
@@ -5,7 +5,11 @@ 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
+ import {
9
+ resolveSuiteNotificationHref,
10
+ suiteAppBaseUrl,
11
+ suiteAppSlugForNotificationSource,
12
+ } from "../lib/apps";
9
13
 
10
14
  const POLL_MS = 60_000;
11
15
 
@@ -143,8 +147,30 @@ export function SuiteNotificationBell({
143
147
  }
144
148
  const target = resolveSuiteNotificationHref(n.href, n.source_app);
145
149
  if (!target) return;
146
- // Cross-app notifications must leave this product origin.
147
150
  if (/^https?:\/\//i.test(target)) {
151
+ try {
152
+ const url = new URL(target);
153
+ const sourceApp = suiteAppSlugForNotificationSource(n.source_app);
154
+ const path = `${url.pathname}${url.search}${url.hash}`;
155
+ if (url.origin === window.location.origin) {
156
+ router.push(path);
157
+ return;
158
+ }
159
+ if (sourceApp && sourceApp !== "shellstack") {
160
+ const sourceOrigin = new URL(suiteAppBaseUrl(sourceApp)).origin;
161
+ if (url.origin === sourceOrigin) {
162
+ const launchUrl = new URL(
163
+ `/launch/${sourceApp}`,
164
+ suiteAppBaseUrl("shellstack"),
165
+ );
166
+ launchUrl.searchParams.set("next", path);
167
+ window.location.assign(launchUrl.toString());
168
+ return;
169
+ }
170
+ }
171
+ } catch {
172
+ // Fall through to the original external link.
173
+ }
148
174
  window.location.assign(target);
149
175
  return;
150
176
  }
@@ -184,7 +210,7 @@ export function SuiteNotificationBell({
184
210
  <span className="relative inline-flex h-5 w-5">
185
211
  <Bell className="h-5 w-5" strokeWidth={1.75} aria-hidden />
186
212
  {unread > 0 ? (
187
- <span className="pointer-events-none absolute -right-2 -top-2 z-[1] flex h-4 min-w-4 items-center justify-center rounded-full bg-ph-brand px-1 text-[9px] font-semibold leading-none text-white ring-2 ring-ph-surface">
213
+ <span className="pointer-events-none absolute -right-2 -top-2 z-[1] flex h-4 min-w-4 items-center justify-center rounded-full bg-ph-brand px-1 text-[9px] font-semibold leading-none text-[var(--ph-on-accent)] ring-2 ring-ph-surface">
188
214
  {unread > 9 ? "9+" : unread}
189
215
  </span>
190
216
  ) : null}
@@ -215,7 +241,6 @@ export function SuiteNotificationBell({
215
241
  items.map((n) => (
216
242
  <button
217
243
  key={n.id}
218
- type="button"
219
244
  id={`${dataTest}-item-${n.id}`}
220
245
  data-test={`${dataTest}-item-${n.id}`}
221
246
  onClick={() => void openItem(n)}
@@ -6,10 +6,7 @@ import { NavArrowLeft, Sparks } from "iconoir-react";
6
6
 
7
7
  import { Tooltip } from "../../components/ui/Tooltip";
8
8
  import { cn } from "../lib/cn";
9
- import {
10
- persistSuiteAskAiOpen,
11
- SUITE_OPEN_ASK_AI_EVENT,
12
- } from "./ai-panel";
9
+ import { persistSuiteAskAiOpen, SUITE_OPEN_ASK_AI_EVENT } from "./ai-panel";
13
10
  import type { SuiteNavIcon } from "./suite-bottom-nav";
14
11
 
15
12
  type CollapsedNode = ReactNode | ((collapsed: boolean) => ReactNode);
@@ -31,7 +28,7 @@ export interface SuiteSidebarProps {
31
28
  /** Optional custom app switcher node, or a function of collapsed state. */
32
29
  appSwitcher?: CollapsedNode;
33
30
  /** Workspace/org/project switcher rendered below the app switcher. */
34
- contextSwitcher: CollapsedNode;
31
+ contextSwitcher?: CollapsedNode;
35
32
  navItems: SuiteSidebarNavItem[];
36
33
  /** Optional secondary nav / tree rendered below primary nav. */
37
34
  secondaryNav?: ReactNode;
@@ -157,17 +154,15 @@ export function SuiteSidebar({
157
154
  surface ? "bg-ph-surface" : "bg-ph-canvas",
158
155
  )}
159
156
  >
160
- <Tooltip content="Back to navigation">
161
- <button
162
- type="button"
163
- data-test="suite-ask-ai-back"
164
- aria-label="Back to navigation"
165
- onClick={() => setAskAi(false)}
166
- className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-ph-subtle transition-colors hover:bg-ph-muted hover:text-ph-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-brand"
167
- >
168
- <NavArrowLeft className="h-4 w-4" aria-hidden />
169
- </button>
170
- </Tooltip>
157
+ <button
158
+ type="button"
159
+ data-test="suite-ask-ai-back"
160
+ aria-label="Back to navigation"
161
+ onClick={() => setAskAi(false)}
162
+ className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-ph-subtle transition-colors hover:bg-ph-muted hover:text-ph-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-brand"
163
+ >
164
+ <NavArrowLeft className="h-4 w-4" aria-hidden />
165
+ </button>
171
166
  <span className="min-w-0 flex-1 truncate text-sm font-semibold text-ph-ink">
172
167
  Shelly AI
173
168
  </span>
@@ -211,12 +206,19 @@ export function SuiteSidebar({
211
206
  "flex min-h-0 flex-1 flex-col",
212
207
  surface ? "bg-ph-surface" : "bg-ph-canvas",
213
208
  chatOpen ? "min-h-0" : "overflow-y-auto",
214
- collapsed ? "px-1.5 py-2" : "p-2",
209
+ collapsed ? "px-1.5 py-2" : "p-3",
215
210
  )}
216
211
  >
217
- <div className={cn("mb-3 w-full shrink-0", collapsed && "flex justify-center")}>
218
- {renderNode(contextSwitcher, collapsed)}
219
- </div>
212
+ {contextSwitcher ? (
213
+ <div
214
+ className={cn(
215
+ "mb-4 w-full shrink-0",
216
+ collapsed && "flex justify-center",
217
+ )}
218
+ >
219
+ {renderNode(contextSwitcher, collapsed)}
220
+ </div>
221
+ ) : null}
220
222
 
221
223
  {chatOpen ? (
222
224
  <div className="flex min-h-0 flex-1 flex-col">
@@ -238,8 +240,8 @@ export function SuiteSidebar({
238
240
  </div>
239
241
  ) : (
240
242
  <>
241
- <nav aria-label="Pages" className="shrink-0 space-y-0.5">
242
- <div className="space-y-0.5">
243
+ <nav aria-label="Pages" className="shrink-0 space-y-1">
244
+ <div className="space-y-1">
243
245
  {navItems.map((item) => {
244
246
  const Icon = item.icon;
245
247
  const active = Boolean(item.active);
@@ -331,9 +333,7 @@ export function SuiteSidebar({
331
333
  >
332
334
  {renderNode(userMenu, collapsed)}
333
335
  {showAskAi ? (
334
- <div
335
- className={cn("shrink-0", collapsed ? "mt-2" : "ml-auto")}
336
- >
336
+ <div className={cn("shrink-0", collapsed ? "mt-2" : "ml-auto")}>
337
337
  <Tooltip content="Shelly AI" side={collapsed ? "right" : "top"}>
338
338
  {askAiButton}
339
339
  </Tooltip>
@@ -167,14 +167,14 @@ export function SuiteEmptyState({
167
167
  {icon}
168
168
  </div>
169
169
  ) : null}
170
- <h3
170
+ <h2
171
171
  className={cn(
172
172
  'font-semibold text-ph-ink',
173
173
  size === 'sm' ? 'text-base' : 'text-lg',
174
174
  )}
175
175
  >
176
176
  {title}
177
- </h3>
177
+ </h2>
178
178
  {description ? (
179
179
  <p className="mx-auto mt-2 max-w-sm text-sm leading-relaxed text-ph-subtle">
180
180
  {description}
@@ -43,12 +43,12 @@ function computeInitials(
43
43
  * email or name so avatars without a photo get a per-user colour instead of a
44
44
  * flat grey. Uses theme `--ph-*` tokens so it adapts to light and dark themes.
45
45
  */
46
- const AVATAR_TOKENS = [
47
- "bg-ph-brand",
48
- "bg-ph-violet",
49
- "bg-ph-info",
50
- "bg-ph-success",
51
- "bg-ph-danger",
46
+ const AVATAR_TOKENS: Array<[string, string]> = [
47
+ ["bg-ph-brand", "text-[var(--ph-on-accent)]"],
48
+ ["bg-ph-violet", "text-[var(--ph-on-violet)]"],
49
+ ["bg-ph-info", "text-[var(--ph-on-info)]"],
50
+ ["bg-ph-success", "text-[var(--ph-on-success)]"],
51
+ ["bg-ph-danger", "text-[var(--ph-on-danger)]"],
52
52
  ];
53
53
 
54
54
  function avatarColorClass(seed: string): string {
@@ -56,7 +56,8 @@ function avatarColorClass(seed: string): string {
56
56
  for (let i = 0; i < seed.length; i += 1) {
57
57
  hash = seed.charCodeAt(i) + ((hash << 5) - hash);
58
58
  }
59
- return AVATAR_TOKENS[Math.abs(hash) % AVATAR_TOKENS.length];
59
+ const [bg, ink] = AVATAR_TOKENS[Math.abs(hash) % AVATAR_TOKENS.length];
60
+ return `${bg} ${ink}`;
60
61
  }
61
62
 
62
63
  /**
@@ -96,7 +97,7 @@ export function SuiteUserMenu({
96
97
  ) : (
97
98
  <span
98
99
  className={cn(
99
- "flex h-full w-full items-center justify-center text-sm font-semibold text-white",
100
+ "flex h-full w-full items-center justify-center text-sm font-semibold",
100
101
  fallbackColor,
101
102
  )}
102
103
  >
@@ -1,7 +1,15 @@
1
- import { cleanup, render, waitFor } from "@testing-library/react";
1
+ import { act, cleanup, render, waitFor } from "@testing-library/react";
2
+ import { useEffect } from "react";
2
3
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
4
 
4
- import { SuiteThemeProvider, type SuiteThemeConfig } from "./theme-provider";
5
+ import { SUITE_THEME_COOKIE } from "../lib/theme-cookie";
6
+ import { suiteThemeBootScript } from "../lib/theme-boot";
7
+ import {
8
+ SuiteThemeProvider,
9
+ useSuiteTheme,
10
+ type SuiteThemeConfig,
11
+ type SuiteThemeContextValue,
12
+ } from "./theme-provider";
5
13
 
6
14
  const config: SuiteThemeConfig = {
7
15
  storageKey: "test-theme",
@@ -36,8 +44,8 @@ beforeEach(() => {
36
44
  afterEach(() => {
37
45
  cleanup();
38
46
  vi.unstubAllGlobals();
47
+ document.cookie = `${SUITE_THEME_COOKIE}=; Path=/; Max-Age=0`;
39
48
  document.documentElement.removeAttribute("data-theme");
40
- document.documentElement.removeAttribute("data-suite-lock-page-zoom");
41
49
  document.documentElement.classList.remove("dark");
42
50
  document.getElementById("theme-color-meta")?.remove();
43
51
  });
@@ -60,4 +68,80 @@ describe("SuiteThemeProvider", () => {
60
68
  expect(meta).toHaveAttribute("content", config.themeColorDark),
61
69
  );
62
70
  });
71
+
72
+ it("adopts a shared-cookie preference from another suite app", async () => {
73
+ document.cookie = `${SUITE_THEME_COOKIE}=light; Path=/`;
74
+ document.documentElement.setAttribute("data-theme", config.darkThemeId);
75
+
76
+ render(
77
+ <SuiteThemeProvider config={config}>
78
+ <div />
79
+ </SuiteThemeProvider>,
80
+ );
81
+
82
+ await waitFor(() =>
83
+ expect(document.documentElement.getAttribute("data-theme")).toBe(
84
+ config.lightThemeId,
85
+ ),
86
+ );
87
+ expect(document.documentElement.classList.contains("dark")).toBe(false);
88
+ });
89
+
90
+ it("persists setTheme to the shared cookie for other suite apps", async () => {
91
+ document.cookie = `${SUITE_THEME_COOKIE}=system; Path=/`;
92
+ document.documentElement.setAttribute("data-theme", config.lightThemeId);
93
+
94
+ let captured: SuiteThemeContextValue | null = null;
95
+ function Probe() {
96
+ const ctx = useSuiteTheme();
97
+ useEffect(() => {
98
+ captured = ctx;
99
+ }, [ctx]);
100
+ return null;
101
+ }
102
+
103
+ render(
104
+ <SuiteThemeProvider config={config}>
105
+ <Probe />
106
+ </SuiteThemeProvider>,
107
+ );
108
+
109
+ await waitFor(() => expect(captured).not.toBeNull());
110
+ act(() => captured!.setTheme("dark"));
111
+
112
+ expect(document.documentElement.getAttribute("data-theme")).toBe(
113
+ config.darkThemeId,
114
+ );
115
+ expect(document.documentElement.classList.contains("dark")).toBe(true);
116
+ expect(document.cookie).toContain(`${SUITE_THEME_COOKIE}=dark`);
117
+ });
118
+ });
119
+
120
+ describe("suiteThemeBootScript", () => {
121
+ it("applies the shared cookie mode before the OS preference", () => {
122
+ document.cookie = `${SUITE_THEME_COOKIE}=light; Path=/`;
123
+ const script = suiteThemeBootScript({
124
+ lightThemeId: "x-light",
125
+ darkThemeId: "x-dark",
126
+ storageKey: "legacy",
127
+ });
128
+ new Function(script)();
129
+ expect(document.documentElement.getAttribute("data-theme")).toBe(
130
+ "x-light",
131
+ );
132
+ expect(document.documentElement.classList.contains("dark")).toBe(false);
133
+ });
134
+
135
+ it("falls back to the OS preference when no cookie or stored mode exists", () => {
136
+ const script = suiteThemeBootScript({
137
+ lightThemeId: "x-light",
138
+ darkThemeId: "x-dark",
139
+ storageKey: "legacy",
140
+ fallback: "light",
141
+ });
142
+ // The stubbed localStorage returns a legacy "dark" preference.
143
+ new Function(script)();
144
+ expect(document.documentElement.getAttribute("data-theme")).toBe("x-dark");
145
+ expect(document.documentElement.classList.contains("dark")).toBe(true);
146
+ });
63
147
  });