gloomberb 0.7.4 → 0.7.5

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": "gloomberb",
3
- "version": "0.7.4",
3
+ "version": "0.7.5",
4
4
  "description": "Bloomberg-style stock portfolio tracker for the terminal",
5
5
  "module": "src/index.tsx",
6
6
  "type": "module",
package/src/app.tsx CHANGED
@@ -40,7 +40,7 @@ import {
40
40
  type PaneBinding,
41
41
  type PaneInstanceConfig,
42
42
  } from "./types/config";
43
- import type { CliLaunchRequest, PaneTemplateCreateOptions, PaneTemplateInstanceConfig, WizardStep } from "./types/plugin";
43
+ import type { CliLaunchRequest, PaneTemplateCreateOptions, PaneTemplateInstanceConfig, PinTickerOptions, WizardStep } from "./types/plugin";
44
44
  import type { PaneSettingField } from "./types/plugin";
45
45
  import type { TickerRecord } from "./types/ticker";
46
46
  import type { DataProvider } from "./types/data-provider";
@@ -48,7 +48,7 @@ import type { TickerFinancials } from "./types/financials";
48
48
  import type { BrokerAccount } from "./types/trading";
49
49
  import type { DesktopDockPreviewState, DesktopSharedStateSnapshot, DesktopThemePreviewState, DesktopWindowBridge } from "./types/desktop-window";
50
50
  import type { DesktopApplicationMenuBridge } from "./types/desktop-menu";
51
- import { resolveTickerSearch, upsertTickerFromSearchResult } from "./utils/ticker-search";
51
+ import { resolveTickerOpenTarget, type TickerOpenTarget } from "./utils/ticker-search";
52
52
 
