@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.
- package/README.md +9 -10
- package/dist/{Chip-Cq3AiAji.d.mts → Chip-BzuNgJIa.d.mts} +81 -23
- package/dist/{Chip-Cq3AiAji.d.ts → Chip-BzuNgJIa.d.ts} +81 -23
- package/dist/{ai-message-blocknote-WABTHFY7.mjs → ai-message-blocknote-D4Z6DUCJ.mjs} +9 -3
- package/dist/chunk-2EO5VCXP.mjs +3912 -0
- package/dist/chunk-APKRZQRO.mjs +251 -0
- package/dist/chunk-BS7BZI5W.mjs +19 -0
- package/dist/chunk-IULORI3A.mjs +8 -0
- package/dist/index.d.mts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +997 -576
- package/dist/index.mjs +49 -45
- package/dist/suite.d.mts +157 -55
- package/dist/suite.d.ts +157 -55
- package/dist/suite.js +4249 -801
- package/dist/suite.mjs +2578 -594
- package/dist/theme-boot-P-oqKVZF.d.mts +74 -0
- package/dist/theme-boot-P-oqKVZF.d.ts +74 -0
- package/dist/theme-boot.d.mts +2 -0
- package/dist/theme-boot.d.ts +2 -0
- package/dist/theme-boot.js +44 -0
- package/dist/theme-boot.mjs +8 -0
- package/dist/ui.d.mts +5 -29
- package/dist/ui.d.ts +5 -29
- package/dist/ui.js +647 -352
- package/dist/ui.mjs +10 -12
- package/docs/README.md +34 -0
- package/docs/ai-quick-reference.md +56 -0
- package/docs/component-library-improvement-plan.md +751 -0
- package/docs/component-reference.md +81 -0
- package/docs/components.md +48 -0
- package/docs/demos.md +19 -0
- package/docs/installation.md +117 -0
- package/docs/suite-audit.md +34 -0
- package/docs/theme-token-reference.md +67 -0
- package/docs/themes.md +164 -0
- package/package.json +22 -21
- package/public/fonts/OpenRunde-Bold.woff2 +0 -0
- package/public/fonts/OpenRunde-Medium.woff2 +0 -0
- package/public/fonts/OpenRunde-Regular.woff2 +0 -0
- package/public/fonts/OpenRunde-Semibold.woff2 +0 -0
- package/scripts/package-smoke.mjs +43 -0
- package/src/app/globals.css +2 -1
- package/src/components/ui/Accordion.tsx +20 -6
- package/src/components/ui/Avatar.tsx +5 -3
- package/src/components/ui/Calendar.tsx +187 -63
- package/src/components/ui/Card.test.tsx +38 -0
- package/src/components/ui/Card.tsx +77 -14
- package/src/components/ui/CollapsibleSection.tsx +12 -4
- package/src/components/ui/CommandPalette.test.tsx +91 -4
- package/src/components/ui/CommandPalette.tsx +138 -128
- package/src/components/ui/EmptyState.tsx +2 -2
- package/src/components/ui/FormField.tsx +1 -1
- package/src/components/ui/Input.tsx +42 -18
- package/src/components/ui/Kbd.tsx +18 -3
- package/src/components/ui/Modal.tsx +3 -1
- package/src/components/ui/Progress.tsx +14 -5
- package/src/components/ui/SegmentedControl.tsx +2 -2
- package/src/components/ui/SettingsLayout.tsx +1 -1
- package/src/components/ui/Stat.tsx +3 -2
- package/src/components/ui/Table.tsx +8 -3
- package/src/components/ui/Tooltip.test.tsx +70 -1
- package/src/components/ui/Tooltip.tsx +53 -29
- package/src/components/ui/Typography.tsx +103 -49
- package/src/components/ui/avatar-stat.test.tsx +23 -0
- package/src/components/ui/calendar.test.tsx +88 -0
- package/src/components/ui/disclosure.test.tsx +39 -0
- package/src/components/ui/index.ts +44 -6
- package/src/components/ui/progress.test.tsx +15 -0
- package/src/components/ui/typography.test.tsx +26 -1
- package/src/styles/suite-skin.css +8 -2
- package/src/styles/themes.css +184 -68
- package/src/suite/components/ai-message-blocknote.tsx +13 -2
- package/src/suite/components/ai-panel.tsx +41 -10
- package/src/suite/components/app-switcher.test.tsx +21 -6
- package/src/suite/components/app-switcher.tsx +7 -11
- package/src/suite/components/suite-app-layout.tsx +12 -3
- package/src/suite/components/suite-bottom-nav.tsx +0 -1
- package/src/suite/components/suite-clients-projects-directory.test.tsx +71 -0
- package/src/suite/components/suite-clients-projects-directory.tsx +943 -0
- package/src/suite/components/suite-integration-picker.test.tsx +224 -0
- package/src/suite/components/suite-integration-picker.tsx +939 -0
- package/src/suite/components/suite-integration-rules.test.tsx +157 -0
- package/src/suite/components/suite-integration-rules.tsx +471 -0
- package/src/suite/components/suite-mobile-drawer.test.tsx +18 -2
- package/src/suite/components/suite-mobile-drawer.tsx +65 -98
- package/src/suite/components/suite-notification-bell.tsx +29 -4
- package/src/suite/components/suite-sidebar.tsx +25 -25
- package/src/suite/components/suite-skeleton.tsx +2 -2
- package/src/suite/components/suite-user-menu.tsx +9 -8
- package/src/suite/components/theme-provider.test.tsx +87 -3
- package/src/suite/components/theme-provider.tsx +144 -23
- package/src/suite/components/today-ui.tsx +79 -68
- package/src/suite/components/workspace-switcher.test.tsx +56 -0
- package/src/suite/components/workspace-switcher.tsx +3 -3
- package/src/suite/icons/app-accents.ts +12 -12
- package/src/suite/icons/glyphs.ts +6 -12
- package/src/suite/index.ts +27 -0
- package/src/suite/lib/apps.test.ts +9 -1
- package/src/suite/lib/apps.ts +14 -8
- package/src/suite/lib/cookies.ts +53 -0
- package/src/suite/lib/theme-boot.ts +32 -0
- package/src/suite/lib/theme-cookie.ts +7 -0
- package/src/suite/lib/use-sidebar-width.ts +5 -51
- package/dist/chunk-KDR6HI6F.mjs +0 -1075
- package/dist/chunk-NOI42JNZ.mjs +0 -151
- package/dist/chunk-VXISSXTB.mjs +0 -2564
- package/src/suite/lib/use-lock-mobile-page-zoom.test.tsx +0 -65
- 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 {
|
|
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
|
|
88
|
-
if (
|
|
89
|
-
|
|
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
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
import { Refresh as RefreshCw } from
|
|
4
|
-
import type { ReactNode } from
|
|
5
|
-
import { cn } from
|
|
6
|
-
import { SuiteAppIcon } from
|
|
7
|
-
import { SuiteIcon } from
|
|
8
|
-
|
|
9
|
-
|
|
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 =
|
|
12
|
+
export type SuiteAppSlug =
|
|
13
|
+
"shellstack" | "tides" | "turtletime" | "kraken" | "shelly";
|
|
14
14
|
|
|
15
15
|
const APP_META: Record<SuiteAppSlug, { name: string }> = {
|
|
16
|
-
shellstack: { name:
|
|
17
|
-
tides: { name:
|
|
18
|
-
turtletime: { name:
|
|
19
|
-
kraken: { name:
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
"shellstack",
|
|
25
|
+
"tides",
|
|
26
|
+
"turtletime",
|
|
27
|
+
"kraken",
|
|
28
|
+
"shelly",
|
|
27
29
|
];
|
|
28
30
|
|
|
29
31
|
export function sourceToSuiteApp(source: string): SuiteAppSlug {
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
|
41
|
-
shellstack:
|
|
42
|
-
tides:
|
|
43
|
-
turtletime:
|
|
44
|
-
kraken:
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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:
|
|
71
|
-
day:
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
241
|
-
if (hour >= 12 && hour < 17) period =
|
|
242
|
-
else if (hour >= 17 && hour < 21) period =
|
|
243
|
-
else if (hour >= 21 || hour < 5) period =
|
|
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(
|
|
248
|
-
user?.email?.split(
|
|
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 ?
|
|
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 ?
|
|
270
|
+
return `${greeting}. ${taskCount} task${taskCount === 1 ? "" : "s"} due today.`;
|
|
263
271
|
}
|
|
264
272
|
if (hours > 0) {
|
|
265
|
-
return `${greeting}. ${hours} hour${hours === 1 ?
|
|
273
|
+
return `${greeting}. ${hours} hour${hours === 1 ? "" : "s"} logged today.`;
|
|
266
274
|
}
|
|
267
|
-
if (period ===
|
|
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 =
|
|
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 ===
|
|
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
|
-
|
|
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 =
|
|
322
|
+
accent = "neutral",
|
|
315
323
|
onClick,
|
|
316
324
|
href,
|
|
317
325
|
loading = false,
|
|
318
326
|
active = false,
|
|
319
327
|
className,
|
|
320
|
-
dataTest =
|
|
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
|
-
|
|
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 &&
|
|
338
|
-
loading &&
|
|
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(
|
|
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 =
|
|
477
|
-
description =
|
|
478
|
-
cta =
|
|
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
|
-
|
|
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
|
-
<
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
568
|
-
iconClassName ??
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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" | "
|
|
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: "#
|
|
51
|
-
tile: "#
|
|
52
|
-
onTile: "#
|
|
53
|
-
onTileAccent: "#
|
|
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
|
-
|
|
63
|
-
label: "
|
|
64
|
-
accent: "#
|
|
65
|
-
tile: "#
|
|
66
|
-
onTile: "#
|
|
67
|
-
onTileAccent: "#
|
|
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
|
-
|
|
78
|
+
shelly: "shelly-snail",
|
|
79
79
|
};
|