@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
@@ -6,15 +6,24 @@ import {
6
6
  useContext,
7
7
  useEffect,
8
8
  useMemo,
9
+ useRef,
9
10
  useState,
10
11
  type ReactNode,
11
12
  } from 'react';
12
13
 
13
- import { useLockMobilePageZoom } from '../lib/use-lock-mobile-page-zoom';
14
+ import { readCookie, writeCookie } from '../lib/cookies';
15
+ import { SUITE_THEME_CHANNEL, SUITE_THEME_COOKIE } from '../lib/theme-cookie';
14
16
 
15
17
  export type SuiteTheme = 'system' | 'light' | 'dark';
16
18
  export type SuiteResolvedTheme = 'light' | 'dark';
17
19
 
20
+ /**
21
+ * One cookie shared by every suite app: pick light/dark/system in any app
22
+ * and the rest follow (cookies ignore ports on localhost and subdomains can
23
+ * opt in via the shared domain attribute).
24
+ */
25
+ export { SUITE_THEME_COOKIE };
26
+
18
27
  export interface SuiteThemeConfig {
19
28
  /** localStorage key holding the stored preference. */
20
29
  storageKey: string;
@@ -45,6 +54,38 @@ export interface SuiteThemeContextValue {
45
54
 
46
55
  const SuiteThemeContext = createContext<SuiteThemeContextValue | null>(null);
47
56
 
57
+ let suiteThemeChannel: BroadcastChannel | null = null;
58
+
59
+ function broadcastTheme(theme: SuiteTheme): void {
60
+ if (typeof BroadcastChannel === 'undefined') return;
61
+ try {
62
+ suiteThemeChannel ??= new BroadcastChannel(SUITE_THEME_CHANNEL);
63
+ suiteThemeChannel.postMessage(theme);
64
+ } catch {
65
+ // Cross-tab broadcast is best-effort.
66
+ }
67
+ }
68
+
69
+ function readSharedTheme(): SuiteTheme | null {
70
+ if (typeof window === 'undefined') return null;
71
+ try {
72
+ const value = readCookie(SUITE_THEME_COOKIE);
73
+ return value === 'light' || value === 'dark' || value === 'system'
74
+ ? value
75
+ : null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ function writeSharedTheme(theme: SuiteTheme): void {
82
+ try {
83
+ writeCookie(SUITE_THEME_COOKIE, theme);
84
+ } catch {
85
+ // Cookie writes can fail in restricted browser contexts.
86
+ }
87
+ }
88
+
48
89
  function systemTheme(fallback: SuiteResolvedTheme): SuiteResolvedTheme {
49
90
  if (typeof window === 'undefined') return fallback;
50
91
  return window.matchMedia('(prefers-color-scheme: dark)').matches
@@ -84,9 +125,15 @@ function applyTheme(
84
125
 
85
126
  function readStoredTheme(storageKey: string): SuiteTheme {
86
127
  if (typeof window === 'undefined') return 'system';
87
- const stored = localStorage.getItem(storageKey);
88
- if (stored === 'light' || stored === 'dark' || stored === 'system')
89
- return stored;
128
+ const shared = readSharedTheme();
129
+ if (shared) return shared;
130
+ try {
131
+ const stored = localStorage.getItem(storageKey);
132
+ if (stored === 'light' || stored === 'dark' || stored === 'system')
133
+ return stored;
134
+ } catch {
135
+ // localStorage can be unavailable; the cookie still crosses apps.
136
+ }
90
137
  return 'system';
91
138
  }
92
139
 
@@ -96,6 +143,11 @@ function readStoredTheme(storageKey: string): SuiteTheme {
96
143
  * adopts that DOM state on mount and owns every post-hydration change (user
97
144
  * picks a theme, or the OS preference flips while set to `system`).
98
145
  *
146
+ * The chosen mode is persisted both to the app's localStorage key and to the
147
+ * shared suite cookie, and adopted from the cookie whenever another suite
148
+ * app or tab changes it (focus, visibility, polling and same-origin
149
+ * `storage`/`BroadcastChannel` events).
150
+ *
99
151
  * Apps wrap this with their own `ThemeProvider`/`useTheme` module that pins
100
152
  * the storage key, theme ids and meta colours.
101
153
  */
@@ -106,7 +158,6 @@ export function SuiteThemeProvider({
106
158
  config: SuiteThemeConfig;
107
159
  children: ReactNode;
108
160
  }) {
109
- useLockMobilePageZoom();
110
161
  const [state, setState] = useState<{
111
162
  theme: SuiteTheme;
112
163
  resolvedTheme: SuiteResolvedTheme;
@@ -124,14 +175,22 @@ export function SuiteThemeProvider({
124
175
  : resolveTheme(theme, config.fallbackTheme);
125
176
  return { theme, resolvedTheme };
126
177
  });
127
- const { theme, resolvedTheme } = state;
178
+ const themeRef = useRef(state.theme);
179
+ themeRef.current = state.theme;
180
+
181
+ useEffect(() => {
182
+ applyTheme(state.theme, config);
183
+ }, [state.theme, config]);
128
184
 
185
+ // Seed the shared cookie so a legacy per-app preference spreads to the
186
+ // rest of the suite on the next app switch.
129
187
  useEffect(() => {
130
- applyTheme(theme, config);
131
- }, [theme, config]);
188
+ if (readSharedTheme() == null) writeSharedTheme(state.theme);
189
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- seed once on mount
190
+ }, []);
132
191
 
133
192
  useEffect(() => {
134
- if (theme !== 'system') return;
193
+ if (state.theme !== 'system') return;
135
194
  const media = window.matchMedia('(prefers-color-scheme: dark)');
136
195
  const update = () => {
137
196
  const resolved = applyTheme('system', config);
@@ -139,32 +198,94 @@ export function SuiteThemeProvider({
139
198
  };
140
199
  media.addEventListener('change', update);
141
200
  return () => media.removeEventListener('change', update);
142
- }, [theme, config]);
201
+ }, [state.theme, config]);
202
+
203
+ useEffect(() => {
204
+ let channel: BroadcastChannel | null = null;
205
+ try {
206
+ channel = new BroadcastChannel(SUITE_THEME_CHANNEL);
207
+ } catch {
208
+ channel = null;
209
+ }
210
+
211
+ const adopt = (next: unknown) => {
212
+ if (next !== 'light' && next !== 'dark' && next !== 'system') return;
213
+ if (next === themeRef.current) return;
214
+ const resolved = applyTheme(next, config);
215
+ try {
216
+ localStorage.setItem(config.storageKey, next);
217
+ } catch {
218
+ // localStorage can be unavailable; the cookie still crosses apps.
219
+ }
220
+ setState({ theme: next, resolvedTheme: resolved });
221
+ };
222
+
223
+ const readShared = () => adopt(readSharedTheme());
224
+ const onChannel = (event: MessageEvent) => adopt(event.data);
225
+ const onStorage = (event: StorageEvent) => {
226
+ if (event.key === config.storageKey) adopt(event.newValue);
227
+ };
228
+ let poll: ReturnType<typeof setInterval> | null = null;
229
+ const startPolling = () => {
230
+ if (poll == null) poll = setInterval(readShared, 2000);
231
+ };
232
+ const stopPolling = () => {
233
+ if (poll != null) {
234
+ clearInterval(poll);
235
+ poll = null;
236
+ }
237
+ };
238
+ const onVisibility = () => {
239
+ if (document.visibilityState === 'visible') {
240
+ readShared();
241
+ startPolling();
242
+ } else {
243
+ stopPolling();
244
+ }
245
+ };
246
+
247
+ channel?.addEventListener('message', onChannel);
248
+ window.addEventListener('storage', onStorage);
249
+ window.addEventListener('focus', readShared);
250
+ document.addEventListener('visibilitychange', onVisibility);
251
+ if (document.visibilityState === 'visible') startPolling();
252
+
253
+ return () => {
254
+ channel?.removeEventListener('message', onChannel);
255
+ channel?.close();
256
+ window.removeEventListener('storage', onStorage);
257
+ window.removeEventListener('focus', readShared);
258
+ document.removeEventListener('visibilitychange', onVisibility);
259
+ stopPolling();
260
+ };
261
+ }, [config]);
143
262
 
144
263
  const setTheme = useCallback(
145
264
  (next: SuiteTheme) => {
146
265
  const resolved = applyTheme(next, config);
147
266
  setState({ theme: next, resolvedTheme: resolved });
148
- localStorage.setItem(config.storageKey, next);
267
+ try {
268
+ localStorage.setItem(config.storageKey, next);
269
+ } catch {
270
+ // localStorage can be unavailable; the cookie still crosses apps.
271
+ }
272
+ writeSharedTheme(next);
273
+ broadcastTheme(next);
149
274
  },
150
275
  [config],
151
276
  );
152
277
 
153
278
  const toggleTheme = useCallback(() => {
154
- setState((prev) => {
155
- const next: SuiteTheme =
156
- resolveTheme(prev.theme, config.fallbackTheme) === 'dark'
157
- ? 'light'
158
- : 'dark';
159
- const resolved = applyTheme(next, config);
160
- localStorage.setItem(config.storageKey, next);
161
- return { theme: next, resolvedTheme: resolved };
162
- });
163
- }, [config]);
279
+ const next: SuiteTheme =
280
+ resolveTheme(themeRef.current, config.fallbackTheme) === 'dark'
281
+ ? 'light'
282
+ : 'dark';
283
+ setTheme(next);
284
+ }, [config, setTheme]);
164
285
 
165
286
  const value = useMemo(
166
- () => ({ theme, resolvedTheme, setTheme, toggleTheme }),
167
- [theme, resolvedTheme, setTheme, toggleTheme],
287
+ () => ({ theme: state.theme, resolvedTheme: state.resolvedTheme, setTheme, toggleTheme }),
288
+ [state.theme, state.resolvedTheme, setTheme, toggleTheme],
168
289
  );
169
290
 
170
291
  return (
@@ -1,53 +1,59 @@
1
- 'use client';
2
-
3
- import { Refresh as RefreshCw } from 'iconoir-react';
4
- import type { ReactNode } from 'react';
5
- import { cn } from '../lib/cn';
6
- import { SuiteAppIcon } from '../icons/suite-app-icon';
7
- import { SuiteIcon } from '../icons/suite-icon';
8
- import type { SuiteIconName } from '../icons/glyphs';
9
- type LucideIcon = import('react').ComponentType<
10
- import('react').SVGProps<SVGSVGElement>
1
+ "use client";
2
+
3
+ import { Refresh as RefreshCw } from "iconoir-react";
4
+ import type { ReactNode } from "react";
5
+ import { cn } from "../lib/cn";
6
+ import { SuiteAppIcon } from "../icons/suite-app-icon";
7
+ import { SuiteIcon } from "../icons/suite-icon";
8
+ type LucideIcon = import("react").ComponentType<
9
+ import("react").SVGProps<SVGSVGElement>
11
10
  >;
12
11
 
13
- export type SuiteAppSlug = 'shellstack' | 'tides' | 'turtletime' | 'kraken';
12
+ export type SuiteAppSlug =
13
+ "shellstack" | "tides" | "turtletime" | "kraken" | "shelly";
14
14
 
15
15
  const APP_META: Record<SuiteAppSlug, { name: string }> = {
16
- shellstack: { name: 'ShellStack' },
17
- tides: { name: 'Tides' },
18
- turtletime: { name: 'TurtleTime' },
19
- kraken: { name: 'Kraken' },
16
+ shellstack: { name: "ShellStack" },
17
+ tides: { name: "Tides" },
18
+ turtletime: { name: "TurtleTime" },
19
+ kraken: { name: "Kraken" },
20
+ shelly: { name: "Shelly" },
20
21
  };
21
22
 
22
23
  const SUITE_APPS: SuiteAppSlug[] = [
23
- 'shellstack',
24
- 'tides',
25
- 'turtletime',
26
- 'kraken',
24
+ "shellstack",
25
+ "tides",
26
+ "turtletime",
27
+ "kraken",
28
+ "shelly",
27
29
  ];
28
30
 
29
31
  export function sourceToSuiteApp(source: string): SuiteAppSlug {
30
- if (source === 'tides' || source === 'turtletime' || source === 'kraken') {
31
- return source;
32
+ const normalised = source.trim().toLowerCase();
33
+ if (
34
+ normalised === "tides" ||
35
+ normalised === "turtletime" ||
36
+ normalised === "kraken" ||
37
+ normalised === "shelly"
38
+ ) {
39
+ return normalised;
32
40
  }
33
- return 'shellstack';
41
+ return "shellstack";
34
42
  }
35
43
 
36
44
  export function suiteAppLabel(source: string): string {
37
45
  return APP_META[sourceToSuiteApp(source)].name;
38
46
  }
39
47
 
40
- const SOURCE_APP_GLYPH: Record<SuiteAppSlug, SuiteIconName> = {
41
- shellstack: 'stack-hex',
42
- tides: 'kanban-wave',
43
- turtletime: 'timer-shell',
44
- kraken: 'squid-doc',
48
+ const SOURCE_APP_LOGOS: Record<SuiteAppSlug, string> = {
49
+ shellstack: "/icon-shellstack.svg",
50
+ tides: "/tides-icon.svg",
51
+ turtletime: "/turtletime-icon.svg",
52
+ kraken: "/kraken-icon.svg",
53
+ shelly: "/shelly-icon.svg",
45
54
  };
46
55
 
47
- /**
48
- * Small line icon for list rows — the app's suite glyph with its brand
49
- * accent on a muted base. Not the branded app tile (see SuiteAppIcon).
50
- */
56
+ /** Actual product favicon/logo for suite notification rows. */
51
57
  export function SourceAppGlyph({
52
58
  source,
53
59
  className,
@@ -57,18 +63,20 @@ export function SourceAppGlyph({
57
63
  }) {
58
64
  const app = sourceToSuiteApp(source);
59
65
  return (
60
- <SuiteIcon
61
- name={SOURCE_APP_GLYPH[app]}
62
- accent={app}
63
- className={cn('h-4 w-4 shrink-0 text-ph-subtle', className)}
66
+ // These assets are copied into each product's public directory.
67
+ // eslint-disable-next-line @next/next/no-img-element
68
+ <img
69
+ src={SOURCE_APP_LOGOS[app]}
70
+ alt=""
71
+ className={cn("h-7 w-7 shrink-0 rounded-lg object-contain", className)}
64
72
  />
65
73
  );
66
74
  }
67
75
 
68
76
  function formatLongDate(date = new Date()): string {
69
77
  return new Intl.DateTimeFormat(undefined, {
70
- month: 'long',
71
- day: 'numeric',
78
+ month: "long",
79
+ day: "numeric",
72
80
  }).format(date);
73
81
  }
74
82
 
@@ -82,7 +90,7 @@ export function SuiteAppStrip({
82
90
  return (
83
91
  <div
84
92
  className={cn(
85
- 'flex flex-wrap items-center justify-center gap-2.5',
93
+ "flex flex-wrap items-center justify-center gap-2.5",
86
94
  className,
87
95
  )}
88
96
  aria-label="ShellStack suite apps"
@@ -223,7 +231,7 @@ export function TodayTopBar({
223
231
  aria-label="Refresh today"
224
232
  >
225
233
  <RefreshCw
226
- className={cn('h-4 w-4', refreshing && 'animate-spin')}
234
+ className={cn("h-4 w-4", refreshing && "animate-spin")}
227
235
  aria-hidden
228
236
  />
229
237
  </button>
@@ -237,15 +245,15 @@ export function getTodayGreeting(
237
245
  stats?: { taskCount?: number; overdueCount?: number; hours?: number },
238
246
  ): string {
239
247
  const hour = new Date().getHours();
240
- let period = 'morning';
241
- if (hour >= 12 && hour < 17) period = 'afternoon';
242
- else if (hour >= 17 && hour < 21) period = 'evening';
243
- else if (hour >= 21 || hour < 5) period = 'night';
248
+ let period = "morning";
249
+ if (hour >= 12 && hour < 17) period = "afternoon";
250
+ else if (hour >= 17 && hour < 21) period = "evening";
251
+ else if (hour >= 21 || hour < 5) period = "night";
244
252
 
245
253
  const name =
246
254
  user?.first_name ||
247
- user?.name?.split(' ')[0] ||
248
- user?.email?.split('@')[0] ||
255
+ user?.name?.split(" ")[0] ||
256
+ user?.email?.split("@")[0] ||
249
257
  null;
250
258
 
251
259
  let greeting = `Good ${period}`;
@@ -256,22 +264,22 @@ export function getTodayGreeting(
256
264
  const hours = stats?.hours ?? 0;
257
265
 
258
266
  if (overdueCount > 0) {
259
- return `${greeting}. ${taskCount} task${taskCount === 1 ? '' : 's'} due today, ${overdueCount} overdue.`;
267
+ return `${greeting}. ${taskCount} task${taskCount === 1 ? "" : "s"} due today, ${overdueCount} overdue.`;
260
268
  }
261
269
  if (taskCount > 0) {
262
- return `${greeting}. ${taskCount} task${taskCount === 1 ? '' : 's'} due today.`;
270
+ return `${greeting}. ${taskCount} task${taskCount === 1 ? "" : "s"} due today.`;
263
271
  }
264
272
  if (hours > 0) {
265
- return `${greeting}. ${hours} hour${hours === 1 ? '' : 's'} logged today.`;
273
+ return `${greeting}. ${hours} hour${hours === 1 ? "" : "s"} logged today.`;
266
274
  }
267
- if (period === 'evening' || period === 'night') {
275
+ if (period === "evening" || period === "night") {
268
276
  return `${greeting}. Nice work — time to wrap up.`;
269
277
  }
270
278
  return `${greeting}. Clear queue — take a nice, deep breath.`;
271
279
  }
272
280
 
273
281
  export function TodayHero({
274
- message = 'Take a nice, deep breath.',
282
+ message = "Take a nice, deep breath.",
275
283
  brief,
276
284
  briefMeta,
277
285
  }: {
@@ -283,7 +291,7 @@ export function TodayHero({
283
291
  return (
284
292
  <div className="mt-4 sm:mt-6">
285
293
  <p
286
- key={typeof message === 'string' ? message : 'hero'}
294
+ key={typeof message === "string" ? message : "hero"}
287
295
  className="today-hero max-w-2xl font-display text-lg font-semibold tracking-tight text-ph-ink sm:text-xl sm:leading-snug"
288
296
  >
289
297
  {message}
@@ -301,7 +309,7 @@ export function TodayHero({
301
309
  }
302
310
 
303
311
  export type TodayPrimaryAccent =
304
- 'tides' | 'turtletime' | 'kraken' | 'shellstack' | 'neutral';
312
+ "tides" | "turtletime" | "kraken" | "shellstack" | "neutral";
305
313
 
306
314
  /**
307
315
  * Compact primary CTA for Today — one action per app.
@@ -311,13 +319,13 @@ export function TodayPrimaryAction({
311
319
  label,
312
320
  description,
313
321
  icon,
314
- accent = 'neutral',
322
+ accent = "neutral",
315
323
  onClick,
316
324
  href,
317
325
  loading = false,
318
326
  active = false,
319
327
  className,
320
- dataTest = 'today-primary-action',
328
+ dataTest = "today-primary-action",
321
329
  }: {
322
330
  label: string;
323
331
  description?: string;
@@ -332,10 +340,10 @@ export function TodayPrimaryAction({
332
340
  dataTest?: string;
333
341
  }) {
334
342
  const classes = cn(
335
- 'today-primary-action group relative flex w-full max-w-md items-center gap-3 overflow-hidden rounded-xl px-3.5 py-2.5 text-left transition sm:px-4 sm:py-3',
343
+ "today-primary-action group relative flex w-full max-w-md items-center gap-3 overflow-hidden rounded-xl px-3.5 py-2.5 text-left transition sm:px-4 sm:py-3",
336
344
  `today-primary-action--${accent}`,
337
- active && 'today-primary-action--active',
338
- loading && 'pointer-events-none opacity-70',
345
+ active && "today-primary-action--active",
346
+ loading && "pointer-events-none opacity-70",
339
347
  className,
340
348
  );
341
349
 
@@ -452,7 +460,7 @@ export function TodayLayout({
452
460
  }) {
453
461
  return (
454
462
  <div
455
- className={cn('mt-5 space-y-6 sm:mt-6', className)}
463
+ className={cn("mt-5 space-y-6 sm:mt-6", className)}
456
464
  data-test="today-layout"
457
465
  >
458
466
  {primary ? <div className="max-w-md">{primary}</div> : null}
@@ -473,9 +481,9 @@ export function TodayLayout({
473
481
  * Prefer onClick → openSuiteAskAi(); href is a fallback only.
474
482
  */
475
483
  export function TodayAskAiCard({
476
- title = 'Shelly AI',
477
- description = 'Ask about your workspace, draft something, or look a fact up online.',
478
- cta = 'Ask',
484
+ title = "Shelly AI",
485
+ description = "Ask about your workspace, draft something, or look a fact up online.",
486
+ cta = "Ask",
479
487
  className,
480
488
  onClick,
481
489
  href,
@@ -488,15 +496,18 @@ export function TodayAskAiCard({
488
496
  href?: string;
489
497
  }) {
490
498
  const classes = cn(
491
- 'group flex w-full items-start gap-3.5 rounded-2xl border border-ph-border/70 bg-ph-surface px-4 py-3.5 text-left shadow-[0_1px_12px_-8px_rgba(15,23,42,0.1)] transition hover:border-ph-brand/35 hover:bg-[color-mix(in_oklab,var(--ph-accent)_6%,var(--ph-surface))] sm:px-5',
499
+ "group flex w-full items-start gap-3.5 rounded-2xl border border-ph-border/70 bg-ph-surface px-4 py-3.5 text-left shadow-[0_1px_12px_-8px_rgba(15,23,42,0.1)] transition hover:border-ph-brand/35 hover:bg-[color-mix(in_oklab,var(--ph-accent)_6%,var(--ph-surface))] sm:px-5",
492
500
  className,
493
501
  );
494
502
 
495
503
  const body = (
496
504
  <>
497
- <span className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-ph-brand/15 text-ph-brand shadow-sm ring-1 ring-black/[0.06]">
498
- <SuiteIcon name="stack-hex" accent="shellstack" className="h-5 w-5" />
499
- </span>
505
+ <img
506
+ src="/shelly-icon.svg"
507
+ alt=""
508
+ aria-hidden
509
+ className="h-5 w-5 shrink-0 object-contain"
510
+ />
500
511
  <span className="min-w-0 flex-1">
501
512
  <span className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
502
513
  <span className="font-display text-sm font-bold tracking-tight text-ph-ink">
@@ -564,8 +575,8 @@ export function TodaySection({
564
575
  <div className="flex min-w-0 items-start gap-3.5">
565
576
  <span
566
577
  className={cn(
567
- 'inline-flex h-10 w-10 sm:h-11 sm:w-11 shrink-0 items-center justify-center rounded-2xl shadow-sm ring-1 ring-black/[0.06]',
568
- iconClassName ?? 'bg-ph-muted text-ph-ink',
578
+ "inline-flex h-10 w-10 sm:h-11 sm:w-11 shrink-0 items-center justify-center rounded-2xl shadow-sm ring-1 ring-black/[0.06]",
579
+ iconClassName ?? "bg-ph-muted text-ph-ink",
569
580
  )}
570
581
  >
571
582
  <Icon className="h-5 w-5" aria-hidden />
@@ -598,7 +609,7 @@ export function TodayPanel({
598
609
  return (
599
610
  <div
600
611
  className={cn(
601
- 'border-ph-border/70 rounded-2xl border bg-ph-surface p-5 shadow-[0_1px_16px_-8px_rgba(15,23,42,0.12)] sm:p-5',
612
+ "border-ph-border/70 rounded-2xl border bg-ph-surface p-5 shadow-[0_1px_16px_-8px_rgba(15,23,42,0.12)] sm:p-5",
602
613
  className,
603
614
  )}
604
615
  >
@@ -0,0 +1,56 @@
1
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { SuiteWorkspaceSwitcher } from "./workspace-switcher";
6
+
7
+ function TestDropdownMenu({
8
+ trigger,
9
+ children,
10
+ }: {
11
+ trigger: ReactNode;
12
+ children: ReactNode;
13
+ }) {
14
+ return (
15
+ <div>
16
+ {trigger}
17
+ {children}
18
+ </div>
19
+ );
20
+ }
21
+
22
+ function TestDropdownItem({
23
+ children,
24
+ ...props
25
+ }: ComponentPropsWithoutRef<"button">) {
26
+ return (
27
+ <button data-dropdown-item="true" {...props}>
28
+ {children}
29
+ </button>
30
+ );
31
+ }
32
+
33
+ describe("SuiteWorkspaceSwitcher", () => {
34
+ afterEach(cleanup);
35
+
36
+ it("uses menu items rather than listbox options for workspace choices", () => {
37
+ render(
38
+ <SuiteWorkspaceSwitcher
39
+ name="Acme"
40
+ currentId="acme"
41
+ workspaces={[
42
+ { id: "acme", name: "Acme" },
43
+ { id: "globex", name: "Globex" },
44
+ ]}
45
+ onSelect={vi.fn()}
46
+ dropdownMenu={TestDropdownMenu}
47
+ dropdownItem={TestDropdownItem}
48
+ />,
49
+ );
50
+
51
+ const currentWorkspace = screen.getByRole("button", { name: "Acme" });
52
+ expect(currentWorkspace).toHaveAttribute("data-dropdown-item", "true");
53
+ expect(currentWorkspace).toHaveAttribute("aria-current", "true");
54
+ expect(currentWorkspace).not.toHaveAttribute("role", "option");
55
+ });
56
+ });
@@ -82,7 +82,6 @@ export function SuiteWorkspaceSwitcher({
82
82
  open && "border-ph-brand/40",
83
83
  )}
84
84
  data-test="workspace-switcher-trigger"
85
- aria-expanded={open}
86
85
  >
87
86
  <Building className="h-4 w-4 shrink-0 text-ph-ink" aria-hidden />
88
87
  {!collapsed ? (
@@ -119,8 +118,9 @@ export function SuiteWorkspaceSwitcher({
119
118
  <DropdownItem
120
119
  key={workspace.id}
121
120
  id={`workspace-option-${workspace.id}`}
122
- role="option"
123
- aria-selected={workspace.id === currentId}
121
+ aria-current={
122
+ workspace.id === currentId ? "true" : undefined
123
+ }
124
124
  data-test="workspace-option"
125
125
  data-workspace-id={workspace.id}
126
126
  onMouseDown={(event) => event.preventDefault()}
@@ -8,7 +8,7 @@ import type { SuiteIconName } from "./glyphs";
8
8
  */
9
9
 
10
10
  export type SuiteAccentSlug =
11
- "turtletime" | "tides" | "kraken" | "shellstack" | "crew" | "nakama";
11
+ "turtletime" | "tides" | "kraken" | "shellstack" | "crew" | "shelly";
12
12
 
13
13
  export interface SuiteAppAccent {
14
14
  /** Display name (default aria label for app marks). */
@@ -47,10 +47,10 @@ export const APP_ACCENTS: Record<SuiteAccentSlug, SuiteAppAccent> = {
47
47
  },
48
48
  shellstack: {
49
49
  label: "ShellStack",
50
- accent: "#5e5893",
51
- tile: "#5e5893",
52
- onTile: "#ffffff",
53
- onTileAccent: "#c4a7e7",
50
+ accent: "#0078D4",
51
+ tile: "#FFFFFF",
52
+ onTile: "#111114",
53
+ onTileAccent: "#0078D4",
54
54
  },
55
55
  crew: {
56
56
  label: "Crew",
@@ -59,12 +59,12 @@ export const APP_ACCENTS: Record<SuiteAccentSlug, SuiteAppAccent> = {
59
59
  onTile: "#ffffff",
60
60
  onTileAccent: "#a5f3fc",
61
61
  },
62
- nakama: {
63
- label: "Nakama",
64
- accent: "#b8eb44",
65
- tile: "#17101f",
66
- onTile: "#fff2e2",
67
- onTileAccent: "#f54b87",
62
+ shelly: {
63
+ label: "Shelly",
64
+ accent: "#0078D4",
65
+ tile: "#1B1B1F",
66
+ onTile: "#ECECF1",
67
+ onTileAccent: "#00BCF2",
68
68
  },
69
69
  };
70
70
 
@@ -75,5 +75,5 @@ export const APP_GLYPHS: Record<SuiteAccentSlug, SuiteIconName> = {
75
75
  kraken: "squid-doc",
76
76
  shellstack: "stack-hex",
77
77
  crew: "crew-bot",
78
- nakama: "nakama-fist",
78
+ shelly: "shelly-snail",
79
79
  };