53
53
  import {
54
54
  clearPersistedBrokerAccounts,
@@ -70,6 +70,7 @@ import {
70
70
  gridlockAllPanes,
71
71
  isPaneInLayout,
72
72
  removePane,
73
+ type LayoutBounds,
73
74
  } from "./plugins/pane-manager";
74
75
  import { notifyGridlockComplete } from "./plugins/gridlock-notification";
75
76
  import {
@@ -206,6 +207,7 @@ function AppInner({
206
207
  const isDetachedWindow = desktopWindowBridge?.kind === "detached";
207
208
  const detachedPaneId = isDetachedWindow ? desktopWindowBridge.paneId ?? null : null;
208
209
  const [desktopDockPreview, setDesktopDockPreview] = useState<DesktopDockPreviewState | null>(null);
210
+ const [commandBarNativeOccluder, setCommandBarNativeOccluder] = useState<LayoutBounds | null>(null);
209
211
  appActiveRef.current = appActive;
210
212
  const appNotifier = useMemo(() => createAppNotifier({
211
213
  isAppActive: () => appActiveRef.current,
@@ -1359,14 +1361,45 @@ function AppInner({
1359
1361
 
1360
1362
  focusVisiblePane(instanceId);
1361
1363
  };
1362
- pluginRegistry.pinTickerFn = (symbol, options) => {
1363
- if (isDetachedWindow) return;
1364
+ const resolveOpenTickerTarget = async (rawSymbol: string): Promise<TickerOpenTarget | null> => {
1365
+ try {
1366
+ const target = await resolveTickerOpenTarget({
1367
+ query: rawSymbol,
1368
+ tickers: stateRef.current.tickers,
1369
+ dataProvider,
1370
+ tickerRepository,
1371
+ });
1372
+ if (!target) {
1373
+ pluginRegistry.notify({ body: `Could not open ${rawSymbol}.`, type: "error" });
1374
+ }
1375
+ return target;
1376
+ } catch (err) {
1377
+ const message = err instanceof Error ? err.message : String(err);
1378
+ pluginRegistry.notify({ body: `Failed to open ${rawSymbol}: ${message}`, type: "error" });
1379
+ return null;
1380
+ }
1381
+ };
1382
+ const publishTickerOpenTarget = (target: TickerOpenTarget) => {
1383
+ const currentTicker = stateRef.current.tickers.get(target.symbol);
1384
+ if (currentTicker !== target.ticker) {
1385
+ dispatch({ type: "UPDATE_TICKER", ticker: target.ticker });
1386
+ }
1387
+ if (target.created) {
1388
+ pluginRegistry.events.emit("ticker:added", { symbol: target.symbol, ticker: target.ticker });
1389
+ }
1390
+ };
1391
+ const placePinnedTickerTarget = (target: TickerOpenTarget, options?: PinTickerOptions) => {
1364
1392
  const paneType = options?.paneType ?? "ticker-detail";
1365
1393
  const paneDef = pluginRegistry.panes.get(paneType);
1366
1394
  if (!paneDef) return;
1395
+
1396
+ publishTickerOpenTarget(target);
1397
+ const symbol = target.symbol;
1398
+ const currentState = stateRef.current;
1399
+ const currentLayout = currentState.config.layout;
1367
1400
  const existing = options?.forceNewPane
1368
1401
  ? null
1369
- : findFixedTickerPaneForSymbol(state.config.layout, paneType, symbol);
1402
+ : findFixedTickerPaneForSymbol(currentLayout, paneType, symbol);
1370
1403
  if (existing) {
1371
1404
  focusVisiblePane(existing.instanceId);
1372
1405
  return;
@@ -1379,45 +1412,38 @@ function AppInner({
1379
1412
  const { width, height } = pluginRegistry.getTermSizeFn();
1380
1413
  const shouldFloat = options?.floating ?? true;
1381
1414
  const nextLayout = shouldFloat
1382
- ? addPaneFloating(state.config.layout, instance, width, height, paneDef)
1415
+ ? addPaneFloating(currentLayout, instance, width, height, paneDef)
1383
1416
  : addPaneToLayout(
1384
- state.config.layout,
1417
+ currentLayout,
1385
1418
  instance,
1386
1419
  {
1387
- relativeTo: state.focusedPaneId && isPaneInLayout(state.config.layout, state.focusedPaneId)
1388
- ? state.focusedPaneId
1389
- : (getDockedPaneIds(state.config.layout).at(-1) ?? instance.instanceId),
1420
+ relativeTo: currentState.focusedPaneId && isPaneInLayout(currentLayout, currentState.focusedPaneId)
1421
+ ? currentState.focusedPaneId
1422
+ : (getDockedPaneIds(currentLayout).at(-1) ?? instance.instanceId),
1390
1423
  position: "right",
1391
1424
  },
1392
1425
  );
1393
1426
  persistLayout(nextLayout);
1394
1427
  activatePane(instance.instanceId, nextLayout);
1395
1428
  };
1429
+ const openPinnedTicker = async (rawSymbol: string, options?: PinTickerOptions) => {
1430
+ const target = await resolveOpenTickerTarget(rawSymbol);
1431
+ if (!target) return;
1432
+ placePinnedTickerTarget(target, options);
1433
+ };
1434
+ pluginRegistry.pinTickerFn = (symbol, options) => {
1435
+ if (isDetachedWindow) return;
1436
+ void openPinnedTicker(symbol, options);
1437
+ };
1396
1438
 
1397
1439
  pluginRegistry.navigateTickerFn = (rawSymbol, options) => {
1398
1440
  if (isDetachedWindow) return;
1399
1441
  const sourcePaneId = options?.sourcePaneId ?? stateRef.current.focusedPaneId;
1400
1442
  (async () => {
1401
1443
  try {
1402
- // Resolve or create the ticker in the local database
1403
- const resolved = await resolveTickerSearch({
1404
- query: rawSymbol,
1405
- activeTicker: null,
1406
- tickers: stateRef.current.tickers,
1407
- dataProvider,
1408
- });
1409
-
1410
- let symbol = rawSymbol;
1411
- if (resolved?.kind === "local") {
1412
- symbol = resolved.symbol;
1413
- } else if (resolved?.kind === "provider" && resolved.result) {
1414
- const { ticker, created } = await upsertTickerFromSearchResult(tickerRepository, resolved.result);
1415
- symbol = ticker.metadata.ticker;
1416
- dispatch({ type: "UPDATE_TICKER", ticker });
1417
- if (created) {
1418
- pluginRegistry.events.emit("ticker:added", { symbol, ticker });
1419
- }
1420
- }
1444
+ const target = await resolveOpenTickerTarget(rawSymbol);
1445
+ if (!target) return;
1446
+ const symbol = target.symbol;
1421
1447
 
1422
1448
  // Only actions that originate from a ticker detail replace that exact pane.
1423
1449
  // Everything else opens or focuses a fixed ticker detail for the symbol.
@@ -1436,6 +1462,7 @@ function AppInner({
1436
1462
  };
1437
1463
 
1438
1464
  if (detailPane) {
1465
+ publishTickerOpenTarget(target);
1439
1466
  const nextLayout = {
1440
1467
  ...currentLayout,
1441
1468
  instances: currentLayout.instances.map((instance) => (
@@ -1451,7 +1478,7 @@ function AppInner({
1451
1478
  currentFocusedPaneId: stateRef.current.focusedPaneId,
1452
1479
  targetPaneId: null,
1453
1480
  })) {
1454
- pluginRegistry.pinTicker(symbol, { floating: false });
1481
+ placePinnedTickerTarget(target, { floating: false });
1455
1482
  }
1456
1483
  } catch (err) {
1457
1484
  const message = err instanceof Error ? err.message : String(err);
@@ -1618,6 +1645,7 @@ function AppInner({
1618
1645
  pluginRegistry={pluginRegistry}
1619
1646
  desktopWindowBridge={desktopWindowBridge}
1620
1647
  desktopDockPreview={desktopDockPreview}
1648
+ commandBarNativeOccluder={commandBarNativeOccluder}
1621
1649
  />
1622
1650
  <StatusBar />
1623
1651
  {state.commandBarOpen && (
@@ -1627,6 +1655,7 @@ function AppInner({
1627
1655
  pluginRegistry={pluginRegistry}
1628
1656
  quitApp={() => rendererHost.requestExit()}
1629
1657
  onCheckForUpdates={() => runUpdateCheck(true)}
1658
+ onNativeOccluderChange={setCommandBarNativeOccluder}
1630
1659
  />
1631
1660
  )}
1632
1661
  <ToastViewport position="bottom-right" />
@@ -73,8 +73,10 @@ import {
73
73
  movePaneRelative,
74
74
  removePane,
75
75
  swapPanes,
76
+ type LayoutBounds,
76
77
  } from "../../plugins/pane-manager";
77
78
  import { notifyGridlockComplete } from "../../plugins/gridlock-notification";
79
+ import { resolveAppHeaderHeightCells } from "../layout/shell";
78
80
  import { CHART_RENDERER_PREFERENCES } from "../chart/chart-types";
79
81
  import {
80
82
  getEmptyState,
@@ -168,6 +170,7 @@ interface CommandBarProps {
168
170
  pluginRegistry: PluginRegistry;
169
171
  quitApp: () => void;
170
172
  onCheckForUpdates?: () => void | Promise<void>;
173
+ onNativeOccluderChange?: (rect: LayoutBounds | null) => void;
171
174
  }
172
175
 
173
176
  type WorkflowStringValues = Record<string, string>;
@@ -645,6 +648,7 @@ export function CommandBar({
645
648
  pluginRegistry,
646
649
  quitApp,
647
650
  onCheckForUpdates,
651
+ onNativeOccluderChange,
648
652
  }: CommandBarProps) {
649
653
  const dispatch = useAppDispatch();
650
654
  const themeColors = useThemeColors();
@@ -709,7 +713,7 @@ export function CommandBar({
709
713
  }, []);
710
714
  const { symbol: activeTickerSymbol, ticker: activeTickerData, financials: activeFinancials } = useFocusedTicker();
711
715
  const { width: termWidth, height: termHeight } = useViewport();
712
- const { nativePaneChrome, cellWidthPx = 8, cellHeightPx = 18 } = useUiCapabilities();
716
+ const { nativePaneChrome, cellWidthPx = 8, cellHeightPx = 18, titleBarOverlay } = useUiCapabilities();
713
717
  const availableCommands = useMemo(
714
718
  () => nativePaneChrome
715
719
  ? commands.filter((command) => command.id !== "cycle-chart-renderer")
@@ -5390,6 +5394,13 @@ export function CommandBar({
5390
5394
  : bodyHeight + 7;
5391
5395
  const barLeft = Math.max(4, Math.floor((termWidth - barWidth) / 2));
5392
5396
  const barTop = Math.max(1, Math.floor((termHeight - barHeight) / 2));
5397
+ const appHeaderHeight = resolveAppHeaderHeightCells({ titleBarOverlay, cellHeightPx });
5398
+ const nativeOccluderRect = useMemo<LayoutBounds>(() => ({
5399
+ x: barLeft,
5400
+ y: barTop - appHeaderHeight,
5401
+ width: barWidth,
5402
+ height: barHeight,
5403
+ }), [appHeaderHeight, barHeight, barLeft, barTop, barWidth]);
5393
5404
  const resultsInnerWidth = Math.max(12, barWidth - nativePanelPaddingColumns - contentPadding * 2);
5394
5405
  const trailingWidth = Math.max(8, Math.min(12, Math.floor(resultsInnerWidth * 0.18)));
5395
5406
  const labelWidth = Math.max(10, resultsInnerWidth - trailingWidth);
@@ -5419,6 +5430,13 @@ export function CommandBar({
5419
5430
  }
5420
5431
  }, [listBodyHeight, selectedScrollRowIndex, visibleListState?.kind, visibleListState?.query]);
5421
5432
 
5433
+ useLayoutEffect(() => {
5434
+ onNativeOccluderChange?.(nativeOccluderRect);
5435
+ return () => {
5436
+ onNativeOccluderChange?.(null);
5437
+ };
5438
+ }, [nativeOccluderRect, onNativeOccluderChange]);
5439
+
5422
5440
  useEffect(() => {
5423
5441
  if (!nativePaneChrome || currentRoute?.kind !== "workflow") return;
5424
5442
  const scrollBox = workflowScrollRef.current;
@@ -560,6 +560,32 @@ describe("Shell", () => {
560
560
  ]);
561
561
  });
562
562
 
563
+ test("keeps command bar native occlusion scoped to the panel", () => {
564
+ const state = buildNativeWindowState(
565
+ ["portfolio-list:main"],
566
+ [],
567
+ null,
568
+ { open: false, width: 120, contentHeight: 40 },
569
+ [
570
+ {
571
+ id: "command-bar:panel",
572
+ rect: { x: 24, y: 8, width: 72, height: 14 },
573
+ zIndex: Number.MAX_SAFE_INTEGER,
574
+ },
575
+ ],
576
+ );
577
+
578
+ expect(state.occluders).toEqual([
579
+ {
580
+ id: "command-bar:panel",
581
+ paneId: null,
582
+ rect: { x: 24, y: 9, width: 72, height: 14 },
583
+ zIndex: Number.MAX_SAFE_INTEGER,
584
+ },
585
+ ]);
586
+ expect(state.occluders.some((occluder) => occluder.id === "overlay:global")).toBe(false);
587
+ });
588
+
563
589
  test("adds dock dividers as global native occluders", () => {
564
590
  const state = buildNativeWindowState(
565
591
  ["left:main", "right:main"],
@@ -76,6 +76,7 @@ interface ShellProps {
76
76
  pluginRegistry: PluginRegistry;
77
77
  desktopWindowBridge?: DesktopWindowBridge;
78
78
  desktopDockPreview?: DesktopDockPreviewState | null;
79
+ commandBarNativeOccluder?: LayoutBounds | null;
79
80
  }
80
81
 
81
82
  interface HoverOverlay {
@@ -192,6 +193,7 @@ const DEFAULT_HEADER_HEIGHT = 1;
192
193
  const MENU_MIN_WIDTH = 18;
193
194
  const MENU_MAX_WIDTH = 44;
194
195
  const MENU_Z_INDEX = 10_000;
196
+ const DOCK_DIVIDER_SIZE = 1;
195
197
  const PANE_DRAG_THRESHOLD = 2;
196
198
  const PRECISE_PANE_DRAG_THRESHOLD = 0.15;
197
199
  const PANE_MANAGEMENT_ACCELERATORS = {
@@ -498,6 +500,39 @@ export function resolveNativeDockDividers(
498
500
  ));
499
501
  }
500
502
 
503
+ function resolveDividerPreviewRect(
504
+ axis: "horizontal" | "vertical",
505
+ bounds: LayoutBounds,
506
+ ratio: number,
507
+ nativePaneChrome: boolean,
508
+ ): LayoutBounds {
509
+ if (axis === "horizontal") {
510
+ const offset = nativePaneChrome
511
+ ? bounds.width * ratio
512
+ : bounds.width > DOCK_DIVIDER_SIZE
513
+ ? Math.round((bounds.width - DOCK_DIVIDER_SIZE) * ratio)
514
+ : Math.max(0, Math.round(bounds.width * ratio) - DOCK_DIVIDER_SIZE);
515
+ return {
516
+ x: nativePaneChrome ? bounds.x + offset - (DOCK_DIVIDER_SIZE / 2) : bounds.x + offset,
517
+ y: bounds.y,
518
+ width: DOCK_DIVIDER_SIZE,
519
+ height: bounds.height,
520
+ };
521
+ }
522
+
523
+ const offset = nativePaneChrome
524
+ ? bounds.height * ratio
525
+ : bounds.height > DOCK_DIVIDER_SIZE
526
+ ? Math.round((bounds.height - DOCK_DIVIDER_SIZE) * ratio)
527
+ : Math.max(0, Math.round(bounds.height * ratio) - DOCK_DIVIDER_SIZE);
528
+ return {
529
+ x: bounds.x,
530
+ y: nativePaneChrome ? bounds.y + offset - (DOCK_DIVIDER_SIZE / 2) : bounds.y + offset,
531
+ width: bounds.width,
532
+ height: DOCK_DIVIDER_SIZE,
533
+ };
534
+ }
535
+
501
536
  export function finalizePaneDragRelease(
502
537
  layout: LayoutConfig,
503
538
  paneId: string,
@@ -789,7 +824,12 @@ function resolveExternalDockPreview(
789
824
  }
790
825
  }
791
826
 
792
- export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview }: ShellProps) {
827
+ export function Shell({
828
+ pluginRegistry,
829
+ desktopWindowBridge,
830
+ desktopDockPreview,
831
+ commandBarNativeOccluder = null,
832
+ }: ShellProps) {
793
833
  useThemeColors();
794
834
  const dispatch = useAppDispatch();
795
835
  const config = useAppSelector((state) => state.config);
@@ -1047,7 +1087,9 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
1047
1087
  event.stopPropagation();
1048
1088
  });
1049
1089
 
1050
- const dockGeometryOptions = useMemo(() => (nativePaneChrome ? { precise: true } : undefined), [nativePaneChrome]);
1090
+ const dockGeometryOptions = useMemo(() => (
1091
+ nativePaneChrome ? { precise: true } : { reserveDividerGutters: true }
1092
+ ), [nativePaneChrome]);
1051
1093
  const dockLeafLayouts = useMemo(() => getDockLeafLayouts(visibleLayout, bounds, dockGeometryOptions), [bounds, dockGeometryOptions, visibleLayout]);
1052
1094
  const dockDividerLayouts = useMemo(() => getDockDividerLayouts(visibleLayout, bounds, dockGeometryOptions), [bounds, dockGeometryOptions, visibleLayout]);
1053
1095
  const snapGuides = useMemo(() => makeSnapGuides(width, contentHeight), [contentHeight, width]);
@@ -1092,8 +1134,29 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
1092
1134
  });
1093
1135
  }
1094
1136
 
1137
+ if (menuState) {
1138
+ occluders.push({
1139
+ id: `pane-menu:${menuState.paneId}`,
1140
+ rect: {
1141
+ x: menuState.x,
1142
+ y: menuState.y,
1143
+ width: menuState.width,
1144
+ height: menuState.items.length + 2,
1145
+ },
1146
+ zIndex: MENU_Z_INDEX,
1147
+ });
1148
+ }
1149
+
1150
+ if (commandBarNativeOccluder) {
1151
+ occluders.push({
1152
+ id: "command-bar:panel",
1153
+ rect: commandBarNativeOccluder,
1154
+ zIndex: Number.MAX_SAFE_INTEGER,
1155
+ });
1156
+ }
1157
+
1095
1158
  return occluders;
1096
- }, [activeHoverOverlay, activePaneDrag, dragFloatingRect, effectiveDockPreview]);
1159
+ }, [activeHoverOverlay, activePaneDrag, commandBarNativeOccluder, dragFloatingRect, effectiveDockPreview, menuState]);
1097
1160
  const nativeDockDividers = useMemo(
1098
1161
  () => resolveNativeDockDividers(dockDividerLayouts, dividerPreview),
1099
1162
  [dividerPreview, dockDividerLayouts],
@@ -1107,12 +1170,12 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
1107
1170
  zIndex: pane.floating?.zIndex ?? 50,
1108
1171
  })),
1109
1172
  dragFloatingRect,
1110
- { open: overlayOpen, width, contentHeight },
1173
+ { open: dialogOpen, width, contentHeight },
1111
1174
  nativeTransientOccluders,
1112
1175
  nativeDockDividers,
1113
1176
  appHeaderHeight,
1114
1177
  ),
1115
- [appHeaderHeight, contentHeight, dockedPanes, dragFloatingRect, nativeDockDividers, nativeTransientOccluders, overlayOpen, visibleFloatingPanes, width],
1178
+ [appHeaderHeight, contentHeight, dialogOpen, dockedPanes, dragFloatingRect, nativeDockDividers, nativeTransientOccluders, visibleFloatingPanes, width],
1116
1179
  );
1117
1180
 
1118
1181
  useEffect(() => {
@@ -1194,22 +1257,7 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
1194
1257
  const total = drag.axis === "horizontal" ? drag.bounds.width : drag.bounds.height;
1195
1258
  const delta = drag.axis === "horizontal" ? preciseX - drag.startX : preciseShellY - drag.startY;
1196
1259
  const nextRatio = Math.max(0.1, Math.min(0.9, drag.startRatio + (delta / Math.max(1, total))));
1197
- const offset = drag.axis === "horizontal"
1198
- ? drag.bounds.width * nextRatio
1199
- : drag.bounds.height * nextRatio;
1200
- const nextRect = drag.axis === "horizontal"
1201
- ? {
1202
- x: nativePaneChrome ? drag.bounds.x + offset - 0.5 : drag.bounds.x + Math.round(offset) - 1,
1203
- y: drag.bounds.y,
1204
- width: 1,
1205
- height: drag.bounds.height,
1206
- }
1207
- : {
1208
- x: drag.bounds.x,
1209
- y: nativePaneChrome ? drag.bounds.y + offset - 0.5 : drag.bounds.y + Math.round(offset) - 1,
1210
- width: drag.bounds.width,
1211
- height: 1,
1212
- };
1260
+ const nextRect = resolveDividerPreviewRect(drag.axis, drag.bounds, nextRatio, nativePaneChrome);
1213
1261
  updateDividerPreview({ pathKey: drag.path.join("."), rect: nextRect, ratio: nextRatio });
1214
1262
  } else if (drag.type === "pane-drag") {
1215
1263
  if (!isMeaningfulPaneDrag(drag.startX, drag.startY, preciseX, preciseShellY, dragThreshold)) {
@@ -1,12 +1,22 @@
1
- import { expect, test } from "bun:test";
1
+ import { afterEach, expect, test } from "bun:test";
2
2
  import {
3
3
  buildTreemap,
4
4
  buildTreemapRects,
5
5
  formatHolderOwnershipPercent,
6
6
  resolveHolderOwnershipPercent,
7
+ tileTextColor,
7
8
  } from "./index";
9
+ import { applyTheme, colors } from "../../../theme/colors";
10
+ import { contrastRatio } from "../../../theme/color-utils";
11
+ import { DEFAULT_THEME, themes } from "../../../theme/themes";
8
12
  import type { HolderRecord } from "../../../types/financials";
9
13
 
14
+ const TILE_TEXT_MIN_CONTRAST = 4.5;
15
+
16
+ afterEach(() => {
17
+ applyTheme(DEFAULT_THEME);
18
+ });
19
+
10
20
  function holder(name: string, value: number): HolderRecord & { id: string } {
11
21
  return {
12
22
  id: name,
@@ -55,3 +65,22 @@ test("resolves holder ownership from provider percent before value over market c
55
65
  expect(resolveHolderOwnershipPercent({ value: 120 }, undefined)).toBeUndefined();
56
66
  expect(formatHolderOwnershipPercent(0.085)).toBe("8.50%");
57
67
  });
68
+
69
+ test("keeps holder treemap text readable on semantic tile backgrounds", () => {
70
+ for (const themeId of Object.keys(themes)) {
71
+ applyTheme(themeId);
72
+ const backgrounds = {
73
+ positive: colors.positive,
74
+ negative: colors.negative,
75
+ neutral: colors.neutral,
76
+ selected: colors.selected,
77
+ };
78
+
79
+ for (const [name, background] of Object.entries(backgrounds)) {
80
+ expect(
81
+ contrastRatio(tileTextColor(background), background),
82
+ `${themeId} ${name}`,
83
+ ).toBeGreaterThanOrEqual(TILE_TEXT_MIN_CONTRAST);
84
+ }
85
+ }
86
+ });
@@ -11,7 +11,7 @@ import {
11
11
  } from "../../../components";
12
12
  import { useShortcut } from "../../../react/input";
13
13
  import { blendHex, colors, priceColor } from "../../../theme/colors";
14
- import { higherContrast } from "../../../theme/color-utils";
14
+ import { blendForContrast, higherContrast } from "../../../theme/color-utils";
15
15
  import type { HolderData, HolderRecord } from "../../../types/financials";
16
16
  import type { GloomPlugin } from "../../../types/plugin";
17
17
  import { formatCompact, formatPercent, formatPercentRaw, padTo } from "../../../utils/format";
@@ -447,12 +447,16 @@ function desktopTileColor(row: HolderRow): string {
447
447
  return blendHex(colors.panel, row.changePercent > 0 ? colors.positive : colors.negative, intensity);
448
448
  }
449
449
 
450
- function tileTextColor(backgroundColor: string): string {
451
- return higherContrast(
450
+ const TILE_TEXT_MIN_CONTRAST = 4.5;
451
+
452
+ export function tileTextColor(backgroundColor: string): string {
453
+ const preferred = higherContrast(
452
454
  higherContrast(colors.text, colors.textBright, backgroundColor),
453
455
  colors.selectedText,
454
456
  backgroundColor,
455
457
  );
458
+ const fallback = higherContrast("#000000", "#ffffff", backgroundColor);
459
+ return blendForContrast(preferred, backgroundColor, fallback, TILE_TEXT_MIN_CONTRAST);
456
460
  }
457
461
 
458
462
  function Tile({ tile, selected, currency, marketCap, onSelect }: {
@@ -5,6 +5,8 @@ import {
5
5
  applyDrop,
6
6
  floatAtRect,
7
7
  gridlockAllPanes,
8
+ getDockDividerLayouts,
9
+ getDockLeafLayouts,
8
10
  getDockedPaneIds,
9
11
  getLeafRect,
10
12
  simulateDrop,
@@ -44,6 +46,35 @@ describe("pane-manager split-tree drops", () => {
44
46
  expect(getDockedPaneIds(next)).toContain(notesPane.instanceId);
45
47
  });
46
48
 
49
+ test("can reserve a gutter for dock dividers without overlapping pane rects", () => {
50
+ const layout = {
51
+ dockRoot: {
52
+ kind: "split" as const,
53
+ axis: "horizontal" as const,
54
+ ratio: 0.5,
55
+ first: { kind: "pane" as const, instanceId: "left:main" },
56
+ second: { kind: "pane" as const, instanceId: "right:main" },
57
+ },
58
+ instances: [
59
+ createPaneInstance("left", { instanceId: "left:main" }),
60
+ createPaneInstance("right", { instanceId: "right:main" }),
61
+ ],
62
+ floating: [],
63
+ detached: [],
64
+ };
65
+
66
+ const leaves = getDockLeafLayouts(layout, BOUNDS, { reserveDividerGutters: true });
67
+ const divider = getDockDividerLayouts(layout, BOUNDS, { reserveDividerGutters: true })[0];
68
+ const left = leaves.find((leaf) => leaf.instanceId === "left:main");
69
+ const right = leaves.find((leaf) => leaf.instanceId === "right:main");
70
+
71
+ expect(left).toBeDefined();
72
+ expect(right).toBeDefined();
73
+ expect(divider).toBeDefined();
74
+ expect(divider!.rect.x).toBe(left!.rect.x + left!.rect.width);
75
+ expect(right!.rect.x).toBe(divider!.rect.x + divider!.rect.width);
76
+ });
77
+
47
78
  test("splits the hovered pane on leaf drops and matches the preview", () => {
48
79
  const config = createDefaultConfig("/tmp/gloomberb-test");
49
80
  const notesPane = createPaneInstance("chat");
@@ -84,6 +84,7 @@ export interface DockDividerLayout {
84
84
  export interface DockGeometryOptions {
85
85
  precise?: boolean;
86
86
  dividerSize?: number;
87
+ reserveDividerGutters?: boolean;
87
88
  }
88
89
 
89
90
  export interface LayoutSimulation {
@@ -430,18 +431,31 @@ function collectDockGeometry(
430
431
 
431
432
  const precise = options.precise === true;
432
433
  const dividerSize = options.dividerSize ?? 1;
434
+ const reserveDividerGutters = options.reserveDividerGutters === true && !precise;
433
435
 
434
436
  if (node.axis === "horizontal") {
435
- const [firstWidth, secondWidth] = resolveSplitSizes(bounds.width, node.ratio, MIN_PANE_WIDTH, precise);
437
+ const reserveDividerGutter = reserveDividerGutters && bounds.width > dividerSize;
438
+ const splitWidth = reserveDividerGutter ? bounds.width - dividerSize : bounds.width;
439
+ const [firstWidth, secondWidth] = resolveSplitSizes(splitWidth, node.ratio, MIN_PANE_WIDTH, precise);
436
440
  const firstBounds = { x: bounds.x, y: bounds.y, width: firstWidth, height: bounds.height };
437
- const secondBounds = { x: bounds.x + firstWidth, y: bounds.y, width: secondWidth, height: bounds.height };
441
+ const dividerX = reserveDividerGutter
442
+ ? bounds.x + firstWidth
443
+ : precise
444
+ ? bounds.x + firstWidth - (dividerSize / 2)
445
+ : bounds.x + firstWidth - 1;
446
+ const secondBounds = {
447
+ x: reserveDividerGutter ? bounds.x + firstWidth + dividerSize : bounds.x + firstWidth,
448
+ y: bounds.y,
449
+ width: secondWidth,
450
+ height: bounds.height,
451
+ };
438
452
  dividers.push({
439
453
  path,
440
454
  axis: node.axis,
441
455
  bounds: { ...bounds },
442
456
  ratio: node.ratio,
443
457
  rect: {
444
- x: precise ? bounds.x + firstWidth - (dividerSize / 2) : bounds.x + firstWidth - 1,
458
+ x: dividerX,
445
459
  y: bounds.y,
446
460
  width: dividerSize,
447
461
  height: bounds.height,
@@ -452,9 +466,21 @@ function collectDockGeometry(
452
466
  return { leaves, dividers };
453
467
  }
454
468
 
455
- const [firstHeight, secondHeight] = resolveSplitSizes(bounds.height, node.ratio, MIN_DOCKED_HEIGHT, precise);
469
+ const reserveDividerGutter = reserveDividerGutters && bounds.height > dividerSize;
470
+ const splitHeight = reserveDividerGutter ? bounds.height - dividerSize : bounds.height;
471
+ const [firstHeight, secondHeight] = resolveSplitSizes(splitHeight, node.ratio, MIN_DOCKED_HEIGHT, precise);
456
472
  const firstBounds = { x: bounds.x, y: bounds.y, width: bounds.width, height: firstHeight };
457
- const secondBounds = { x: bounds.x, y: bounds.y + firstHeight, width: bounds.width, height: secondHeight };
473
+ const dividerY = reserveDividerGutter
474
+ ? bounds.y + firstHeight
475
+ : precise
476
+ ? bounds.y + firstHeight - (dividerSize / 2)
477
+ : bounds.y + firstHeight - 1;
478
+ const secondBounds = {
479
+ x: bounds.x,
480
+ y: reserveDividerGutter ? bounds.y + firstHeight + dividerSize : bounds.y + firstHeight,
481
+ width: bounds.width,
482
+ height: secondHeight,
483
+ };
458
484
  dividers.push({
459
485
  path,
460
486
  axis: node.axis,
@@ -462,7 +488,7 @@ function collectDockGeometry(
462
488
  ratio: node.ratio,
463
489
  rect: {
464
490
  x: bounds.x,
465
- y: precise ? bounds.y + firstHeight - (dividerSize / 2) : bounds.y + firstHeight - 1,
491
+ y: dividerY,
466
492
  width: bounds.width,
467
493
  height: dividerSize,
468
494
  },
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync } from "fs";
2
- import { join } from "path";
2
+ import { dirname, join } from "path";
3
3
  import { Buffer } from "node:buffer";
4
4
  import Electrobun, { ApplicationMenu, BrowserView, BrowserWindow, Utils, ContextMenu, type UpdateStatusEntry } from "electrobun/bun";
5
5
  import {
@@ -17,7 +17,7 @@ import type { DesktopDockPreviewState, DesktopSharedStateSnapshot, DesktopThemeP
17
17
  import type { ReleaseInfo, UpdateCheckResult, UpdateProgress } from "../../../updater";
18
18
  import { buildSoundCommand } from "../../../notifications/app-notifier";
19
19
  import { isPaneDetached } from "../../../plugins/pane-manager";
20
- import { ELECTROBUN_CONTEXT_MENU_ACTION, type ElectrobunDesktopRpcSchema } from "../shared/protocol";
20
+ import { ELECTROBUN_CONTEXT_MENU_ACTION, type DesktopRestartMessage, type ElectrobunDesktopRpcSchema } from "../shared/protocol";
21
21
  import { decodeRpcValue, encodeRpcValue } from "../view/rpc-codec";
22
22
  import { contextMenuSelectionMessage } from "./context-menu-click";
23
23
  import { getContextMenuRequestId, normalizeContextMenuItems } from "./context-menu-normalize";
@@ -65,6 +65,7 @@ let desktopWorkspace: DesktopWorkspace | null = null;
65
65
  let currentDockPreview: DesktopDockPreviewState = { paneId: null, edge: null };
66
66
  let currentThemePreview: DesktopThemePreviewState = { theme: null };
67
67
  let desktopUpdateInProgress = false;
68
+ let desktopRestartInProgress = false;
68
69
 
69
70
  const detachedWindows = new Map<string, BrowserWindow>();
70
71
  const detachedFrameTimers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -82,6 +83,19 @@ function summarizeError(error: unknown): string {
82
83
  return error instanceof Error ? error.stack ?? error.message : String(error);
83
84
  }
84
85
 
86
+ function shellQuote(value: string): string {
87
+ return `'${value.replaceAll("'", "'\\''")}'`;
88
+ }
89
+
90
+ function findMacAppBundle(execPath: string): string | null {
91
+ let current = dirname(execPath);
92
+ while (current && current !== dirname(current)) {
93
+ if (current.endsWith(".app")) return current;
94
+ current = dirname(current);
95
+ }
96
+ return null;
97
+ }
98
+
85
99
  function requireServices(): AppServices {
86
100
  if (!services) throw new Error("Backend services have not been initialized.");
87
101
  return services;
@@ -633,6 +647,62 @@ function teardownServices(): void {
633
647
  services = null;
634
648
  }
635
649
 
650
+ function spawnDetached(command: string[]): void {
651
+ const child = Bun.spawn(command, {
652
+ detached: true,
653
+ stdio: ["ignore", "ignore", "ignore"],
654
+ } as any);
655
+ child.unref();
656
+ }
657
+
658
+ function scheduleDesktopRelaunch(): void {
659
+ const pid = process.pid;
660
+ const macAppBundle = process.platform === "darwin" ? findMacAppBundle(process.execPath) : null;
661
+
662
+ if (macAppBundle) {
663
+ spawnDetached([
664
+ "sh",
665
+ "-c",
666
+ `while kill -0 ${pid} 2>/dev/null; do sleep 0.25; done; sleep 0.5; open ${shellQuote(macAppBundle)}`,
667
+ ]);
668
+ return;
669
+ }
670
+
671
+ if (process.platform === "win32") {
672
+ spawnDetached([process.execPath, ...process.argv.slice(1)]);
673
+ return;
674
+ }
675
+
676
+ const args = process.argv.slice(1).map(shellQuote).join(" ");
677
+ spawnDetached([
678
+ "sh",
679
+ "-c",
680
+ `while kill -0 ${pid} 2>/dev/null; do sleep 0.25; done; cd ${shellQuote(process.cwd())}; ${shellQuote(process.execPath)}${args ? ` ${args}` : ""}`,
681
+ ]);
682
+ }
683
+
684
+ function restartDesktopApp(message: DesktopRestartMessage = {}): void {
685
+ if (desktopRestartInProgress) return;
686
+ desktopRestartInProgress = true;
687
+ console.error("[desktop-recovery] restart requested", {
688
+ reason: message.reason,
689
+ source: message.source,
690
+ pid: process.pid,
691
+ execPath: process.execPath,
692
+ argv: process.argv,
693
+ });
694
+ try {
695
+ scheduleDesktopRelaunch();
696
+ } catch (error) {
697
+ desktopRestartInProgress = false;
698
+ console.error("[desktop-recovery] failed to schedule restart", summarizeError(error));
699
+ throw error;
700
+ }
701
+ closeAllDetachedWindows();
702
+ teardownServices();
703
+ Utils.quit();
704
+ }
705
+
636
706
  function resolveDetachedWindowTitle(instanceId: string): string {
637
707
  const instance = currentConfig ? findPaneInstance(currentConfig.layout, instanceId) : null;
638
708
  if (!instance) return "Gloomberb";
@@ -1087,6 +1157,12 @@ async function handleBackendRequest(
1087
1157
  requireServices().persistence.pluginState.delete(payload.pluginId as string, payload.key as string);
1088
1158
  syncBackendCloudAuthState(payload.pluginId as string, payload.key as string, null);
1089
1159
  return null;
1160
+ case "host.restart":
1161
+ restartDesktopApp({
1162
+ reason: typeof payload.reason === "string" ? payload.reason : undefined,
1163
+ source: typeof payload.source === "string" ? payload.source : "backend-request",
1164
+ });
1165
+ return null;
1090
1166
  case "host.exit":
1091
1167
  closeAllDetachedWindows();
1092
1168
  teardownServices();
@@ -1153,7 +1229,11 @@ function createWindowRpc(key: string): DesktopRpc {
1153
1229
  requests: {
1154
1230
  "backend.request": async ({ method, payload }) => encodeRpcValue(await handleBackendRequest(rpc, method, payload)),
1155
1231
  },
1156
- messages: {},
1232
+ messages: {
1233
+ "host.restart": (message) => {
1234
+ restartDesktopApp(message);
1235
+ },
1236
+ },
1157
1237
  },
1158
1238
  });
1159
1239
  registerWindowRpc(key, rpc);
@@ -53,6 +53,11 @@ export interface CapabilityEventMessage {
53
53
  event: unknown;
54
54
  }
55
55
 
56
+ export interface DesktopRestartMessage {
57
+ reason?: string;
58
+ source?: string;
59
+ }
60
+
56
61
  export interface ElectrobunDesktopRpcSchema {
57
62
  bun: {
58
63
  requests: {
@@ -61,7 +66,9 @@ export interface ElectrobunDesktopRpcSchema {
61
66
  response: unknown;
62
67
  };
63
68
  };
64
- messages: {};
69
+ messages: {
70
+ "host.restart": DesktopRestartMessage;
71
+ };
65
72
  };
66
73
  webview: {
67
74
  requests: {};
@@ -5,6 +5,7 @@ import {
5
5
  type CapabilityEventMessage,
6
6
  type ContextMenuSelectMessage,
7
7
  type DesktopDockPreviewMessage,
8
+ type DesktopRestartMessage,
8
9
  type DesktopStateMessage,
9
10
  type DesktopThemePreviewMessage,
10
11
  type ElectrobunBackendInit,
@@ -131,6 +132,10 @@ export async function initElectrobunBackend(payload?: { kind?: "main" | "detache
131
132
  return initSnapshot;
132
133
  }
133
134
 
135
+ export function requestElectrobunRestart(message: DesktopRestartMessage = {}): void {
136
+ rpc.send["host.restart"](message);
137
+ }
138
+
134
139
  export function getElectrobunBackendInitSnapshot(): ElectrobunBackendInit | null {
135
140
  return initSnapshot;
136
141
  }
@@ -0,0 +1,118 @@
1
+ /// <reference lib="dom" />
2
+ /** @jsxImportSource react */
3
+ import { Component, useMemo, useState, type ErrorInfo, type ReactNode } from "react";
4
+ import { backendRequest, requestElectrobunRestart } from "./backend-rpc";
5
+
6
+ declare global {
7
+ interface Window {
8
+ __gloomRenderFatalError?: (error: unknown, details?: string) => void;
9
+ }
10
+ }
11
+
12
+ interface DesktopFatalScreenProps {
13
+ title?: string;
14
+ error: unknown;
15
+ details?: string;
16
+ source: string;
17
+ }
18
+
19
+ interface ElectrobunErrorBoundaryState {
20
+ hasError: boolean;
21
+ error: unknown;
22
+ details?: string;
23
+ }
24
+
25
+ export function formatFatalError(error: unknown, details?: string): string {
26
+ const message = error instanceof Error ? error.stack ?? error.message : String(error);
27
+ return [message, details].filter((value): value is string => Boolean(value)).join("\n");
28
+ }
29
+
30
+ export function DesktopFatalScreen({
31
+ title = "Gloomberb crashed",
32
+ error,
33
+ details,
34
+ source,
35
+ }: DesktopFatalScreenProps) {
36
+ const [status, setStatus] = useState<string | null>(null);
37
+ const errorText = useMemo(() => formatFatalError(error, details), [details, error]);
38
+
39
+ const reloadWindow = () => {
40
+ window.location.reload();
41
+ };
42
+
43
+ const restartApp = () => {
44
+ setStatus("Restart requested...");
45
+ try {
46
+ requestElectrobunRestart({
47
+ source,
48
+ reason: errorText.slice(0, 240),
49
+ });
50
+ window.setTimeout(() => {
51
+ setStatus("If the app stays open, quit it and open it again.");
52
+ }, 2500);
53
+ } catch (restartError) {
54
+ setStatus(`Restart request failed: ${formatFatalError(restartError)}`);
55
+ }
56
+ };
57
+
58
+ const copyError = async () => {
59
+ try {
60
+ if (navigator.clipboard?.writeText) {
61
+ await navigator.clipboard.writeText(errorText);
62
+ } else {
63
+ await backendRequest("host.copyText", { text: errorText });
64
+ }
65
+ setStatus("Error copied.");
66
+ } catch (copyError) {
67
+ setStatus(`Copy failed: ${formatFatalError(copyError)}`);
68
+ }
69
+ };
70
+
71
+ return (
72
+ <div className="gloom-fatal">
73
+ <h1>{title}</h1>
74
+ <div className="gloom-fatal-actions">
75
+ <button type="button" data-variant="primary" onClick={restartApp}>Restart app</button>
76
+ <button type="button" onClick={reloadWindow}>Reload window</button>
77
+ <button type="button" onClick={copyError}>Copy error</button>
78
+ </div>
79
+ {status && <div className="gloom-fatal-status" aria-live="polite">{status}</div>}
80
+ <pre>{errorText}</pre>
81
+ </div>
82
+ );
83
+ }
84
+
85
+ export class ElectrobunErrorBoundary extends Component<
86
+ { children: ReactNode },
87
+ ElectrobunErrorBoundaryState
88
+ > {
89
+ override state: ElectrobunErrorBoundaryState = { hasError: false, error: null };
90
+
91
+ static getDerivedStateFromError(error: unknown): ElectrobunErrorBoundaryState {
92
+ return { hasError: true, error };
93
+ }
94
+
95
+ override componentDidCatch(error: unknown, errorInfo: ErrorInfo): void {
96
+ console.error("[desktop-recovery] renderer error boundary", error, errorInfo.componentStack);
97
+ this.setState({
98
+ hasError: true,
99
+ error,
100
+ details: errorInfo.componentStack ?? undefined,
101
+ });
102
+ }
103
+
104
+ override render(): ReactNode {
105
+ if (this.state.hasError) {
106
+ return (
107
+ <DesktopFatalScreen
108
+ title="Gloomberb crashed"
109
+ error={this.state.error}
110
+ details={this.state.details}
111
+ source="react-error-boundary"
112
+ />
113
+ );
114
+ }
115
+
116
+ return this.props.children;
117
+ }
118
+ }
@@ -15,6 +15,7 @@ import {
15
15
  installElectrobunHttpFetchTransport,
16
16
  } from "./http-fetch";
17
17
  import { installElectrobunUpdateHost } from "./update-host";
18
+ import { DesktopFatalScreen, ElectrobunErrorBoundary } from "./fatal-screen";
18
19
  import { WebInputHostProvider } from "./input-host";
19
20
  import { webNativeRenderer } from "./native-renderer";
20
21
  import { WebToastHostProvider } from "./toast-host";
@@ -27,16 +28,32 @@ const rootElement = document.getElementById("root");
27
28
  if (!rootElement) {
28
29
  throw new Error("Missing root element");
29
30
  }
31
+ const appRootElement = rootElement;
30
32
 
31
- const root = createRoot(rootElement);
33
+ const root = createRoot(appRootElement);
32
34
  const bootLog = debugLog.createLogger("electrobun-web-boot");
33
35
 
34
- rootElement.tabIndex = -1;
36
+ appRootElement.tabIndex = -1;
35
37
  root.render(<div className="gloom-loading">Starting Gloomberb...</div>);
36
38
 
39
+ function renderFatalError(error: unknown, details?: string, title = "Gloomberb failed to start"): void {
40
+ root.render(
41
+ <DesktopFatalScreen
42
+ title={title}
43
+ error={error}
44
+ details={details}
45
+ source="renderer-fatal"
46
+ />,
47
+ );
48
+ }
49
+
50
+ window.__gloomRenderFatalError = (error, details) => {
51
+ renderFatalError(error, details, "Gloomberb crashed");
52
+ };
53
+
37
54
  function focusWebSurface(): void {
38
55
  window.focus();
39
- rootElement.focus({ preventScroll: true });
56
+ appRootElement.focus({ preventScroll: true });
40
57
  }
41
58
 
42
59
  function requestStartupFocus(): void {
@@ -69,21 +86,23 @@ async function boot() {
69
86
  const desktopApplicationMenuBridge = createApplicationMenuBridge();
70
87
  measurePerfAsync("startup.electrobun.root-render", async () => {
71
88
  root.render(
72
- <UiHostProvider ui={webUiHost} renderer={webRendererHost} nativeRenderer={webNativeRenderer}>
73
- <WebInputHostProvider>
74
- <WebToastHostProvider>
75
- <WebDialogHostProvider>
76
- <App
77
- config={config}
78
- desktopWindowBridge={desktopWindowBridge}
79
- desktopApplicationMenuBridge={desktopApplicationMenuBridge}
80
- desktopSnapshot={desktopSnapshot}
81
- desktopThemePreview={init.desktopThemePreview}
82
- />
83
- </WebDialogHostProvider>
84
- </WebToastHostProvider>
85
- </WebInputHostProvider>
86
- </UiHostProvider>,
89
+ <ElectrobunErrorBoundary>
90
+ <UiHostProvider ui={webUiHost} renderer={webRendererHost} nativeRenderer={webNativeRenderer}>
91
+ <WebInputHostProvider>
92
+ <WebToastHostProvider>
93
+ <WebDialogHostProvider>
94
+ <App
95
+ config={config}
96
+ desktopWindowBridge={desktopWindowBridge}
97
+ desktopApplicationMenuBridge={desktopApplicationMenuBridge}
98
+ desktopSnapshot={desktopSnapshot}
99
+ desktopThemePreview={init.desktopThemePreview}
100
+ />
101
+ </WebDialogHostProvider>
102
+ </WebToastHostProvider>
103
+ </WebInputHostProvider>
104
+ </UiHostProvider>
105
+ </ElectrobunErrorBoundary>,
87
106
  );
88
107
  });
89
108
  requestStartupFocus();
@@ -96,10 +115,5 @@ async function boot() {
96
115
  }
97
116
 
98
117
  boot().catch((error) => {
99
- root.render(
100
- <div className="gloom-fatal">
101
- <h1>Gloomberb failed to start</h1>
102
- <pre>{error instanceof Error ? error.stack ?? error.message : String(error)}</pre>
103
- </div>,
104
- );
118
+ renderFatalError(error);
105
119
  });
@@ -1,5 +1,5 @@
1
1
  @keyframes gloom-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
2
- html, body, #root { width: 100%; height: 100%; min-height: 100%; overflow: hidden; background: var(--gloom-bg); }
2
+ html, body, #root { width: 100%; height: 100%; min-height: 100%; overflow: hidden; background: var(--gloom-bg, #272a34); }
3
3
  #root:focus { outline: none; }
4
4
  body {
5
5
  --cell-w: 8px;
@@ -8,7 +8,7 @@ body {
8
8
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
9
9
  font-size: 12px;
10
10
  line-height: var(--cell-h);
11
- color: var(--gloom-text);
11
+ color: var(--gloom-text, #f2f2f2);
12
12
  cursor: default;
13
13
  user-select: none;
14
14
  color-scheme: dark;
@@ -284,8 +284,52 @@ body.gloom-dragging {
284
284
  top: 50%;
285
285
  height: 1px;
286
286
  }
287
- .gloom-loading, .gloom-fatal { padding: 24px; color: var(--gloom-text); }
288
- .gloom-fatal pre { white-space: pre-wrap; color: var(--gloom-negative); }
287
+ .gloom-loading, .gloom-fatal {
288
+ padding: 24px;
289
+ color: var(--gloom-text, #f2f2f2);
290
+ background: var(--gloom-bg, #272a34);
291
+ }
292
+ .gloom-fatal {
293
+ display: flex;
294
+ flex-direction: column;
295
+ gap: 14px;
296
+ overflow: auto;
297
+ }
298
+ .gloom-fatal h1 {
299
+ margin: 0 0 6px;
300
+ font-size: 28px;
301
+ line-height: 1.25;
302
+ }
303
+ .gloom-fatal-actions {
304
+ display: flex;
305
+ flex-wrap: wrap;
306
+ gap: 8px;
307
+ }
308
+ .gloom-fatal button {
309
+ border: 1px solid var(--gloom-border, #5b6070);
310
+ border-radius: 6px;
311
+ padding: 5px 10px;
312
+ color: var(--gloom-text, #f2f2f2);
313
+ background: var(--gloom-panel, #1f222b);
314
+ }
315
+ .gloom-fatal button:hover {
316
+ background: color-mix(in srgb, var(--gloom-text-bright, #fff) 10%, var(--gloom-panel, #1f222b));
317
+ }
318
+ .gloom-fatal button[data-variant="primary"] {
319
+ border-color: var(--gloom-border-focused, #8cb4ff);
320
+ color: var(--gloom-bg, #111318);
321
+ background: var(--gloom-border-focused, #8cb4ff);
322
+ }
323
+ .gloom-fatal-status {
324
+ min-height: var(--cell-h);
325
+ color: var(--gloom-text-dim, #a7acba);
326
+ }
327
+ .gloom-fatal pre {
328
+ margin: 0;
329
+ white-space: pre-wrap;
330
+ color: var(--gloom-negative, #ff5f5f);
331
+ user-select: text;
332
+ }
289
333
  .gloom-toast-viewport {
290
334
  position: fixed;
291
335
  right: 16px;
@@ -84,6 +84,12 @@ export type ResolvedTickerSearch =
84
84
  | { kind: "local"; symbol: string; ticker: TickerRecord }
85
85
  | { kind: "provider"; symbol: string; result: InstrumentSearchResult };
86
86
 
87
+ export interface TickerOpenTarget {
88
+ symbol: string;
89
+ ticker: TickerRecord;
90
+ created: boolean;
91
+ }
92
+
87
93
  interface SearchQueryIntent {
88
94
  normalizedQuery: string;
89
95
  compactQuery: string;
@@ -297,6 +303,71 @@ export async function upsertTickerFromSearchResult(
297
303
  return { ticker, created };
298
304
  }
299
305
 
306
+ export async function resolveTickerOpenTarget({
307
+ query,
308
+ tickers,
309
+ dataProvider,
310
+ tickerRepository,
311
+ searchContext,
312
+ }: {
313
+ query: string;
314
+ tickers: ReadonlyMap<string, TickerRecord>;
315
+ dataProvider: DataProvider;
316
+ tickerRepository: TickerRepository;
317
+ searchContext?: SearchRequestContext;
318
+ }): Promise<TickerOpenTarget | null> {
319
+ const symbol = normalizeTickerInput(null, query);
320
+ if (!symbol) return null;
321
+
322
+ let resolved: ResolvedTickerSearch | null = null;
323
+ try {
324
+ resolved = await resolveTickerSearch({
325
+ query: symbol,
326
+ activeTicker: null,
327
+ tickers,
328
+ dataProvider,
329
+ searchContext,
330
+ });
331
+ } catch {
332
+ resolved = null;
333
+ }
334
+
335
+ if (resolved?.kind === "local") {
336
+ return { symbol: resolved.symbol, ticker: resolved.ticker, created: false };
337
+ }
338
+
339
+ if (resolved?.kind === "provider") {
340
+ const { ticker, created } = await upsertTickerFromSearchResult(tickerRepository, resolved.result);
341
+ return { symbol: ticker.metadata.ticker, ticker, created };
342
+ }
343
+
344
+ try {
345
+ const quote = await dataProvider.getQuote(symbol, "");
346
+ const quoteSymbol = normalizeTickerSymbol(quote.symbol || symbol);
347
+ const existing = await tickerRepository.loadTicker(quoteSymbol);
348
+ if (existing) {
349
+ return { symbol: existing.metadata.ticker, ticker: existing, created: false };
350
+ }
351
+
352
+ const ticker = await tickerRepository.createTicker({
353
+ ticker: quoteSymbol,
354
+ exchange: quote.listingExchangeName ?? quote.exchangeName ?? "",
355
+ currency: quote.currency || "USD",
356
+ name: quote.name || quoteSymbol,
357
+ assetCategory: undefined,
358
+ portfolios: [],
359
+ watchlists: [],
360
+ positions: [],
361
+ custom: {},
362
+ tags: [],
363
+ });
364
+
365
+ return { symbol: ticker.metadata.ticker, ticker, created: true };
366
+ } catch {
367
+ return null;
368
+ }
369
+ }
370
+
300
371
  export function findExactTickerSearchMatch<T extends Pick<TickerSearchRankableItem, "label"> & Partial<TickerSearchRankableItem>>(
301
372
  items: T[],
302
373
  query: string,
@@ -618,7 +689,7 @@ function analyzeSearchQuery(query: string): SearchQueryIntent {
618
689
  .map((token) => ASSET_HINT_MAP[token])
619
690
  .filter((token): token is TickerSearchInstrumentClass => token != null),
620
691
  ));
621
- const assetPreference = assetHints.length === 1 ? assetHints[0] : null;
692
+ const assetPreference = assetHints.length === 1 ? assetHints[0]! : null;
622
693
 
623
694
  const companyTokens = tokens.filter((token) => !(token in EXCHANGE_HINT_ALIASES) && !(token in ASSET_HINT_MAP));
624
695
  const companyQuery = companyTokens.join(" ");
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.7.4";
1
+ export const VERSION = "0.7.5";