@xenide-io/the-old-ui-theme 0.4.6 → 0.4.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xenide-io/the-old-ui-theme",
3
- "version": "0.4.6",
3
+ "version": "0.4.9",
4
4
  "description": "Props-driven React components and theme tokens inspired by classic interface design — optimized for Next.js apps and internal tools.",
5
5
  "author": "Xenide",
6
6
  "license": "MIT",
@@ -131,6 +131,8 @@
131
131
  "access": "public"
132
132
  },
133
133
  "dependencies": {
134
+ "@blocknote/core": "0.52.1",
135
+ "@blocknote/react": "0.52.1",
134
136
  "@radix-ui/react-dialog": "^1.1.23",
135
137
  "@radix-ui/react-dropdown-menu": "^2.1.24",
136
138
  "clsx": "^2.1.1",
@@ -475,6 +475,28 @@ button:hover > .ph-card {
475
475
  -webkit-overflow-scrolling: touch;
476
476
  }
477
477
 
478
+ /* ── Ask AI answer content ───────────────────────────────────────────── */
479
+ .suite-ai-blocknote .bn-editor {
480
+ min-height: 0;
481
+ padding: 0;
482
+ background: transparent;
483
+ color: var(--ph-text-primary);
484
+ font-family: inherit;
485
+ font-size: 0.875rem;
486
+ }
487
+ .suite-ai-blocknote .bn-block-content {
488
+ padding-block: 2px;
489
+ }
490
+ .suite-ai-blocknote [data-content-type="heading"] {
491
+ padding-top: 8px;
492
+ }
493
+ .suite-ai-blocknote [data-content-type="codeBlock"] {
494
+ overflow-x: auto;
495
+ border: 1px solid var(--ph-border);
496
+ border-radius: 6px;
497
+ background: var(--ph-surface);
498
+ }
499
+
478
500
  /* ── Auth / OAuth / launch handoff ──────────────────────────────────────
479
501
  One calm opacity fade — no stacked entrance reveals on bridge pages. */
480
502
  .suite-auth-handoff {
@@ -0,0 +1,40 @@
1
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import SuiteAiBlockNoteMessage from "./ai-message-blocknote";
5
+ import { SuiteThemeProvider } from "./theme-provider";
6
+
7
+ const themeConfig = {
8
+ storageKey: "suite-ai-test-theme",
9
+ lightThemeId: "test-light",
10
+ darkThemeId: "test-dark",
11
+ fallbackTheme: "light" as const,
12
+ };
13
+
14
+ describe("SuiteAiBlockNoteMessage", () => {
15
+ beforeEach(() => {
16
+ vi.stubGlobal("localStorage", {
17
+ getItem: () => "light",
18
+ setItem: vi.fn(),
19
+ });
20
+ });
21
+ afterEach(() => {
22
+ cleanup();
23
+ vi.unstubAllGlobals();
24
+ });
25
+
26
+ it("renders Markdown as restricted read-only BlockNote content", async () => {
27
+ const { container } = render(
28
+ <SuiteThemeProvider config={themeConfig}>
29
+ <SuiteAiBlockNoteMessage markdown={"## Summary\n\n- First item"} />
30
+ </SuiteThemeProvider>,
31
+ );
32
+
33
+ await waitFor(() =>
34
+ expect(screen.getByText("Summary")).toBeInTheDocument(),
35
+ );
36
+ expect(screen.getByText("First item")).toBeInTheDocument();
37
+ expect(container.querySelector('[contenteditable="false"]')).toBeTruthy();
38
+ expect(container.querySelector("img")).toBeNull();
39
+ });
40
+ });
@@ -0,0 +1,61 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+ import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core";
5
+ import { BlockNoteViewRaw, useCreateBlockNote } from "@blocknote/react";
6
+ import "@blocknote/react/style.css";
7
+
8
+ import { useSuiteTheme } from "./theme-provider";
9
+
10
+ const assistantSchema = BlockNoteSchema.create({
11
+ blockSpecs: {
12
+ paragraph: defaultBlockSpecs.paragraph,
13
+ heading: defaultBlockSpecs.heading,
14
+ bulletListItem: defaultBlockSpecs.bulletListItem,
15
+ numberedListItem: defaultBlockSpecs.numberedListItem,
16
+ checkListItem: defaultBlockSpecs.checkListItem,
17
+ codeBlock: defaultBlockSpecs.codeBlock,
18
+ quote: defaultBlockSpecs.quote,
19
+ table: defaultBlockSpecs.table,
20
+ divider: defaultBlockSpecs.divider,
21
+ },
22
+ });
23
+
24
+ export default function SuiteAiBlockNoteMessage({
25
+ markdown,
26
+ }: {
27
+ markdown: string;
28
+ }) {
29
+ const { resolvedTheme } = useSuiteTheme();
30
+ const editor = useCreateBlockNote({ schema: assistantSchema });
31
+
32
+ useEffect(() => {
33
+ try {
34
+ const blocks = editor.tryParseMarkdownToBlocks(markdown);
35
+ editor.replaceBlocks(
36
+ editor.document,
37
+ blocks.length > 0 ? blocks : [{ type: "paragraph", content: markdown }],
38
+ );
39
+ } catch {
40
+ editor.replaceBlocks(editor.document, [
41
+ { type: "paragraph", content: markdown },
42
+ ]);
43
+ }
44
+ }, [editor, markdown]);
45
+
46
+ return (
47
+ <BlockNoteViewRaw
48
+ editor={editor}
49
+ editable={false}
50
+ theme={resolvedTheme}
51
+ className="suite-ai-blocknote"
52
+ formattingToolbar={false}
53
+ sideMenu={false}
54
+ slashMenu={false}
55
+ linkToolbar={false}
56
+ filePanel={false}
57
+ tableHandles={false}
58
+ comments={false}
59
+ />
60
+ );
61
+ }
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { resolveSuiteAiLauncherPosition } from './ai-panel';
4
+
5
+ describe('resolveSuiteAiLauncherPosition', () => {
6
+ it('docks to either edge and keeps the launcher inside the viewport', () => {
7
+ expect(
8
+ resolveSuiteAiLauncherPosition('left', -20, 1000, 800, 120, 40),
9
+ ).toEqual({
10
+ x: 12,
11
+ y: 12,
12
+ });
13
+ expect(
14
+ resolveSuiteAiLauncherPosition('right', 900, 1000, 800, 120, 40),
15
+ ).toEqual({
16
+ x: 868,
17
+ y: 748,
18
+ });
19
+ });
20
+ });
@@ -1,15 +1,23 @@
1
1
  'use client';
2
2
 
3
3
  import {
4
+ lazy,
5
+ Suspense,
4
6
  useCallback,
5
7
  useEffect,
6
8
  useRef,
7
9
  useState,
8
10
  type ComponentType,
11
+ type KeyboardEvent as ReactKeyboardEvent,
12
+ type PointerEvent as ReactPointerEvent,
9
13
  type ReactNode,
10
14
  } from 'react';
11
15
  import { Check, Copy, Sparks, Xmark as X } from 'iconoir-react';
12
16
 
17
+ const SuiteAiBlockNoteMessage = lazy(
18
+ () => import('./ai-message-blocknote'),
19
+ );
20
+
13
21
  export const SUITE_OPEN_ASK_AI_EVENT = 'shellstack:open-ask-ai';
14
22
 
15
23
  export type SuiteAskAiOpenDetail = {
@@ -37,6 +45,34 @@ export interface SuiteAiChatMessage {
37
45
  created_at?: string;
38
46
  }
39
47
 
48
+ type LauncherSide = 'left' | 'right';
49
+ type LauncherPosition = { x: number; y: number };
50
+
51
+ const LAUNCHER_EDGE_GAP = 12;
52
+ const LAUNCHER_STORAGE_KEY = 'suite-ask-ai-launcher-position';
53
+
54
+ export function resolveSuiteAiLauncherPosition(
55
+ side: LauncherSide,
56
+ desiredY: number,
57
+ viewportWidth: number,
58
+ viewportHeight: number,
59
+ launcherWidth: number,
60
+ launcherHeight: number,
61
+ ): LauncherPosition {
62
+ const maxX = Math.max(
63
+ LAUNCHER_EDGE_GAP,
64
+ viewportWidth - launcherWidth - LAUNCHER_EDGE_GAP,
65
+ );
66
+ const maxY = Math.max(
67
+ LAUNCHER_EDGE_GAP,
68
+ viewportHeight - launcherHeight - LAUNCHER_EDGE_GAP,
69
+ );
70
+ return {
71
+ x: side === 'left' ? LAUNCHER_EDGE_GAP : maxX,
72
+ y: Math.min(Math.max(desiredY, LAUNCHER_EDGE_GAP), maxY),
73
+ };
74
+ }
75
+
40
76
  /**
41
77
  * Suite-wide Ask AI slide-over — Notion-style continuous chat.
42
78
  *
@@ -77,9 +113,67 @@ export function SuiteAiPanel({
77
113
  const [hydrating, setHydrating] = useState(false);
78
114
  const [error, setError] = useState('');
79
115
  const [copiedId, setCopiedId] = useState<number | null>(null);
116
+ const [launcherSide, setLauncherSide] = useState<LauncherSide>('right');
117
+ const [launcherPosition, setLauncherPosition] =
118
+ useState<LauncherPosition | null>(null);
119
+ const [draggingLauncher, setDraggingLauncher] = useState(false);
80
120
  const scrollerRef = useRef<HTMLDivElement>(null);
121
+ const launcherRef = useRef<HTMLButtonElement>(null);
122
+ const launcherSideRef = useRef<LauncherSide>('right');
123
+ const launcherPositionRef = useRef<LauncherPosition | null>(null);
124
+ const launcherDragRef = useRef<{
125
+ pointerId: number;
126
+ offsetX: number;
127
+ offsetY: number;
128
+ startX: number;
129
+ startY: number;
130
+ } | null>(null);
131
+ const suppressLauncherClickRef = useRef(false);
81
132
  const pendingPromptRef = useRef<string | null>(null);
82
133
 
134
+ useEffect(() => {
135
+ const launcher = launcherRef.current;
136
+ if (!launcher) return;
137
+
138
+ let side: LauncherSide = 'right';
139
+ let desiredY = window.innerHeight - launcher.offsetHeight - 20;
140
+ try {
141
+ const stored = JSON.parse(
142
+ localStorage.getItem(LAUNCHER_STORAGE_KEY) || 'null',
143
+ ) as {
144
+ side?: LauncherSide;
145
+ y?: number;
146
+ } | null;
147
+ if (stored?.side === 'left' || stored?.side === 'right')
148
+ side = stored.side;
149
+ if (typeof stored?.y === 'number') desiredY = stored.y;
150
+ } catch {
151
+ // Ignore invalid saved launcher positions.
152
+ }
153
+
154
+ launcherSideRef.current = side;
155
+ setLauncherSide(side);
156
+
157
+ const placeLauncher = () => {
158
+ const element = launcherRef.current;
159
+ if (!element) return;
160
+ const next = resolveSuiteAiLauncherPosition(
161
+ launcherSideRef.current,
162
+ launcherPositionRef.current?.y ?? desiredY,
163
+ window.innerWidth,
164
+ window.innerHeight,
165
+ element.offsetWidth,
166
+ element.offsetHeight,
167
+ );
168
+ launcherPositionRef.current = next;
169
+ setLauncherPosition(next);
170
+ };
171
+
172
+ placeLauncher();
173
+ window.addEventListener('resize', placeLauncher);
174
+ return () => window.removeEventListener('resize', placeLauncher);
175
+ }, []);
176
+
83
177
  const scrollToBottom = useCallback(() => {
84
178
  const el = scrollerRef.current;
85
179
  if (!el) return;
@@ -185,21 +279,158 @@ export function SuiteAiPanel({
185
279
  }
186
280
  }
187
281
 
282
+ function saveLauncherPosition(
283
+ side: LauncherSide,
284
+ position: LauncherPosition,
285
+ ) {
286
+ try {
287
+ localStorage.setItem(
288
+ LAUNCHER_STORAGE_KEY,
289
+ JSON.stringify({ side, y: position.y }),
290
+ );
291
+ } catch {
292
+ // Storage may be unavailable; dragging still works for this session.
293
+ }
294
+ }
295
+
296
+ function dockLauncher(side: LauncherSide, desiredY: number) {
297
+ const launcher = launcherRef.current;
298
+ if (!launcher) return;
299
+ const next = resolveSuiteAiLauncherPosition(
300
+ side,
301
+ desiredY,
302
+ window.innerWidth,
303
+ window.innerHeight,
304
+ launcher.offsetWidth,
305
+ launcher.offsetHeight,
306
+ );
307
+ launcherSideRef.current = side;
308
+ launcherPositionRef.current = next;
309
+ setLauncherSide(side);
310
+ setLauncherPosition(next);
311
+ saveLauncherPosition(side, next);
312
+ }
313
+
314
+ function startLauncherDrag(event: ReactPointerEvent<HTMLButtonElement>) {
315
+ if (event.pointerType === 'mouse' && event.button !== 0) return;
316
+ const rect = event.currentTarget.getBoundingClientRect();
317
+ launcherDragRef.current = {
318
+ pointerId: event.pointerId,
319
+ offsetX: event.clientX - rect.left,
320
+ offsetY: event.clientY - rect.top,
321
+ startX: event.clientX,
322
+ startY: event.clientY,
323
+ };
324
+ suppressLauncherClickRef.current = false;
325
+ event.currentTarget.setPointerCapture(event.pointerId);
326
+ setDraggingLauncher(true);
327
+ }
328
+
329
+ function moveLauncher(event: ReactPointerEvent<HTMLButtonElement>) {
330
+ const drag = launcherDragRef.current;
331
+ if (!drag || drag.pointerId !== event.pointerId) return;
332
+ const launcher = event.currentTarget;
333
+ const maxX = Math.max(
334
+ LAUNCHER_EDGE_GAP,
335
+ window.innerWidth - launcher.offsetWidth - LAUNCHER_EDGE_GAP,
336
+ );
337
+ const maxY = Math.max(
338
+ LAUNCHER_EDGE_GAP,
339
+ window.innerHeight - launcher.offsetHeight - LAUNCHER_EDGE_GAP,
340
+ );
341
+ const next = {
342
+ x: Math.min(
343
+ Math.max(event.clientX - drag.offsetX, LAUNCHER_EDGE_GAP),
344
+ maxX,
345
+ ),
346
+ y: Math.min(
347
+ Math.max(event.clientY - drag.offsetY, LAUNCHER_EDGE_GAP),
348
+ maxY,
349
+ ),
350
+ };
351
+ if (
352
+ Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) > 4
353
+ ) {
354
+ suppressLauncherClickRef.current = true;
355
+ }
356
+ launcherPositionRef.current = next;
357
+ setLauncherPosition(next);
358
+ }
359
+
360
+ function finishLauncherDrag(event: ReactPointerEvent<HTMLButtonElement>) {
361
+ const drag = launcherDragRef.current;
362
+ if (!drag || drag.pointerId !== event.pointerId) return;
363
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
364
+ event.currentTarget.releasePointerCapture(event.pointerId);
365
+ }
366
+ launcherDragRef.current = null;
367
+ setDraggingLauncher(false);
368
+ const rect = event.currentTarget.getBoundingClientRect();
369
+ const currentX = launcherPositionRef.current?.x ?? rect.left;
370
+ const side: LauncherSide =
371
+ currentX + rect.width / 2 < window.innerWidth / 2 ? 'left' : 'right';
372
+ dockLauncher(side, launcherPositionRef.current?.y ?? rect.top);
373
+ }
374
+
375
+ function moveLauncherWithKeyboard(
376
+ event: ReactKeyboardEvent<HTMLButtonElement>,
377
+ ) {
378
+ if (
379
+ !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)
380
+ )
381
+ return;
382
+ event.preventDefault();
383
+ const currentY =
384
+ launcherPositionRef.current?.y ??
385
+ event.currentTarget.getBoundingClientRect().top;
386
+ if (event.key === 'ArrowLeft') dockLauncher('left', currentY);
387
+ else if (event.key === 'ArrowRight') dockLauncher('right', currentY);
388
+ else
389
+ dockLauncher(
390
+ launcherSideRef.current,
391
+ currentY + (event.key === 'ArrowUp' ? -24 : 24),
392
+ );
393
+ }
394
+
188
395
  const Icon = BrandIcon ?? Sparks;
189
396
 
190
397
  return (
191
398
  <>
192
399
  {!hideLauncher ? (
193
- <button
194
- type="button"
195
- data-test="ai-panel-launcher"
196
- onClick={() => setOpen(true)}
197
- 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"
198
- aria-label="Open Ask AI"
199
- >
200
- <Icon className="h-4 w-4 text-ph-brand" />
201
- Ask AI
202
- </button>
400
+ <>
401
+ <button
402
+ ref={launcherRef}
403
+ type="button"
404
+ data-test="ai-panel-launcher"
405
+ onClick={() => {
406
+ if (suppressLauncherClickRef.current) {
407
+ suppressLauncherClickRef.current = false;
408
+ return;
409
+ }
410
+ setOpen(true);
411
+ }}
412
+ onPointerDown={startLauncherDrag}
413
+ onPointerMove={moveLauncher}
414
+ onPointerUp={finishLauncherDrag}
415
+ onPointerCancel={finishLauncherDrag}
416
+ onKeyDown={moveLauncherWithKeyboard}
417
+ className={`fixed z-30 flex touch-none select-none 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 ${draggingLauncher ? 'cursor-grabbing' : 'cursor-grab'}`}
418
+ style={
419
+ launcherPosition
420
+ ? { left: launcherPosition.x, top: launcherPosition.y }
421
+ : { bottom: 20, right: 20 }
422
+ }
423
+ aria-label="Open Ask AI"
424
+ aria-describedby="suite-ask-ai-launcher-help"
425
+ >
426
+ <Icon className="h-4 w-4 text-ph-brand" />
427
+ Ask AI
428
+ </button>
429
+ <span id="suite-ask-ai-launcher-help" className="sr-only">
430
+ Drag to reposition, or use arrow keys. The launcher docks to the
431
+ nearest screen edge.
432
+ </span>
433
+ </>
203
434
  ) : null}
204
435
 
205
436
  {open ? (
@@ -211,7 +442,7 @@ export function SuiteAiPanel({
211
442
  aria-label="Close Ask AI"
212
443
  />
213
444
  <aside
214
- 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"
445
+ className={`absolute inset-y-0 flex w-full max-w-md flex-col bg-ph-surface shadow-2xl ${launcherSide === 'left' ? 'left-0 border-r border-ph-border' : 'right-0 border-l border-ph-border'}`}
215
446
  data-test="suite-ask-ai-panel"
216
447
  >
217
448
  <div className="flex items-center justify-between border-b border-ph-border px-4 py-3">
@@ -305,7 +536,19 @@ export function SuiteAiPanel({
305
536
  }
306
537
  data-test={isUser ? 'ask-ai-user' : 'ask-ai-assistant'}
307
538
  >
308
- <p className="whitespace-pre-wrap">{message.content}</p>
539
+ {isUser ? (
540
+ <p className="whitespace-pre-wrap">{message.content}</p>
541
+ ) : (
542
+ <Suspense
543
+ fallback={
544
+ <p className="whitespace-pre-wrap">
545
+ {message.content}
546
+ </p>
547
+ }
548
+ >
549
+ <SuiteAiBlockNoteMessage markdown={message.content} />
550
+ </Suspense>
551
+ )}
309
552
  {!isUser ? (
310
553
  <button
311
554
  type="button"