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

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/suite.js CHANGED
@@ -2318,6 +2318,22 @@ function openSuiteAskAi(detail) {
2318
2318
  })
2319
2319
  );
2320
2320
  }
2321
+ var LAUNCHER_EDGE_GAP = 12;
2322
+ var LAUNCHER_STORAGE_KEY = "suite-ask-ai-launcher-position";
2323
+ function resolveSuiteAiLauncherPosition(side, desiredY, viewportWidth, viewportHeight, launcherWidth, launcherHeight) {
2324
+ const maxX = Math.max(
2325
+ LAUNCHER_EDGE_GAP,
2326
+ viewportWidth - launcherWidth - LAUNCHER_EDGE_GAP
2327
+ );
2328
+ const maxY = Math.max(
2329
+ LAUNCHER_EDGE_GAP,
2330
+ viewportHeight - launcherHeight - LAUNCHER_EDGE_GAP
2331
+ );
2332
+ return {
2333
+ x: side === "left" ? LAUNCHER_EDGE_GAP : maxX,
2334
+ y: Math.min(Math.max(desiredY, LAUNCHER_EDGE_GAP), maxY)
2335
+ };
2336
+ }
2321
2337
  function SuiteAiPanel({
2322
2338
  presets = [],
2323
2339
  emptyState = "Ask anything about your workspace, or look something up online.",
@@ -2336,8 +2352,51 @@ function SuiteAiPanel({
2336
2352
  const [hydrating, setHydrating] = (0, import_react8.useState)(false);
2337
2353
  const [error, setError] = (0, import_react8.useState)("");
2338
2354
  const [copiedId, setCopiedId] = (0, import_react8.useState)(null);
2355
+ const [launcherSide, setLauncherSide] = (0, import_react8.useState)("right");
2356
+ const [launcherPosition, setLauncherPosition] = (0, import_react8.useState)(null);
2357
+ const [draggingLauncher, setDraggingLauncher] = (0, import_react8.useState)(false);
2339
2358
  const scrollerRef = (0, import_react8.useRef)(null);
2359
+ const launcherRef = (0, import_react8.useRef)(null);
2360
+ const launcherSideRef = (0, import_react8.useRef)("right");
2361
+ const launcherPositionRef = (0, import_react8.useRef)(null);
2362
+ const launcherDragRef = (0, import_react8.useRef)(null);
2363
+ const suppressLauncherClickRef = (0, import_react8.useRef)(false);
2340
2364
  const pendingPromptRef = (0, import_react8.useRef)(null);
2365
+ (0, import_react8.useEffect)(() => {
2366
+ const launcher = launcherRef.current;
2367
+ if (!launcher) return;
2368
+ let side = "right";
2369
+ let desiredY = window.innerHeight - launcher.offsetHeight - 20;
2370
+ try {
2371
+ const stored = JSON.parse(
2372
+ localStorage.getItem(LAUNCHER_STORAGE_KEY) || "null"
2373
+ );
2374
+ if ((stored == null ? void 0 : stored.side) === "left" || (stored == null ? void 0 : stored.side) === "right")
2375
+ side = stored.side;
2376
+ if (typeof (stored == null ? void 0 : stored.y) === "number") desiredY = stored.y;
2377
+ } catch (e) {
2378
+ }
2379
+ launcherSideRef.current = side;
2380
+ setLauncherSide(side);
2381
+ const placeLauncher = () => {
2382
+ var _a, _b;
2383
+ const element = launcherRef.current;
2384
+ if (!element) return;
2385
+ const next = resolveSuiteAiLauncherPosition(
2386
+ launcherSideRef.current,
2387
+ (_b = (_a = launcherPositionRef.current) == null ? void 0 : _a.y) != null ? _b : desiredY,
2388
+ window.innerWidth,
2389
+ window.innerHeight,
2390
+ element.offsetWidth,
2391
+ element.offsetHeight
2392
+ );
2393
+ launcherPositionRef.current = next;
2394
+ setLauncherPosition(next);
2395
+ };
2396
+ placeLauncher();
2397
+ window.addEventListener("resize", placeLauncher);
2398
+ return () => window.removeEventListener("resize", placeLauncher);
2399
+ }, []);
2341
2400
  const scrollToBottom = (0, import_react8.useCallback)(() => {
2342
2401
  const el = scrollerRef.current;
2343
2402
  if (!el) return;
@@ -2433,22 +2492,135 @@ function SuiteAiPanel({
2433
2492
  } catch (e) {
2434
2493
  }
2435
2494
  }
2495
+ function saveLauncherPosition(side, position) {
2496
+ try {
2497
+ localStorage.setItem(
2498
+ LAUNCHER_STORAGE_KEY,
2499
+ JSON.stringify({ side, y: position.y })
2500
+ );
2501
+ } catch (e) {
2502
+ }
2503
+ }
2504
+ function dockLauncher(side, desiredY) {
2505
+ const launcher = launcherRef.current;
2506
+ if (!launcher) return;
2507
+ const next = resolveSuiteAiLauncherPosition(
2508
+ side,
2509
+ desiredY,
2510
+ window.innerWidth,
2511
+ window.innerHeight,
2512
+ launcher.offsetWidth,
2513
+ launcher.offsetHeight
2514
+ );
2515
+ launcherSideRef.current = side;
2516
+ launcherPositionRef.current = next;
2517
+ setLauncherSide(side);
2518
+ setLauncherPosition(next);
2519
+ saveLauncherPosition(side, next);
2520
+ }
2521
+ function startLauncherDrag(event) {
2522
+ if (event.pointerType === "mouse" && event.button !== 0) return;
2523
+ const rect = event.currentTarget.getBoundingClientRect();
2524
+ launcherDragRef.current = {
2525
+ pointerId: event.pointerId,
2526
+ offsetX: event.clientX - rect.left,
2527
+ offsetY: event.clientY - rect.top,
2528
+ startX: event.clientX,
2529
+ startY: event.clientY
2530
+ };
2531
+ suppressLauncherClickRef.current = false;
2532
+ event.currentTarget.setPointerCapture(event.pointerId);
2533
+ setDraggingLauncher(true);
2534
+ }
2535
+ function moveLauncher(event) {
2536
+ const drag = launcherDragRef.current;
2537
+ if (!drag || drag.pointerId !== event.pointerId) return;
2538
+ const launcher = event.currentTarget;
2539
+ const maxX = Math.max(
2540
+ LAUNCHER_EDGE_GAP,
2541
+ window.innerWidth - launcher.offsetWidth - LAUNCHER_EDGE_GAP
2542
+ );
2543
+ const maxY = Math.max(
2544
+ LAUNCHER_EDGE_GAP,
2545
+ window.innerHeight - launcher.offsetHeight - LAUNCHER_EDGE_GAP
2546
+ );
2547
+ const next = {
2548
+ x: Math.min(
2549
+ Math.max(event.clientX - drag.offsetX, LAUNCHER_EDGE_GAP),
2550
+ maxX
2551
+ ),
2552
+ y: Math.min(
2553
+ Math.max(event.clientY - drag.offsetY, LAUNCHER_EDGE_GAP),
2554
+ maxY
2555
+ )
2556
+ };
2557
+ if (Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) > 4) {
2558
+ suppressLauncherClickRef.current = true;
2559
+ }
2560
+ launcherPositionRef.current = next;
2561
+ setLauncherPosition(next);
2562
+ }
2563
+ function finishLauncherDrag(event) {
2564
+ var _a, _b, _c, _d;
2565
+ const drag = launcherDragRef.current;
2566
+ if (!drag || drag.pointerId !== event.pointerId) return;
2567
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
2568
+ event.currentTarget.releasePointerCapture(event.pointerId);
2569
+ }
2570
+ launcherDragRef.current = null;
2571
+ setDraggingLauncher(false);
2572
+ const rect = event.currentTarget.getBoundingClientRect();
2573
+ const currentX = (_b = (_a = launcherPositionRef.current) == null ? void 0 : _a.x) != null ? _b : rect.left;
2574
+ const side = currentX + rect.width / 2 < window.innerWidth / 2 ? "left" : "right";
2575
+ dockLauncher(side, (_d = (_c = launcherPositionRef.current) == null ? void 0 : _c.y) != null ? _d : rect.top);
2576
+ }
2577
+ function moveLauncherWithKeyboard(event) {
2578
+ var _a, _b;
2579
+ if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key))
2580
+ return;
2581
+ event.preventDefault();
2582
+ const currentY = (_b = (_a = launcherPositionRef.current) == null ? void 0 : _a.y) != null ? _b : event.currentTarget.getBoundingClientRect().top;
2583
+ if (event.key === "ArrowLeft") dockLauncher("left", currentY);
2584
+ else if (event.key === "ArrowRight") dockLauncher("right", currentY);
2585
+ else
2586
+ dockLauncher(
2587
+ launcherSideRef.current,
2588
+ currentY + (event.key === "ArrowUp" ? -24 : 24)
2589
+ );
2590
+ }
2436
2591
  const Icon = BrandIcon != null ? BrandIcon : import_iconoir_react8.Sparks;
2437
2592
  return /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)(import_jsx_runtime19.Fragment, { children: [
2438
- !hideLauncher ? /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)(
2439
- "button",
2440
- {
2441
- type: "button",
2442
- "data-test": "ai-panel-launcher",
2443
- onClick: () => setOpen(true),
2444
- 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",
2445
- "aria-label": "Open Ask AI",
2446
- children: [
2447
- /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(Icon, { className: "h-4 w-4 text-ph-brand" }),
2448
- "Ask AI"
2449
- ]
2450
- }
2451
- ) : null,
2593
+ !hideLauncher ? /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)(import_jsx_runtime19.Fragment, { children: [
2594
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)(
2595
+ "button",
2596
+ {
2597
+ ref: launcherRef,
2598
+ type: "button",
2599
+ "data-test": "ai-panel-launcher",
2600
+ onClick: () => {
2601
+ if (suppressLauncherClickRef.current) {
2602
+ suppressLauncherClickRef.current = false;
2603
+ return;
2604
+ }
2605
+ setOpen(true);
2606
+ },
2607
+ onPointerDown: startLauncherDrag,
2608
+ onPointerMove: moveLauncher,
2609
+ onPointerUp: finishLauncherDrag,
2610
+ onPointerCancel: finishLauncherDrag,
2611
+ onKeyDown: moveLauncherWithKeyboard,
2612
+ 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"}`,
2613
+ style: launcherPosition ? { left: launcherPosition.x, top: launcherPosition.y } : { bottom: 20, right: 20 },
2614
+ "aria-label": "Open Ask AI",
2615
+ "aria-describedby": "suite-ask-ai-launcher-help",
2616
+ children: [
2617
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(Icon, { className: "h-4 w-4 text-ph-brand" }),
2618
+ "Ask AI"
2619
+ ]
2620
+ }
2621
+ ),
2622
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("span", { id: "suite-ask-ai-launcher-help", className: "sr-only", children: "Drag to reposition, or use arrow keys. The launcher docks to the nearest screen edge." })
2623
+ ] }) : null,
2452
2624
  open ? /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("div", { className: "fixed inset-0 z-40", role: "dialog", "aria-modal": "true", children: [
2453
2625
  /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
2454
2626
  "button",
@@ -2462,7 +2634,7 @@ function SuiteAiPanel({
2462
2634
  /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)(
2463
2635
  "aside",
2464
2636
  {
2465
- 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",
2637
+ 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"}`,
2466
2638
  "data-test": "suite-ask-ai-panel",
2467
2639
  children: [
2468
2640
  /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("div", { className: "flex items-center justify-between border-b border-ph-border px-4 py-3", children: [
package/dist/suite.mjs CHANGED
@@ -2092,6 +2092,22 @@ function openSuiteAskAi(detail) {
2092
2092
  })
2093
2093
  );
2094
2094
  }
2095
+ var LAUNCHER_EDGE_GAP = 12;
2096
+ var LAUNCHER_STORAGE_KEY = "suite-ask-ai-launcher-position";
2097
+ function resolveSuiteAiLauncherPosition(side, desiredY, viewportWidth, viewportHeight, launcherWidth, launcherHeight) {
2098
+ const maxX = Math.max(
2099
+ LAUNCHER_EDGE_GAP,
2100
+ viewportWidth - launcherWidth - LAUNCHER_EDGE_GAP
2101
+ );
2102
+ const maxY = Math.max(
2103
+ LAUNCHER_EDGE_GAP,
2104
+ viewportHeight - launcherHeight - LAUNCHER_EDGE_GAP
2105
+ );
2106
+ return {
2107
+ x: side === "left" ? LAUNCHER_EDGE_GAP : maxX,
2108
+ y: Math.min(Math.max(desiredY, LAUNCHER_EDGE_GAP), maxY)
2109
+ };
2110
+ }
2095
2111
  function SuiteAiPanel({
2096
2112
  presets = [],
2097
2113
  emptyState = "Ask anything about your workspace, or look something up online.",
@@ -2110,8 +2126,51 @@ function SuiteAiPanel({
2110
2126
  const [hydrating, setHydrating] = useState8(false);
2111
2127
  const [error, setError] = useState8("");
2112
2128
  const [copiedId, setCopiedId] = useState8(null);
2129
+ const [launcherSide, setLauncherSide] = useState8("right");
2130
+ const [launcherPosition, setLauncherPosition] = useState8(null);
2131
+ const [draggingLauncher, setDraggingLauncher] = useState8(false);
2113
2132
  const scrollerRef = useRef3(null);
2133
+ const launcherRef = useRef3(null);
2134
+ const launcherSideRef = useRef3("right");
2135
+ const launcherPositionRef = useRef3(null);
2136
+ const launcherDragRef = useRef3(null);
2137
+ const suppressLauncherClickRef = useRef3(false);
2114
2138
  const pendingPromptRef = useRef3(null);
2139
+ useEffect7(() => {
2140
+ const launcher = launcherRef.current;
2141
+ if (!launcher) return;
2142
+ let side = "right";
2143
+ let desiredY = window.innerHeight - launcher.offsetHeight - 20;
2144
+ try {
2145
+ const stored = JSON.parse(
2146
+ localStorage.getItem(LAUNCHER_STORAGE_KEY) || "null"
2147
+ );
2148
+ if ((stored == null ? void 0 : stored.side) === "left" || (stored == null ? void 0 : stored.side) === "right")
2149
+ side = stored.side;
2150
+ if (typeof (stored == null ? void 0 : stored.y) === "number") desiredY = stored.y;
2151
+ } catch (e) {
2152
+ }
2153
+ launcherSideRef.current = side;
2154
+ setLauncherSide(side);
2155
+ const placeLauncher = () => {
2156
+ var _a, _b;
2157
+ const element = launcherRef.current;
2158
+ if (!element) return;
2159
+ const next = resolveSuiteAiLauncherPosition(
2160
+ launcherSideRef.current,
2161
+ (_b = (_a = launcherPositionRef.current) == null ? void 0 : _a.y) != null ? _b : desiredY,
2162
+ window.innerWidth,
2163
+ window.innerHeight,
2164
+ element.offsetWidth,
2165
+ element.offsetHeight
2166
+ );
2167
+ launcherPositionRef.current = next;
2168
+ setLauncherPosition(next);
2169
+ };
2170
+ placeLauncher();
2171
+ window.addEventListener("resize", placeLauncher);
2172
+ return () => window.removeEventListener("resize", placeLauncher);
2173
+ }, []);
2115
2174
  const scrollToBottom = useCallback3(() => {
2116
2175
  const el = scrollerRef.current;
2117
2176
  if (!el) return;
@@ -2207,22 +2266,135 @@ function SuiteAiPanel({
2207
2266
  } catch (e) {
2208
2267
  }
2209
2268
  }
2269
+ function saveLauncherPosition(side, position) {
2270
+ try {
2271
+ localStorage.setItem(
2272
+ LAUNCHER_STORAGE_KEY,
2273
+ JSON.stringify({ side, y: position.y })
2274
+ );
2275
+ } catch (e) {
2276
+ }
2277
+ }
2278
+ function dockLauncher(side, desiredY) {
2279
+ const launcher = launcherRef.current;
2280
+ if (!launcher) return;
2281
+ const next = resolveSuiteAiLauncherPosition(
2282
+ side,
2283
+ desiredY,
2284
+ window.innerWidth,
2285
+ window.innerHeight,
2286
+ launcher.offsetWidth,
2287
+ launcher.offsetHeight
2288
+ );
2289
+ launcherSideRef.current = side;
2290
+ launcherPositionRef.current = next;
2291
+ setLauncherSide(side);
2292
+ setLauncherPosition(next);
2293
+ saveLauncherPosition(side, next);
2294
+ }
2295
+ function startLauncherDrag(event) {
2296
+ if (event.pointerType === "mouse" && event.button !== 0) return;
2297
+ const rect = event.currentTarget.getBoundingClientRect();
2298
+ launcherDragRef.current = {
2299
+ pointerId: event.pointerId,
2300
+ offsetX: event.clientX - rect.left,
2301
+ offsetY: event.clientY - rect.top,
2302
+ startX: event.clientX,
2303
+ startY: event.clientY
2304
+ };
2305
+ suppressLauncherClickRef.current = false;
2306
+ event.currentTarget.setPointerCapture(event.pointerId);
2307
+ setDraggingLauncher(true);
2308
+ }
2309
+ function moveLauncher(event) {
2310
+ const drag = launcherDragRef.current;
2311
+ if (!drag || drag.pointerId !== event.pointerId) return;
2312
+ const launcher = event.currentTarget;
2313
+ const maxX = Math.max(
2314
+ LAUNCHER_EDGE_GAP,
2315
+ window.innerWidth - launcher.offsetWidth - LAUNCHER_EDGE_GAP
2316
+ );
2317
+ const maxY = Math.max(
2318
+ LAUNCHER_EDGE_GAP,
2319
+ window.innerHeight - launcher.offsetHeight - LAUNCHER_EDGE_GAP
2320
+ );
2321
+ const next = {
2322
+ x: Math.min(
2323
+ Math.max(event.clientX - drag.offsetX, LAUNCHER_EDGE_GAP),
2324
+ maxX
2325
+ ),
2326
+ y: Math.min(
2327
+ Math.max(event.clientY - drag.offsetY, LAUNCHER_EDGE_GAP),
2328
+ maxY
2329
+ )
2330
+ };
2331
+ if (Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) > 4) {
2332
+ suppressLauncherClickRef.current = true;
2333
+ }
2334
+ launcherPositionRef.current = next;
2335
+ setLauncherPosition(next);
2336
+ }
2337
+ function finishLauncherDrag(event) {
2338
+ var _a, _b, _c, _d;
2339
+ const drag = launcherDragRef.current;
2340
+ if (!drag || drag.pointerId !== event.pointerId) return;
2341
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
2342
+ event.currentTarget.releasePointerCapture(event.pointerId);
2343
+ }
2344
+ launcherDragRef.current = null;
2345
+ setDraggingLauncher(false);
2346
+ const rect = event.currentTarget.getBoundingClientRect();
2347
+ const currentX = (_b = (_a = launcherPositionRef.current) == null ? void 0 : _a.x) != null ? _b : rect.left;
2348
+ const side = currentX + rect.width / 2 < window.innerWidth / 2 ? "left" : "right";
2349
+ dockLauncher(side, (_d = (_c = launcherPositionRef.current) == null ? void 0 : _c.y) != null ? _d : rect.top);
2350
+ }
2351
+ function moveLauncherWithKeyboard(event) {
2352
+ var _a, _b;
2353
+ if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key))
2354
+ return;
2355
+ event.preventDefault();
2356
+ const currentY = (_b = (_a = launcherPositionRef.current) == null ? void 0 : _a.y) != null ? _b : event.currentTarget.getBoundingClientRect().top;
2357
+ if (event.key === "ArrowLeft") dockLauncher("left", currentY);
2358
+ else if (event.key === "ArrowRight") dockLauncher("right", currentY);
2359
+ else
2360
+ dockLauncher(
2361
+ launcherSideRef.current,
2362
+ currentY + (event.key === "ArrowUp" ? -24 : 24)
2363
+ );
2364
+ }
2210
2365
  const Icon = BrandIcon != null ? BrandIcon : Sparks;
2211
2366
  return /* @__PURE__ */ jsxs15(Fragment5, { children: [
2212
- !hideLauncher ? /* @__PURE__ */ jsxs15(
2213
- "button",
2214
- {
2215
- type: "button",
2216
- "data-test": "ai-panel-launcher",
2217
- onClick: () => setOpen(true),
2218
- 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",
2219
- "aria-label": "Open Ask AI",
2220
- children: [
2221
- /* @__PURE__ */ jsx18(Icon, { className: "h-4 w-4 text-ph-brand" }),
2222
- "Ask AI"
2223
- ]
2224
- }
2225
- ) : null,
2367
+ !hideLauncher ? /* @__PURE__ */ jsxs15(Fragment5, { children: [
2368
+ /* @__PURE__ */ jsxs15(
2369
+ "button",
2370
+ {
2371
+ ref: launcherRef,
2372
+ type: "button",
2373
+ "data-test": "ai-panel-launcher",
2374
+ onClick: () => {
2375
+ if (suppressLauncherClickRef.current) {
2376
+ suppressLauncherClickRef.current = false;
2377
+ return;
2378
+ }
2379
+ setOpen(true);
2380
+ },
2381
+ onPointerDown: startLauncherDrag,
2382
+ onPointerMove: moveLauncher,
2383
+ onPointerUp: finishLauncherDrag,
2384
+ onPointerCancel: finishLauncherDrag,
2385
+ onKeyDown: moveLauncherWithKeyboard,
2386
+ 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"}`,
2387
+ style: launcherPosition ? { left: launcherPosition.x, top: launcherPosition.y } : { bottom: 20, right: 20 },
2388
+ "aria-label": "Open Ask AI",
2389
+ "aria-describedby": "suite-ask-ai-launcher-help",
2390
+ children: [
2391
+ /* @__PURE__ */ jsx18(Icon, { className: "h-4 w-4 text-ph-brand" }),
2392
+ "Ask AI"
2393
+ ]
2394
+ }
2395
+ ),
2396
+ /* @__PURE__ */ jsx18("span", { id: "suite-ask-ai-launcher-help", className: "sr-only", children: "Drag to reposition, or use arrow keys. The launcher docks to the nearest screen edge." })
2397
+ ] }) : null,
2226
2398
  open ? /* @__PURE__ */ jsxs15("div", { className: "fixed inset-0 z-40", role: "dialog", "aria-modal": "true", children: [
2227
2399
  /* @__PURE__ */ jsx18(
2228
2400
  "button",
@@ -2236,7 +2408,7 @@ function SuiteAiPanel({
2236
2408
  /* @__PURE__ */ jsxs15(
2237
2409
  "aside",
2238
2410
  {
2239
- 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",
2411
+ 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"}`,
2240
2412
  "data-test": "suite-ask-ai-panel",
2241
2413
  children: [
2242
2414
  /* @__PURE__ */ jsxs15("div", { className: "flex items-center justify-between border-b border-ph-border px-4 py-3", children: [
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.8",
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",
@@ -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
+ });
@@ -6,6 +6,8 @@ import {
6
6
  useRef,
7
7
  useState,
8
8
  type ComponentType,
9
+ type KeyboardEvent as ReactKeyboardEvent,
10
+ type PointerEvent as ReactPointerEvent,
9
11
  type ReactNode,
10
12
  } from 'react';
11
13
  import { Check, Copy, Sparks, Xmark as X } from 'iconoir-react';
@@ -37,6 +39,34 @@ export interface SuiteAiChatMessage {
37
39
  created_at?: string;
38
40
  }
39
41
 
42
+ type LauncherSide = 'left' | 'right';
43
+ type LauncherPosition = { x: number; y: number };
44
+
45
+ const LAUNCHER_EDGE_GAP = 12;
46
+ const LAUNCHER_STORAGE_KEY = 'suite-ask-ai-launcher-position';
47
+
48
+ export function resolveSuiteAiLauncherPosition(
49
+ side: LauncherSide,
50
+ desiredY: number,
51
+ viewportWidth: number,
52
+ viewportHeight: number,
53
+ launcherWidth: number,
54
+ launcherHeight: number,
55
+ ): LauncherPosition {
56
+ const maxX = Math.max(
57
+ LAUNCHER_EDGE_GAP,
58
+ viewportWidth - launcherWidth - LAUNCHER_EDGE_GAP,
59
+ );
60
+ const maxY = Math.max(
61
+ LAUNCHER_EDGE_GAP,
62
+ viewportHeight - launcherHeight - LAUNCHER_EDGE_GAP,
63
+ );
64
+ return {
65
+ x: side === 'left' ? LAUNCHER_EDGE_GAP : maxX,
66
+ y: Math.min(Math.max(desiredY, LAUNCHER_EDGE_GAP), maxY),
67
+ };
68
+ }
69
+
40
70
  /**
41
71
  * Suite-wide Ask AI slide-over — Notion-style continuous chat.
42
72
  *
@@ -77,9 +107,67 @@ export function SuiteAiPanel({
77
107
  const [hydrating, setHydrating] = useState(false);
78
108
  const [error, setError] = useState('');
79
109
  const [copiedId, setCopiedId] = useState<number | null>(null);
110
+ const [launcherSide, setLauncherSide] = useState<LauncherSide>('right');
111
+ const [launcherPosition, setLauncherPosition] =
112
+ useState<LauncherPosition | null>(null);
113
+ const [draggingLauncher, setDraggingLauncher] = useState(false);
80
114
  const scrollerRef = useRef<HTMLDivElement>(null);
115
+ const launcherRef = useRef<HTMLButtonElement>(null);
116
+ const launcherSideRef = useRef<LauncherSide>('right');
117
+ const launcherPositionRef = useRef<LauncherPosition | null>(null);
118
+ const launcherDragRef = useRef<{
119
+ pointerId: number;
120
+ offsetX: number;
121
+ offsetY: number;
122
+ startX: number;
123
+ startY: number;
124
+ } | null>(null);
125
+ const suppressLauncherClickRef = useRef(false);
81
126
  const pendingPromptRef = useRef<string | null>(null);
82
127
 
128
+ useEffect(() => {
129
+ const launcher = launcherRef.current;
130
+ if (!launcher) return;
131
+
132
+ let side: LauncherSide = 'right';
133
+ let desiredY = window.innerHeight - launcher.offsetHeight - 20;
134
+ try {
135
+ const stored = JSON.parse(
136
+ localStorage.getItem(LAUNCHER_STORAGE_KEY) || 'null',
137
+ ) as {
138
+ side?: LauncherSide;
139
+ y?: number;
140
+ } | null;
141
+ if (stored?.side === 'left' || stored?.side === 'right')
142
+ side = stored.side;
143
+ if (typeof stored?.y === 'number') desiredY = stored.y;
144
+ } catch {
145
+ // Ignore invalid saved launcher positions.
146
+ }
147
+
148
+ launcherSideRef.current = side;
149
+ setLauncherSide(side);
150
+
151
+ const placeLauncher = () => {
152
+ const element = launcherRef.current;
153
+ if (!element) return;
154
+ const next = resolveSuiteAiLauncherPosition(
155
+ launcherSideRef.current,
156
+ launcherPositionRef.current?.y ?? desiredY,
157
+ window.innerWidth,
158
+ window.innerHeight,
159
+ element.offsetWidth,
160
+ element.offsetHeight,
161
+ );
162
+ launcherPositionRef.current = next;
163
+ setLauncherPosition(next);
164
+ };
165
+
166
+ placeLauncher();
167
+ window.addEventListener('resize', placeLauncher);
168
+ return () => window.removeEventListener('resize', placeLauncher);
169
+ }, []);
170
+
83
171
  const scrollToBottom = useCallback(() => {
84
172
  const el = scrollerRef.current;
85
173
  if (!el) return;
@@ -185,21 +273,158 @@ export function SuiteAiPanel({
185
273
  }
186
274
  }
187
275
 
276
+ function saveLauncherPosition(
277
+ side: LauncherSide,
278
+ position: LauncherPosition,
279
+ ) {
280
+ try {
281
+ localStorage.setItem(
282
+ LAUNCHER_STORAGE_KEY,
283
+ JSON.stringify({ side, y: position.y }),
284
+ );
285
+ } catch {
286
+ // Storage may be unavailable; dragging still works for this session.
287
+ }
288
+ }
289
+
290
+ function dockLauncher(side: LauncherSide, desiredY: number) {
291
+ const launcher = launcherRef.current;
292
+ if (!launcher) return;
293
+ const next = resolveSuiteAiLauncherPosition(
294
+ side,
295
+ desiredY,
296
+ window.innerWidth,
297
+ window.innerHeight,
298
+ launcher.offsetWidth,
299
+ launcher.offsetHeight,
300
+ );
301
+ launcherSideRef.current = side;
302
+ launcherPositionRef.current = next;
303
+ setLauncherSide(side);
304
+ setLauncherPosition(next);
305
+ saveLauncherPosition(side, next);
306
+ }
307
+
308
+ function startLauncherDrag(event: ReactPointerEvent<HTMLButtonElement>) {
309
+ if (event.pointerType === 'mouse' && event.button !== 0) return;
310
+ const rect = event.currentTarget.getBoundingClientRect();
311
+ launcherDragRef.current = {
312
+ pointerId: event.pointerId,
313
+ offsetX: event.clientX - rect.left,
314
+ offsetY: event.clientY - rect.top,
315
+ startX: event.clientX,
316
+ startY: event.clientY,
317
+ };
318
+ suppressLauncherClickRef.current = false;
319
+ event.currentTarget.setPointerCapture(event.pointerId);
320
+ setDraggingLauncher(true);
321
+ }
322
+
323
+ function moveLauncher(event: ReactPointerEvent<HTMLButtonElement>) {
324
+ const drag = launcherDragRef.current;
325
+ if (!drag || drag.pointerId !== event.pointerId) return;
326
+ const launcher = event.currentTarget;
327
+ const maxX = Math.max(
328
+ LAUNCHER_EDGE_GAP,
329
+ window.innerWidth - launcher.offsetWidth - LAUNCHER_EDGE_GAP,
330
+ );
331
+ const maxY = Math.max(
332
+ LAUNCHER_EDGE_GAP,
333
+ window.innerHeight - launcher.offsetHeight - LAUNCHER_EDGE_GAP,
334
+ );
335
+ const next = {
336
+ x: Math.min(
337
+ Math.max(event.clientX - drag.offsetX, LAUNCHER_EDGE_GAP),
338
+ maxX,
339
+ ),
340
+ y: Math.min(
341
+ Math.max(event.clientY - drag.offsetY, LAUNCHER_EDGE_GAP),
342
+ maxY,
343
+ ),
344
+ };
345
+ if (
346
+ Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) > 4
347
+ ) {
348
+ suppressLauncherClickRef.current = true;
349
+ }
350
+ launcherPositionRef.current = next;
351
+ setLauncherPosition(next);
352
+ }
353
+
354
+ function finishLauncherDrag(event: ReactPointerEvent<HTMLButtonElement>) {
355
+ const drag = launcherDragRef.current;
356
+ if (!drag || drag.pointerId !== event.pointerId) return;
357
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
358
+ event.currentTarget.releasePointerCapture(event.pointerId);
359
+ }
360
+ launcherDragRef.current = null;
361
+ setDraggingLauncher(false);
362
+ const rect = event.currentTarget.getBoundingClientRect();
363
+ const currentX = launcherPositionRef.current?.x ?? rect.left;
364
+ const side: LauncherSide =
365
+ currentX + rect.width / 2 < window.innerWidth / 2 ? 'left' : 'right';
366
+ dockLauncher(side, launcherPositionRef.current?.y ?? rect.top);
367
+ }
368
+
369
+ function moveLauncherWithKeyboard(
370
+ event: ReactKeyboardEvent<HTMLButtonElement>,
371
+ ) {
372
+ if (
373
+ !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)
374
+ )
375
+ return;
376
+ event.preventDefault();
377
+ const currentY =
378
+ launcherPositionRef.current?.y ??
379
+ event.currentTarget.getBoundingClientRect().top;
380
+ if (event.key === 'ArrowLeft') dockLauncher('left', currentY);
381
+ else if (event.key === 'ArrowRight') dockLauncher('right', currentY);
382
+ else
383
+ dockLauncher(
384
+ launcherSideRef.current,
385
+ currentY + (event.key === 'ArrowUp' ? -24 : 24),
386
+ );
387
+ }
388
+
188
389
  const Icon = BrandIcon ?? Sparks;
189
390
 
190
391
  return (
191
392
  <>
192
393
  {!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>
394
+ <>
395
+ <button
396
+ ref={launcherRef}
397
+ type="button"
398
+ data-test="ai-panel-launcher"
399
+ onClick={() => {
400
+ if (suppressLauncherClickRef.current) {
401
+ suppressLauncherClickRef.current = false;
402
+ return;
403
+ }
404
+ setOpen(true);
405
+ }}
406
+ onPointerDown={startLauncherDrag}
407
+ onPointerMove={moveLauncher}
408
+ onPointerUp={finishLauncherDrag}
409
+ onPointerCancel={finishLauncherDrag}
410
+ onKeyDown={moveLauncherWithKeyboard}
411
+ 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'}`}
412
+ style={
413
+ launcherPosition
414
+ ? { left: launcherPosition.x, top: launcherPosition.y }
415
+ : { bottom: 20, right: 20 }
416
+ }
417
+ aria-label="Open Ask AI"
418
+ aria-describedby="suite-ask-ai-launcher-help"
419
+ >
420
+ <Icon className="h-4 w-4 text-ph-brand" />
421
+ Ask AI
422
+ </button>
423
+ <span id="suite-ask-ai-launcher-help" className="sr-only">
424
+ Drag to reposition, or use arrow keys. The launcher docks to the
425
+ nearest screen edge.
426
+ </span>
427
+ </>
203
428
  ) : null}
204
429
 
205
430
  {open ? (
@@ -211,7 +436,7 @@ export function SuiteAiPanel({
211
436
  aria-label="Close Ask AI"
212
437
  />
213
438
  <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"
439
+ 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
440
  data-test="suite-ask-ai-panel"
216
441
  >
217
442
  <div className="flex items-center justify-between border-b border-ph-border px-4 py-3">