@xenide-io/the-old-ui-theme 0.3.2 → 0.4.3
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/dist/chunk-54ZR4VL5.mjs +4641 -0
- package/dist/chunk-G53YLHVE.mjs +1023 -0
- package/dist/{index-BgG8Homu.d.mts → index-B5NOyoKS.d.mts} +100 -100
- package/dist/{index-BgG8Homu.d.ts → index-B5NOyoKS.d.ts} +100 -100
- package/dist/index.d.mts +9 -145
- package/dist/index.d.ts +9 -145
- package/dist/index.js +653 -1111
- package/dist/index.mjs +86 -342
- package/dist/suite.d.mts +760 -0
- package/dist/suite.d.ts +760 -0
- package/dist/suite.js +2800 -0
- package/dist/suite.mjs +2441 -0
- package/dist/ui.d.mts +1 -2
- package/dist/ui.d.ts +1 -2
- package/dist/ui.js +14 -10
- package/dist/ui.mjs +13 -11
- package/package.json +43 -24
- package/src/components/icons/icons.tsx +0 -2
- package/src/components/icons/index.ts +0 -3
- package/src/components/sections/IconShowcase.tsx +1 -110
- package/src/components/sections/NewComponentsShowcase.tsx +4 -4
- package/src/components/sections/SidebarShowcase.tsx +3 -3
- package/src/components/sections/SuiteShowcase.tsx +669 -0
- package/src/components/ui/ComponentDocs.tsx +2 -2
- package/src/components/ui/ContextMenu.tsx +1 -1
- package/src/components/ui/Menubar.tsx +1 -1
- package/src/components/ui/Sidebar.tsx +10 -8
- package/src/styles/suite-skin.css +255 -0
- package/src/suite/components/ai-panel.tsx +202 -0
- package/src/suite/components/app-switcher.tsx +196 -0
- package/src/suite/components/command-palette-host.tsx +62 -0
- package/src/suite/components/deferred-chrome.tsx +12 -0
- package/src/suite/components/suite-app-layout.tsx +69 -0
- package/src/suite/components/suite-bottom-nav.tsx +113 -0
- package/src/suite/components/suite-layout.tsx +285 -0
- package/src/suite/components/suite-mobile-drawer.tsx +150 -0
- package/src/suite/components/suite-mobile-header.tsx +71 -0
- package/src/suite/components/suite-notification-bell.tsx +227 -0
- package/src/suite/components/suite-settings-mobile-nav.tsx +83 -0
- package/src/suite/components/suite-sidebar.tsx +198 -0
- package/src/suite/components/suite-skeleton.tsx +191 -0
- package/src/suite/components/suite-user-menu.tsx +109 -0
- package/src/suite/components/theme-provider.tsx +174 -0
- package/src/suite/components/today-calibrating.tsx +95 -0
- package/src/suite/components/today-page-frame.tsx +28 -0
- package/src/suite/components/today-ui.tsx +370 -0
- package/src/suite/icons/app-accents.ts +75 -0
- package/src/suite/icons/glyph-parts.tsx +67 -0
- package/src/suite/icons/glyphs.ts +203 -0
- package/src/suite/icons/index.ts +12 -0
- package/src/suite/icons/suite-app-icon.tsx +68 -0
- package/src/suite/icons/suite-icon.tsx +93 -0
- package/src/suite/index.ts +108 -0
- package/src/suite/lib/apps.ts +75 -0
- package/src/suite/lib/cn.ts +6 -0
- package/src/suite/lib/injected.ts +54 -0
- package/src/suite/lib/motion.tsx +48 -0
- package/src/suite/lib/use-idle-mount.ts +25 -0
- package/dist/chunk-5HSOBJ4F.mjs +0 -5968
- package/src/components/icons/lemon-brand-icons.tsx +0 -16
- package/src/components/icons/lemon-category-icons.tsx +0 -72
- package/src/components/icons/lemon-icons.tsx +0 -57
|
@@ -5,7 +5,7 @@ export interface ComponentPropRow {
|
|
|
5
5
|
name: string;
|
|
6
6
|
type: string;
|
|
7
7
|
defaultValue?: string;
|
|
8
|
-
description
|
|
8
|
+
description?: ReactNode;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export interface ComponentDocsProps {
|
|
@@ -41,7 +41,7 @@ export function ComponentDocs({ title = "Props & variants", rows, defaultOpen =
|
|
|
41
41
|
<td className="px-4 py-3 align-top font-mono text-xs text-ph-ink">{row.name}</td>
|
|
42
42
|
<td className="px-4 py-3 align-top font-mono text-xs text-ph-subtle">{row.type}</td>
|
|
43
43
|
<td className="px-4 py-3 align-top font-mono text-xs text-ph-subtle">{row.defaultValue ?? "-"}</td>
|
|
44
|
-
<td className="px-4 py-3 align-top text-ph-subtle">{row.description}</td>
|
|
44
|
+
<td className="px-4 py-3 align-top text-ph-subtle">{row.description ?? <span className="text-ph-mutedtext">—</span>}</td>
|
|
45
45
|
</tr>
|
|
46
46
|
))}
|
|
47
47
|
</tbody>
|
|
@@ -5,7 +5,7 @@ import { cn } from "@/lib/cn";
|
|
|
5
5
|
|
|
6
6
|
export interface MenubarItem {
|
|
7
7
|
label: string;
|
|
8
|
-
items?: { label
|
|
8
|
+
items?: { label?: string; onClick?: () => void; disabled?: boolean; separator?: boolean }[];
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export interface MenubarProps extends HTMLAttributes<HTMLDivElement> {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { type ReactNode
|
|
3
|
+
import { type ReactNode } from "react";
|
|
4
4
|
import { cn } from "@/lib/cn";
|
|
5
5
|
|
|
6
6
|
export interface SidebarGroup {
|
|
7
|
-
label
|
|
7
|
+
label?: string;
|
|
8
8
|
items: SidebarItemDef[];
|
|
9
9
|
}
|
|
10
10
|
|
|
@@ -22,18 +22,19 @@ export interface SidebarProps {
|
|
|
22
22
|
className?: string;
|
|
23
23
|
header?: ReactNode;
|
|
24
24
|
footer?: ReactNode;
|
|
25
|
+
children?: ReactNode;
|
|
25
26
|
collapsed?: boolean;
|
|
26
27
|
onCollapse?: (collapsed: boolean) => void;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
export function Sidebar({ groups, className, header, footer, collapsed = false, onCollapse }: SidebarProps) {
|
|
30
|
+
export function Sidebar({ groups, className, header, footer, children, collapsed = false, onCollapse }: SidebarProps) {
|
|
30
31
|
return (
|
|
31
32
|
<nav className={cn("ph-sidebar flex flex-col h-full", collapsed && "ph-sidebar--collapsed", className)}>
|
|
32
33
|
{header && <div className="ph-sidebar__header px-3 py-3 border-b border-ph-border-subtle">{header}</div>}
|
|
33
34
|
<div className="ph-sidebar__body flex-1 overflow-y-auto px-2 py-3 space-y-5">
|
|
34
35
|
{groups.map((group) => (
|
|
35
|
-
<div key={group.label} className="ph-sidebar__group">
|
|
36
|
-
{!collapsed && (
|
|
36
|
+
<div key={group.label ?? 'default'} className="ph-sidebar__group">
|
|
37
|
+
{group.label && !collapsed && (
|
|
37
38
|
<h3 className="ph-sidebar__group-label px-2 mb-1 text-[10px] font-semibold uppercase tracking-wider text-ph-mutedtext">
|
|
38
39
|
{group.label}
|
|
39
40
|
</h3>
|
|
@@ -46,17 +47,18 @@ export function Sidebar({ groups, className, header, footer, collapsed = false,
|
|
|
46
47
|
</div>
|
|
47
48
|
))}
|
|
48
49
|
</div>
|
|
50
|
+
{children}
|
|
49
51
|
{footer && <div className="ph-sidebar__footer px-3 py-3 border-t border-ph-border-subtle">{footer}</div>}
|
|
50
52
|
</nav>
|
|
51
53
|
);
|
|
52
54
|
}
|
|
53
55
|
|
|
54
|
-
function SidebarItem({ label, icon, badge, active, onClick, collapsed }: SidebarItemDef & { collapsed: boolean }) {
|
|
56
|
+
function SidebarItem({ label, icon, badge, active, href, onClick, collapsed }: SidebarItemDef & { collapsed: boolean }) {
|
|
55
57
|
const Comp = onClick ? "button" : "a";
|
|
56
58
|
return (
|
|
57
59
|
<Comp
|
|
58
60
|
type={onClick ? "button" as const : undefined}
|
|
59
|
-
href={onClick ? undefined :
|
|
61
|
+
href={onClick ? undefined : href}
|
|
60
62
|
onClick={onClick}
|
|
61
63
|
className={cn(
|
|
62
64
|
"ph-sidebar__item flex items-center gap-2.5 w-full rounded-md px-2.5 py-2 text-sm text-left transition",
|
|
@@ -67,7 +69,7 @@ function SidebarItem({ label, icon, badge, active, onClick, collapsed }: Sidebar
|
|
|
67
69
|
)}
|
|
68
70
|
title={collapsed ? label : undefined}
|
|
69
71
|
>
|
|
70
|
-
{icon && <span className="ph-sidebar__item-icon shrink-0 w-
|
|
72
|
+
{icon && <span className={cn("ph-sidebar__item-icon shrink-0 flex items-center justify-center", collapsed ? "w-5 h-5" : "w-4 h-4")}>{icon}</span>}
|
|
71
73
|
{!collapsed && <span className="ph-sidebar__item-label truncate flex-1">{label}</span>}
|
|
72
74
|
{!collapsed && badge && <span className="ph-sidebar__item-badge shrink-0">{badge}</span>}
|
|
73
75
|
</Comp>
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
2
|
+
Suite skin — the shared tactile layer for every ShellStack app.
|
|
3
|
+
Source of truth: the-old-ui/src/styles/suite-skin.css (shipped inside
|
|
4
|
+
`@xenide-io/the-old-ui-theme`).
|
|
5
|
+
|
|
6
|
+
Everything is token-driven (`--ph-*`), so each app's own theme keeps its
|
|
7
|
+
colours while radius, elevation, motion, and touch behaviour stay
|
|
8
|
+
identical across the suite. Import AFTER the theme globals: unlayered
|
|
9
|
+
rules outrank the theme's @layer rules in the cascade, so these win.
|
|
10
|
+
(No @layer blocks here — Tailwind rejects them in imported files.)
|
|
11
|
+
───────────────────────────────────────────────────────────────────────── */
|
|
12
|
+
|
|
13
|
+
html {
|
|
14
|
+
/* Native-app touch: no grey flash on tap, no double-tap delay. */
|
|
15
|
+
-webkit-tap-highlight-color: transparent;
|
|
16
|
+
/* App-like vertical scrolling: no rubber-band chaining into the page. */
|
|
17
|
+
overscroll-behavior-y: none;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
body {
|
|
21
|
+
/* OpenRunde optical tweaks — crisper on high-dpi mobile/tablet. */
|
|
22
|
+
text-rendering: optimizeLegibility;
|
|
23
|
+
-webkit-font-smoothing: antialiased;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* ── Cards ──────────────────────────────────────────────────────────────
|
|
27
|
+
The MASTER spec: 12px radius, resting elevation, hover lift, and a
|
|
28
|
+
visible press state. Shadows are mixed from the theme's text colour
|
|
29
|
+
so they adapt to light AND dark themes instead of going muddy. */
|
|
30
|
+
.ph-card {
|
|
31
|
+
border-radius: var(--suite-radius-card, 12px);
|
|
32
|
+
border-color: var(--ph-border-subtle);
|
|
33
|
+
background: var(--ph-surface-raised);
|
|
34
|
+
box-shadow:
|
|
35
|
+
0 1px 2px color-mix(in oklab, var(--ph-text-primary) 6%, transparent),
|
|
36
|
+
0 1px 1px color-mix(in oklab, var(--ph-text-primary) 4%, transparent);
|
|
37
|
+
transition:
|
|
38
|
+
border-color 160ms cubic-bezier(0.4, 0, 0.2, 1),
|
|
39
|
+
box-shadow 160ms cubic-bezier(0.4, 0, 0.2, 1),
|
|
40
|
+
transform 160ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.ph-card-interactive:hover,
|
|
44
|
+
a:hover > .ph-card,
|
|
45
|
+
button:hover > .ph-card {
|
|
46
|
+
border-color: var(--ph-border-control);
|
|
47
|
+
box-shadow:
|
|
48
|
+
0 2px 4px color-mix(in oklab, var(--ph-text-primary) 7%, transparent),
|
|
49
|
+
0 8px 20px color-mix(in oklab, var(--ph-text-primary) 8%, transparent);
|
|
50
|
+
transform: translateY(-1px);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* Press: physical "push in" — the single biggest native-feel signal. */
|
|
54
|
+
.suite-press {
|
|
55
|
+
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
56
|
+
}
|
|
57
|
+
.suite-press:active {
|
|
58
|
+
transform: scale(0.98);
|
|
59
|
+
}
|
|
60
|
+
.suite-press-card:active .ph-card,
|
|
61
|
+
.ph-card.suite-press:active {
|
|
62
|
+
transform: scale(0.985);
|
|
63
|
+
transition-duration: 80ms;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* ── App bar (mobile) / top bar (desktop) ───────────────────────────────
|
|
67
|
+
Translucent, blurred, content scrolls beneath — like every native
|
|
68
|
+
shell. Colour-mix keeps it on-theme in both modes. */
|
|
69
|
+
.suite-app-bar {
|
|
70
|
+
background: color-mix(in oklab, var(--ph-canvas) 82%, transparent);
|
|
71
|
+
-webkit-backdrop-filter: blur(12px) saturate(1.4);
|
|
72
|
+
backdrop-filter: blur(12px) saturate(1.4);
|
|
73
|
+
padding-top: env(safe-area-inset-top);
|
|
74
|
+
}
|
|
75
|
+
@supports not (backdrop-filter: blur(1px)) {
|
|
76
|
+
.suite-app-bar {
|
|
77
|
+
background: var(--ph-canvas);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* ── Bottom navigation ────────────────────────────────────────────────── */
|
|
82
|
+
/* Fixed to the viewport bottom — never in flow, so browser-chrome resize
|
|
83
|
+
(URL bar collapse) and page transitions can never move it. A spacer in
|
|
84
|
+
the layout reserves its height (see component). */
|
|
85
|
+
.suite-bottom-nav {
|
|
86
|
+
position: fixed;
|
|
87
|
+
inset-inline: 0;
|
|
88
|
+
bottom: 0;
|
|
89
|
+
background: color-mix(in oklab, var(--ph-canvas) 86%, transparent);
|
|
90
|
+
-webkit-backdrop-filter: blur(12px) saturate(1.4);
|
|
91
|
+
backdrop-filter: blur(12px) saturate(1.4);
|
|
92
|
+
padding-bottom: env(safe-area-inset-bottom);
|
|
93
|
+
box-shadow: 0 -1px 0 var(--ph-border-subtle);
|
|
94
|
+
}
|
|
95
|
+
@supports not (backdrop-filter: blur(1px)) {
|
|
96
|
+
.suite-bottom-nav {
|
|
97
|
+
background: var(--ph-canvas);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* Press feedback: the glyph dips, like a native tab bar. */
|
|
102
|
+
.suite-bottom-nav__item svg {
|
|
103
|
+
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
104
|
+
}
|
|
105
|
+
.suite-bottom-nav__item:active svg {
|
|
106
|
+
transform: scale(0.88);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* Active indicator: one soft pill that slides behind the whole active tab —
|
|
110
|
+
icon AND label — so the selection reads as a single centred unit. The
|
|
111
|
+
track is one grid column wide and moves with translateX (GPU-composited,
|
|
112
|
+
no layout work); the visible pill is inset inside the track. Material 3 /
|
|
113
|
+
iOS-style navigation-bar pattern using the same accent tokens as the
|
|
114
|
+
sidebar's active link. */
|
|
115
|
+
.suite-bottom-nav__indicator {
|
|
116
|
+
position: absolute;
|
|
117
|
+
top: 4px;
|
|
118
|
+
bottom: calc(4px + env(safe-area-inset-bottom));
|
|
119
|
+
left: 0;
|
|
120
|
+
width: calc(100% / var(--suite-nav-items, 5));
|
|
121
|
+
transform: translateX(calc(var(--suite-nav-active, 0) * 100%));
|
|
122
|
+
transition: transform 200ms cubic-bezier(0.2, 0.9, 0.3, 1);
|
|
123
|
+
pointer-events: none;
|
|
124
|
+
z-index: 0;
|
|
125
|
+
will-change: transform;
|
|
126
|
+
}
|
|
127
|
+
.suite-bottom-nav__indicator::before {
|
|
128
|
+
content: '';
|
|
129
|
+
position: absolute;
|
|
130
|
+
inset: 0 5px;
|
|
131
|
+
border-radius: 0.9rem;
|
|
132
|
+
background: var(--ph-accent-highlight-secondary);
|
|
133
|
+
}
|
|
134
|
+
.suite-bottom-nav__icon {
|
|
135
|
+
transition: color 160ms ease-out;
|
|
136
|
+
}
|
|
137
|
+
.suite-bottom-nav__icon--active {
|
|
138
|
+
color: var(--ph-accent);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* ── Today hero: one-shot typography entrance ───────────────────────────
|
|
142
|
+
Rises and settles when the greeting text arrives (keyed by message).
|
|
143
|
+
Runs once per message change — never loops, so no battery cost. */
|
|
144
|
+
.today-hero {
|
|
145
|
+
animation: today-hero-enter 560ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
|
146
|
+
}
|
|
147
|
+
@keyframes today-hero-enter {
|
|
148
|
+
from {
|
|
149
|
+
opacity: 0;
|
|
150
|
+
transform: translateY(10px);
|
|
151
|
+
letter-spacing: 0.01em;
|
|
152
|
+
}
|
|
153
|
+
to {
|
|
154
|
+
opacity: 1;
|
|
155
|
+
transform: translateY(0);
|
|
156
|
+
letter-spacing: normal;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
@media (prefers-reduced-motion: reduce) {
|
|
160
|
+
.today-hero {
|
|
161
|
+
animation: none;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* ── Today frame: mobile perf ───────────────────────────────────────────
|
|
166
|
+
blur-3xl + infinite pulse is a GPU/battery hog on phones. Keep the
|
|
167
|
+
atmosphere on tablet/desktop; flatten it on small screens. */
|
|
168
|
+
@media (max-width: 767px) {
|
|
169
|
+
.today-page-frame__sun {
|
|
170
|
+
filter: none;
|
|
171
|
+
opacity: 0.28;
|
|
172
|
+
animation: none;
|
|
173
|
+
}
|
|
174
|
+
.today-page-frame__cloud {
|
|
175
|
+
display: none;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* ── Mobile bottom sheets ───────────────────────────────────────────────
|
|
180
|
+
Below lg, every ph-modal becomes a sheet pinned to the bottom edge —
|
|
181
|
+
the native phone/tablet pattern (Apple HIG, Material 3). Desktop keeps
|
|
182
|
+
the centred dialog. The overlay's p-4 is removed so the sheet truly
|
|
183
|
+
touches the screen edges; a grabber handle and safe-area padding come
|
|
184
|
+
free for every modal in every app. */
|
|
185
|
+
@media (max-width: 1023.98px) {
|
|
186
|
+
.ph-modal-overlay {
|
|
187
|
+
align-items: flex-end !important;
|
|
188
|
+
padding: 0 !important;
|
|
189
|
+
}
|
|
190
|
+
.ph-modal-panel {
|
|
191
|
+
width: 100% !important;
|
|
192
|
+
max-width: 100% !important;
|
|
193
|
+
margin: 0 !important;
|
|
194
|
+
max-height: calc(100svh - 3rem) !important;
|
|
195
|
+
border-radius: 16px 16px 0 0 !important;
|
|
196
|
+
padding-bottom: env(safe-area-inset-bottom);
|
|
197
|
+
}
|
|
198
|
+
/* Grabber — first in-flow child, so it sits above flush header rows. */
|
|
199
|
+
.ph-modal-panel::before {
|
|
200
|
+
content: '';
|
|
201
|
+
display: block;
|
|
202
|
+
flex: none;
|
|
203
|
+
width: 36px;
|
|
204
|
+
height: 4px;
|
|
205
|
+
border-radius: 999px;
|
|
206
|
+
background: color-mix(in oklab, var(--ph-text-primary) 18%, transparent);
|
|
207
|
+
margin: 8px auto 0;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* Enter/exit motion is driven by the Modal component (inline transitions)
|
|
212
|
+
so close animations work too — no CSS keyframes here. */
|
|
213
|
+
|
|
214
|
+
/* ── Bottom action dock ─────────────────────────────────────────────────
|
|
215
|
+
The thumb-zone command bar that sits directly above the bottom nav
|
|
216
|
+
(quick-start, mini-player). Same translucent material as the nav so
|
|
217
|
+
the two read as one strip of chrome. */
|
|
218
|
+
.suite-dock {
|
|
219
|
+
background: color-mix(in oklab, var(--ph-canvas) 86%, transparent);
|
|
220
|
+
-webkit-backdrop-filter: blur(12px) saturate(1.4);
|
|
221
|
+
backdrop-filter: blur(12px) saturate(1.4);
|
|
222
|
+
box-shadow: 0 -1px 0 var(--ph-border-subtle);
|
|
223
|
+
}
|
|
224
|
+
@supports not (backdrop-filter: blur(1px)) {
|
|
225
|
+
.suite-dock {
|
|
226
|
+
background: var(--ph-canvas);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* ── Drawers / sheets never rubber-band their own scroll ──────────────── */
|
|
231
|
+
.suite-scroll-lock {
|
|
232
|
+
overscroll-behavior: contain;
|
|
233
|
+
-webkit-overflow-scrolling: touch;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/* ── Reduced motion: every skin animation honours it ──────────────────── */
|
|
237
|
+
@media (prefers-reduced-motion: reduce) {
|
|
238
|
+
.ph-card,
|
|
239
|
+
.suite-press,
|
|
240
|
+
.suite-bottom-nav__item svg,
|
|
241
|
+
.suite-bottom-nav__indicator,
|
|
242
|
+
.suite-bottom-nav__icon {
|
|
243
|
+
transition-duration: 0.01ms;
|
|
244
|
+
}
|
|
245
|
+
.suite-press:active,
|
|
246
|
+
.ph-card.suite-press:active,
|
|
247
|
+
.suite-press-card:active .ph-card {
|
|
248
|
+
transform: none;
|
|
249
|
+
}
|
|
250
|
+
/* Modal motion is inline (component-driven) — beat it with !important. */
|
|
251
|
+
.ph-modal-overlay,
|
|
252
|
+
.ph-modal-panel {
|
|
253
|
+
transition: none !important;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, type ComponentType, type ReactNode } from 'react';
|
|
4
|
+
import { Check, Copy, Xmark as X } from 'iconoir-react';
|
|
5
|
+
|
|
6
|
+
export interface SuiteAiPreset {
|
|
7
|
+
label: string;
|
|
8
|
+
prompt: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Thin ShellStack AI slide-over shared by suite apps.
|
|
13
|
+
*
|
|
14
|
+
* One-shot completions via the shared `/api/ai/complete` endpoint. Apps
|
|
15
|
+
* inject their API helpers, preset prompts, brand icon and spinner. Renders
|
|
16
|
+
* its own floating launcher so it can be mounted once in the authenticated
|
|
17
|
+
* app shell.
|
|
18
|
+
*/
|
|
19
|
+
export function SuiteAiPanel({
|
|
20
|
+
presets,
|
|
21
|
+
emptyState,
|
|
22
|
+
fetchStatus,
|
|
23
|
+
complete,
|
|
24
|
+
brandIcon: BrandIcon,
|
|
25
|
+
spinner: Spinner,
|
|
26
|
+
}: {
|
|
27
|
+
presets: SuiteAiPreset[];
|
|
28
|
+
/** Idle-state hint copy shown before the first request. */
|
|
29
|
+
emptyState: ReactNode;
|
|
30
|
+
fetchStatus: () => Promise<{ configured: boolean }>;
|
|
31
|
+
complete: (params: { prompt: string }) => Promise<{ text: string }>;
|
|
32
|
+
brandIcon: ComponentType<{ className?: string }>;
|
|
33
|
+
spinner: ComponentType<{ className?: string }>;
|
|
34
|
+
}) {
|
|
35
|
+
const [open, setOpen] = useState(false);
|
|
36
|
+
const [prompt, setPrompt] = useState('');
|
|
37
|
+
const [loading, setLoading] = useState(false);
|
|
38
|
+
const [answer, setAnswer] = useState('');
|
|
39
|
+
const [error, setError] = useState('');
|
|
40
|
+
const [copied, setCopied] = useState(false);
|
|
41
|
+
|
|
42
|
+
async function ask(text: string) {
|
|
43
|
+
const trimmed = text.trim();
|
|
44
|
+
if (!trimmed || loading) return;
|
|
45
|
+
setLoading(true);
|
|
46
|
+
setError('');
|
|
47
|
+
setAnswer('');
|
|
48
|
+
setCopied(false);
|
|
49
|
+
try {
|
|
50
|
+
const status = await fetchStatus();
|
|
51
|
+
if (!status.configured) {
|
|
52
|
+
setError('ShellStack AI is not configured for this workspace yet.');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const result = await complete({ prompt: trimmed });
|
|
56
|
+
setAnswer(result.text);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
setError(
|
|
59
|
+
err instanceof Error
|
|
60
|
+
? err.message
|
|
61
|
+
: 'The AI request failed. Try again.',
|
|
62
|
+
);
|
|
63
|
+
} finally {
|
|
64
|
+
setLoading(false);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function copyAnswer() {
|
|
69
|
+
if (!answer) return;
|
|
70
|
+
try {
|
|
71
|
+
await navigator.clipboard.writeText(answer);
|
|
72
|
+
setCopied(true);
|
|
73
|
+
window.setTimeout(() => setCopied(false), 1500);
|
|
74
|
+
} catch {
|
|
75
|
+
// Clipboard unavailable; ignore.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<>
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
data-test="ai-panel-launcher"
|
|
84
|
+
onClick={() => setOpen(true)}
|
|
85
|
+
className="fixed bottom-5 right-5 z-30 flex items-center gap-2 rounded-full border border-ph-border bg-ph-surface px-4 py-2.5 text-sm font-medium text-ph-ink shadow-ph hover:bg-ph-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-brand"
|
|
86
|
+
aria-label="Open ShellStack AI"
|
|
87
|
+
>
|
|
88
|
+
<BrandIcon className="h-4 w-4 text-ph-brand" />
|
|
89
|
+
Ask AI
|
|
90
|
+
</button>
|
|
91
|
+
|
|
92
|
+
{open && (
|
|
93
|
+
<div className="fixed inset-0 z-40" role="dialog" aria-modal="true">
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
className="absolute inset-0 bg-black/25"
|
|
97
|
+
onClick={() => setOpen(false)}
|
|
98
|
+
aria-label="Close AI panel"
|
|
99
|
+
/>
|
|
100
|
+
<aside className="absolute inset-y-0 right-0 flex w-full max-w-md flex-col border-l border-ph-border bg-ph-surface shadow-2xl">
|
|
101
|
+
<div className="flex items-center justify-between border-b border-ph-border px-4 py-3">
|
|
102
|
+
<div className="flex items-center gap-2">
|
|
103
|
+
<BrandIcon className="h-4 w-4 text-ph-brand" />
|
|
104
|
+
<span className="text-sm font-semibold text-ph-ink">
|
|
105
|
+
ShellStack AI
|
|
106
|
+
</span>
|
|
107
|
+
</div>
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
onClick={() => setOpen(false)}
|
|
111
|
+
className="rounded p-1 hover:bg-ph-muted"
|
|
112
|
+
aria-label="Close"
|
|
113
|
+
>
|
|
114
|
+
<X className="h-4 w-4" />
|
|
115
|
+
</button>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div className="flex flex-wrap gap-1.5 border-b border-ph-border px-4 py-2">
|
|
119
|
+
{presets.map((preset) => (
|
|
120
|
+
<button
|
|
121
|
+
key={preset.label}
|
|
122
|
+
type="button"
|
|
123
|
+
onClick={() => {
|
|
124
|
+
setPrompt(preset.prompt);
|
|
125
|
+
void ask(preset.prompt);
|
|
126
|
+
}}
|
|
127
|
+
disabled={loading}
|
|
128
|
+
className="rounded-md border border-ph-border px-2 py-1 text-xs text-ph-ink hover:bg-ph-muted disabled:opacity-50"
|
|
129
|
+
>
|
|
130
|
+
{preset.label}
|
|
131
|
+
</button>
|
|
132
|
+
))}
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<div className="flex-1 space-y-3 overflow-y-auto p-4">
|
|
136
|
+
{error && (
|
|
137
|
+
<p className="bg-ph-danger/10 rounded-lg px-3 py-2 text-sm text-ph-danger">
|
|
138
|
+
{error}
|
|
139
|
+
</p>
|
|
140
|
+
)}
|
|
141
|
+
{loading && (
|
|
142
|
+
<div className="flex items-center gap-2 text-sm text-ph-mutedtext">
|
|
143
|
+
<Spinner className="h-4 w-4 animate-spin" />
|
|
144
|
+
Thinking…
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
{answer && !loading && (
|
|
148
|
+
<div className="rounded-lg bg-ph-muted px-3 py-2 text-sm text-ph-ink">
|
|
149
|
+
<p className="whitespace-pre-wrap">{answer}</p>
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
onClick={() => void copyAnswer()}
|
|
153
|
+
className="mt-2 inline-flex items-center gap-1 text-xs text-ph-mutedtext hover:text-ph-ink"
|
|
154
|
+
>
|
|
155
|
+
{copied ? (
|
|
156
|
+
<Check className="h-3 w-3" />
|
|
157
|
+
) : (
|
|
158
|
+
<Copy className="h-3 w-3" />
|
|
159
|
+
)}
|
|
160
|
+
{copied ? 'Copied' : 'Copy'}
|
|
161
|
+
</button>
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
{!answer && !loading && !error && (
|
|
165
|
+
<p className="mt-8 text-center text-xs text-ph-mutedtext">
|
|
166
|
+
{emptyState}
|
|
167
|
+
</p>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<div className="border-t border-ph-border p-3">
|
|
172
|
+
<div className="flex gap-2">
|
|
173
|
+
<textarea
|
|
174
|
+
value={prompt}
|
|
175
|
+
onChange={(e) => setPrompt(e.target.value)}
|
|
176
|
+
onKeyDown={(e) => {
|
|
177
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
178
|
+
e.preventDefault();
|
|
179
|
+
void ask(prompt);
|
|
180
|
+
}
|
|
181
|
+
}}
|
|
182
|
+
placeholder="Ask anything…"
|
|
183
|
+
rows={2}
|
|
184
|
+
disabled={loading}
|
|
185
|
+
className="flex-1 resize-none rounded-lg border border-ph-border bg-ph-canvas p-2 text-sm text-ph-ink outline-none focus:ring-1 focus:ring-ph-brand"
|
|
186
|
+
/>
|
|
187
|
+
<button
|
|
188
|
+
type="button"
|
|
189
|
+
onClick={() => void ask(prompt)}
|
|
190
|
+
disabled={loading || !prompt.trim()}
|
|
191
|
+
className="self-end rounded-lg bg-ph-brand px-3 py-2 text-sm font-medium text-white disabled:opacity-50"
|
|
192
|
+
>
|
|
193
|
+
Ask
|
|
194
|
+
</button>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
</aside>
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
</>
|
|
201
|
+
);
|
|
202
|
+
}
|