gloomberb 0.17.0 → 0.17.2

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.
Files changed (201) hide show
  1. package/package.json +1 -1
  2. package/src/{plugins/builtin/cloud → account}/auth-dialog.tsx +7 -7
  3. package/src/api-client/data.ts +5 -1
  4. package/src/api-client/doe.ts +19 -0
  5. package/src/api-client/earnings.ts +4 -2
  6. package/src/api-client/index.ts +1 -0
  7. package/src/app/global-shortcuts.ts +10 -0
  8. package/src/app/keybindings/actions.ts +16 -0
  9. package/src/app/runtime/desktop-deeplink.ts +1 -1
  10. package/src/app/runtime/update.ts +16 -0
  11. package/src/app.tsx +1 -1
  12. package/src/architecture/core-builtin-allowlist.json +4 -9
  13. package/src/architecture/dom-cell-metrics-allowlist.json +100 -26
  14. package/src/cli/commands/market.ts +87 -17
  15. package/src/cli/commands/options-chain.ts +93 -23
  16. package/src/cli/commands/options-leaps.ts +462 -63
  17. package/src/cli/commands/search.ts +10 -7
  18. package/src/cli/commands/ticker.ts +19 -10
  19. package/src/cli/glossary.ts +1 -1
  20. package/src/cli/index.ts +14 -6
  21. package/src/cli/known-gaps.ts +22 -0
  22. package/src/cli/pane-functions/catalog.ts +45 -10
  23. package/src/cli/pane-functions/dom.ts +36 -10
  24. package/src/cli/pane-functions/function-help.ts +4 -2
  25. package/src/cli/pane-functions/headless.ts +12 -1
  26. package/src/cli/pane-functions/index.ts +44 -3
  27. package/src/cli/pane-functions/lock-note.ts +56 -0
  28. package/src/cli/pane-functions/report-notices.ts +15 -0
  29. package/src/cli/symbol-args.ts +19 -0
  30. package/src/components/command-bar/list/view.tsx +2 -1
  31. package/src/components/command-bar/workflow/builtin.ts +2 -2
  32. package/src/components/command-bar/workflow/collection-actions.ts +1 -1
  33. package/src/components/feedback-dialog.tsx +1 -1
  34. package/src/components/index.ts +3 -2
  35. package/src/components/layout/header.tsx +58 -14
  36. package/src/components/layout/shell/chrome.ts +10 -4
  37. package/src/components/layout/shell/index.tsx +1 -1
  38. package/src/components/layout/status-bar-groups.ts +1 -1
  39. package/src/components/layout/status-bar.tsx +2 -2
  40. package/src/components/markdown-text.tsx +14 -2
  41. package/src/components/reader/article-reader.tsx +1 -1
  42. package/src/components/reader/detail-reader.tsx +10 -7
  43. package/src/components/reader/reader-text.tsx +32 -17
  44. package/src/components/ui/column-grid.tsx +206 -0
  45. package/src/components/ui/display.tsx +40 -3
  46. package/src/components/ui/fill.ts +8 -0
  47. package/src/components/ui/index.ts +2 -0
  48. package/src/layout-marketplace/gallery.tsx +2 -2
  49. package/src/layout-marketplace/linked-sync.ts +1 -1
  50. package/src/market-data/market/freshness.ts +3 -0
  51. package/src/market-data/published-apac-sessions.ts +12 -2
  52. package/src/market-data/quotes/screener-live-quotes.ts +31 -7
  53. package/src/plugins/builtin/account-management/form-components.tsx +4 -7
  54. package/src/plugins/builtin/account-management/password-dialog.tsx +3 -2
  55. package/src/plugins/builtin/backtest/pane.tsx +9 -3
  56. package/src/plugins/builtin/buildout/detail/company.tsx +4 -3
  57. package/src/plugins/builtin/buildout/detail/ui.tsx +9 -12
  58. package/src/plugins/builtin/chart-composer/index.tsx +1 -1
  59. package/src/plugins/builtin/chart-composer/presets.ts +33 -1
  60. package/src/plugins/builtin/chat/channel-labels.ts +2 -2
  61. package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -1
  62. package/src/plugins/builtin/chat/content/index.tsx +2 -2
  63. package/src/plugins/builtin/chat/conversations.ts +1 -1
  64. package/src/plugins/builtin/chat/index.tsx +2 -2
  65. package/src/plugins/builtin/chat/share/flow.tsx +1 -1
  66. package/src/plugins/builtin/chat/sidebar-rows.ts +1 -1
  67. package/src/plugins/builtin/chat/sidebar.tsx +2 -2
  68. package/src/plugins/builtin/chat/unread-inbox-pane.tsx +2 -2
  69. package/src/plugins/builtin/cloud/askg/pane.tsx +2 -1
  70. package/src/plugins/builtin/cloud/askg/tool-timeline.tsx +3 -2
  71. package/src/plugins/builtin/cloud/auth-actions.tsx +1 -1
  72. package/src/plugins/builtin/cloud/auth-commands.ts +1 -1
  73. package/src/plugins/builtin/cloud/mcp-connect/dialog.tsx +1 -1
  74. package/src/plugins/builtin/cloud/team/acm-tab.tsx +3 -3
  75. package/src/plugins/builtin/cloud/team/capability.ts +2 -2
  76. package/src/plugins/builtin/cloud/team/command.ts +4 -4
  77. package/src/plugins/builtin/cloud/team/module.tsx +3 -3
  78. package/src/plugins/builtin/cloud/team/pane-model.ts +2 -2
  79. package/src/plugins/builtin/cloud/team/pane-sections.tsx +1 -1
  80. package/src/plugins/builtin/cloud/team/pane-ui.tsx +1 -1
  81. package/src/plugins/builtin/cloud/team/pane.tsx +3 -3
  82. package/src/plugins/builtin/cloud/team/status-widget.tsx +3 -3
  83. package/src/plugins/builtin/cloud/team/team-state-host.ts +1 -1
  84. package/src/plugins/builtin/cloud/team/views.ts +2 -2
  85. package/src/plugins/builtin/cloud/thesis/board-pane.tsx +1 -1
  86. package/src/plugins/builtin/cloud/thesis/detail.tsx +12 -10
  87. package/src/plugins/builtin/cloud/thesis/ticker-tab.tsx +1 -1
  88. package/src/plugins/builtin/cloud/upgrade-command.ts +1 -1
  89. package/src/plugins/builtin/congress-trades/detail.tsx +8 -4
  90. package/src/plugins/builtin/connections/pane.tsx +16 -5
  91. package/src/plugins/builtin/cpi/pane.tsx +5 -4
  92. package/src/plugins/builtin/custom-view/pane.tsx +2 -2
  93. package/src/plugins/builtin/dividend-yield/headless.ts +8 -2
  94. package/src/plugins/builtin/dividend-yield/pane.tsx +4 -2
  95. package/src/plugins/builtin/docs/blocks.tsx +13 -2
  96. package/src/plugins/builtin/docs/pane.tsx +6 -5
  97. package/src/plugins/builtin/doe/index.tsx +11 -4
  98. package/src/plugins/builtin/doe/pane.tsx +5 -4
  99. package/src/plugins/builtin/doe/prices.ts +84 -0
  100. package/src/plugins/builtin/doe/series.ts +4 -1
  101. package/src/plugins/builtin/earnings/board-model.ts +18 -1
  102. package/src/plugins/builtin/earnings/board-table.ts +18 -3
  103. package/src/plugins/builtin/earnings/headless.ts +13 -2
  104. package/src/plugins/builtin/earnings/history-model.ts +1 -1
  105. package/src/plugins/builtin/earnings/history-table.ts +2 -2
  106. package/src/plugins/builtin/earnings-calls/headless.ts +7 -21
  107. package/src/plugins/builtin/earnings-calls/index.tsx +2 -0
  108. package/src/plugins/builtin/earnings-calls/lock-note.ts +17 -0
  109. package/src/plugins/builtin/equity-screener/headless.ts +59 -1
  110. package/src/plugins/builtin/equity-screener/live.ts +82 -31
  111. package/src/plugins/builtin/equity-screener/pane.tsx +60 -23
  112. package/src/plugins/builtin/estimate-revisions/client.ts +1 -1
  113. package/src/plugins/builtin/estimate-revisions/headless.ts +9 -2
  114. package/src/plugins/builtin/estimate-revisions/index.tsx +3 -0
  115. package/src/plugins/builtin/estimate-revisions/lock-note.ts +23 -0
  116. package/src/plugins/builtin/executives/index.tsx +18 -0
  117. package/src/plugins/builtin/exposure/charts.tsx +4 -3
  118. package/src/plugins/builtin/filing-events/index.tsx +8 -10
  119. package/src/plugins/builtin/fixed-income/pane.tsx +2 -1
  120. package/src/plugins/builtin/gpu/equities.tsx +9 -6
  121. package/src/plugins/builtin/help/feedback-tab.tsx +1 -1
  122. package/src/plugins/builtin/iv-history/format.ts +3 -0
  123. package/src/plugins/builtin/iv-history/headless.ts +3 -1
  124. package/src/plugins/builtin/iv-history/index.tsx +3 -1
  125. package/src/plugins/builtin/iv-history/model.ts +3 -1
  126. package/src/plugins/builtin/iv-history/screen-pane.tsx +3 -1
  127. package/src/plugins/builtin/iv-history/universe.ts +1 -1
  128. package/src/plugins/builtin/jobs/headless.ts +6 -20
  129. package/src/plugins/builtin/jobs/index.tsx +2 -0
  130. package/src/plugins/builtin/jobs/lock-note.ts +16 -0
  131. package/src/plugins/builtin/notes/index.tsx +1 -1
  132. package/src/plugins/builtin/notes/owner.tsx +2 -2
  133. package/src/plugins/builtin/options/carry.ts +36 -0
  134. package/src/plugins/builtin/options/index.tsx +2 -1
  135. package/src/plugins/builtin/options/model-spot.ts +94 -0
  136. package/src/plugins/builtin/options/open-interest-as-of.ts +74 -0
  137. package/src/plugins/builtin/options/view.tsx +69 -11
  138. package/src/plugins/builtin/options-calculator/pane.tsx +56 -1
  139. package/src/plugins/builtin/options-calculator/premium-history.ts +34 -0
  140. package/src/plugins/builtin/options-scenario/client.ts +33 -9
  141. package/src/plugins/builtin/options-scenario/csp.ts +168 -0
  142. package/src/plugins/builtin/options-scenario/evidence.ts +3 -0
  143. package/src/plugins/builtin/options-scenario/headless.ts +24 -8
  144. package/src/plugins/builtin/options-scenario/index.tsx +3 -1
  145. package/src/plugins/builtin/options-scenario/pane.tsx +32 -6
  146. package/src/plugins/builtin/portfolio-list/mutations.ts +1 -1
  147. package/src/plugins/builtin/portfolio-list/pane/remove-ticker.ts +1 -1
  148. package/src/plugins/builtin/research/corporate-actions-pane.tsx +4 -2
  149. package/src/plugins/builtin/research/event-model.ts +7 -4
  150. package/src/plugins/builtin/risk-factors/index.tsx +18 -0
  151. package/src/plugins/builtin/risk-factors/pane.tsx +2 -1
  152. package/src/plugins/builtin/scanner/headless.ts +9 -2
  153. package/src/plugins/builtin/scanner/index.ts +3 -0
  154. package/src/plugins/builtin/scanner/lock-note.ts +17 -0
  155. package/src/plugins/builtin/series-inventory.ts +2 -1
  156. package/src/plugins/builtin/shared/cloud-denied.ts +39 -8
  157. package/src/plugins/builtin/shared/earnings-report.ts +10 -4
  158. package/src/plugins/builtin/shared/pro-lock-note.ts +58 -0
  159. package/src/plugins/builtin/shared/series-list-detail.tsx +10 -5
  160. package/src/plugins/builtin/social-mentions/client.ts +3 -3
  161. package/src/plugins/builtin/social-mentions/headless.ts +6 -2
  162. package/src/plugins/builtin/social-mentions/index.tsx +2 -1
  163. package/src/plugins/builtin/social-mentions/lock-note.ts +15 -0
  164. package/src/plugins/builtin/supply-chain/flow.tsx +15 -13
  165. package/src/plugins/builtin/supply-chain/graph.tsx +6 -4
  166. package/src/plugins/builtin/supply-chain/pane.tsx +8 -5
  167. package/src/plugins/builtin/ticker-detail/financials/aggregation.ts +14 -1
  168. package/src/plugins/builtin/ticker-detail/financials/ratios.ts +9 -7
  169. package/src/plugins/builtin/ticker-detail/headless.ts +4 -1
  170. package/src/plugins/builtin/ticker-detail/overview/components.tsx +105 -104
  171. package/src/plugins/builtin/ticker-detail/overview/model.ts +9 -2
  172. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +34 -8
  173. package/src/plugins/builtin/vol-surface/pane.tsx +6 -3
  174. package/src/public/__snapshots__/public-api.test.ts.snap +2 -0
  175. package/src/renderers/dom/data-table/index.tsx +48 -15
  176. package/src/renderers/dom/data-table/row.tsx +1 -1
  177. package/src/renderers/dom/desktop/popover.tsx +6 -15
  178. package/src/renderers/dom/host/input.tsx +27 -3
  179. package/src/renderers/dom/host/surface-3d.tsx +6 -4
  180. package/src/renderers/dom/styles.css +26 -6
  181. package/src/renderers/electrobun/view/app-services.ts +2 -22
  182. package/src/renderers/electrobun/view/news-source.ts +42 -0
  183. package/src/series/validate.ts +5 -0
  184. package/src/shares/dialog.tsx +2 -1
  185. package/src/sources/gloomberb-cloud/normalizers.ts +2 -0
  186. package/src/sources/provider-router/financials.ts +5 -0
  187. package/src/{plugins/builtin/cloud/team → team}/collections-sync.ts +6 -6
  188. package/src/{plugins/builtin/cloud/team → team}/collections.ts +3 -3
  189. package/src/{plugins/builtin/cloud/team → team}/model.ts +2 -2
  190. package/src/{plugins/builtin/cloud/team → team}/pane-request.ts +1 -1
  191. package/src/{plugins/builtin/cloud/team → team}/store.ts +3 -3
  192. package/src/types/data-provider.ts +3 -0
  193. package/src/types/financials.ts +28 -0
  194. package/src/types/plugin.ts +17 -4
  195. package/src/utils/decimal-comma.ts +18 -0
  196. package/src/utils/exchanges.ts +7 -0
  197. package/src/utils/financial-statements.ts +25 -0
  198. package/src/utils/forward-dividend.ts +39 -9
  199. package/src/utils/fundamentals.ts +41 -1
  200. package/src/utils/option-expiry.ts +16 -0
  201. package/src/utils/sec.ts +2 -1
@@ -4,7 +4,8 @@ import type { SupplyRole, SupplyRow } from "../../../api-client/supply-chain";
4
4
  import { Box, ChartSurface, Span, Text, TextAttributes, useUiCapabilities } from "../../../ui";
5
5
  import { useShortcut } from "../../../react/input";
6
6
  import { isPlainKey } from "../../../utils/keyboard";
7
- import { displayWidth, truncateToDisplayWidth } from "../../../utils/format";
7
+ import { displayWidth } from "../../../utils/format";
8
+ import { ClippedText } from "../../../components/ui/display";
8
9
  import { blendHex } from "../../../theme/colors";
9
10
  import { useThemeColors } from "../../../theme/theme-context";
10
11
  import { useStaticChartBitmapSize } from "../../../components/chart/composite/bitmap";
@@ -201,10 +202,10 @@ export function SupplyFlow({ rows, symbol, focusName, focusId, width, height, fo
201
202
  onPress={() => selectNode(node)} onHover={(on) => setHovered(on ? node.id : null)} />)}
202
203
  {geometry.groupRow !== null ? <Box position="absolute" top={geometry.groupRow} left={1} width={width - 2} flexDirection="row" overflow="hidden">
203
204
  <Box width={RELATED_LABEL_CELLS} flexShrink={0}><Text fg={colors.textDim}>Groups</Text></Box>
204
- <Text fg={colors.textDim}>{truncateToDisplayWidth(geometry.groups.map((row) => {
205
+ <ClippedText fg={colors.textDim} text={geometry.groups.map((row) => {
205
206
  const share = shareParts(row, focusId);
206
207
  return `${counterpartyLabel(row)}${share ? ` ${share.value} ${share.basis}` : ""}`;
207
- }).join(" · "), width - RELATED_LABEL_CELLS - 3)}</Text>
208
+ }).join(" · ")} width={width - RELATED_LABEL_CELLS - 3} />
208
209
  </Box> : null}
209
210
  {legend ? <Box position="absolute" top={geometry.legendRow} left={1} width={width - 2} flexDirection="row" gap={2} overflow="hidden">
210
211
  {legend.map((entry) => <Box key={entry.id} flexDirection="row" gap={1} flexShrink={0}>
@@ -253,7 +254,7 @@ function FocusCard({ symbol, name, x, y, width, height, desktop }: { symbol: str
253
254
  backgroundColor={desktop ? colors.panel : undefined} border={!desktop} borderStyle="rounded" borderColor={colors.textMuted}
254
255
  style={desktop ? { border: `1px solid ${blendHex(colors.bg, colors.textBright, 0.35)}`, borderRadius: 6, boxShadow: `0 0 0 3px ${blendHex(colors.bg, colors.textBright, 0.06)}` } : undefined}>
255
256
  <Text fg={colors.textBright} attributes={TextAttributes.BOLD}>{symbol}</Text>
256
- {showName ? <Text fg={colors.textDim}>{truncateToDisplayWidth(name!, width - 4)}</Text> : null}
257
+ {showName ? <ClippedText fg={colors.textDim} text={name!} width={width - 4} /> : null}
257
258
  </Box>;
258
259
  }
259
260
 
@@ -275,13 +276,15 @@ function FlowCard({ node, focusId, desktop, active, onPress, onHover }: {
275
276
  style={desktop ? { borderLeft: `3px ${node.more ? "dotted" : "solid"} ${node.more ? blendHex(colors.bg, accent, 0.5) : accent}`, borderRadius: 3, paddingLeft: 6, cursor: "pointer",
276
277
  boxShadow: active ? `inset 0 0 0 1px ${blendHex(colors.bg, accent, 0.6)}` : undefined } : undefined}
277
278
  onMouseDown={onPress} onMouseOver={() => onHover(true)} onMouseOut={() => onHover(false)} data-gloom-role="supply-flow-node">
278
- <Box flexDirection="row" height={1} overflow="hidden">
279
+ {/* The ticker shows only beside the whole name. On the desktop that is CSS: the row wraps, one line shows, and a ticker that does not fit goes to the hidden line. */}
280
+ <Box flexDirection="row" height={1} overflow="hidden" style={desktop ? { flexWrap: "wrap" } : undefined}>
279
281
  {!desktop ? <Text fg={node.more ? colors.textMuted : accent}>▍</Text> : null}
280
- <Text fg={active && !desktop ? colors.selectedText : titleColor} attributes={company ? TextAttributes.BOLD : 0}>{truncateToDisplayWidth(name, nameWidth)}</Text>
281
- {ticker && displayWidth(name) + displayWidth(ticker) + 1 <= inner ? <Text fg={colors.textDim}>{` ${ticker}`}</Text> : null}
282
+ <ClippedText fg={active && !desktop ? colors.selectedText : titleColor} attributes={company ? TextAttributes.BOLD : 0} text={name} width={nameWidth} />
283
+ {ticker && (desktop || displayWidth(name) + displayWidth(ticker) + 1 <= inner)
284
+ ? <Text fg={colors.textDim} style={desktop ? { flex: "0 0 auto" } : undefined}>{` ${ticker}`}</Text> : null}
282
285
  </Box>
283
286
  {metric ? <Box height={1} paddingLeft={desktop ? 0 : 1} overflow="hidden">
284
- <Text fg={active && !desktop ? colors.selectedText : colors.textDim}>{truncateToDisplayWidth(metric, inner - (desktop ? 0 : 1))}</Text>
287
+ <ClippedText fg={active && !desktop ? colors.selectedText : colors.textDim} text={metric} width={inner - (desktop ? 0 : 1)} />
285
288
  </Box> : null}
286
289
  </Box>;
287
290
  }
@@ -289,13 +292,12 @@ function FlowCard({ node, focusId, desktop, active, onPress, onHover }: {
289
292
  function FlowChip({ node, desktop, active, onPress, onHover }: { node: PositionedNode; desktop: boolean; active: boolean; onPress: () => void; onHover: (on: boolean) => void }) {
290
293
  const colors = useThemeColors();
291
294
  const accent = node.role ? ROLE_COLORS[node.role] : colors.textMuted;
292
- const label = truncateToDisplayWidth(flowChipLabel(node), node.width - 2);
293
295
  const background = active ? blendHex(colors.bg, accent, desktop ? 0.32 : 0.45) : node.more ? undefined : blendHex(colors.bg, accent, 0.14);
294
- const text = <Text fg={node.more ? colors.textDim : active ? colors.textBright : accent}>{label}</Text>;
296
+ const text = <ClippedText fg={node.more ? colors.textDim : active ? colors.textBright : accent} text={flowChipLabel(node)} width={node.width - 2} />;
295
297
  return <Box position="absolute" left={node.x} top={node.y} width={node.width} height={1} justifyContent={desktop ? "center" : undefined}
296
298
  onMouseDown={onPress} onMouseOver={() => onHover(true)} onMouseOut={() => onHover(false)} data-gloom-role="supply-flow-chip">
297
299
  {/* The desktop chip leaves a hairline of row above and below, like ticker chips. */}
298
- {desktop ? <Box paddingX={1} backgroundColor={background} justifyContent="center" style={{ height: "calc(100% - 4px)", borderRadius: 3, cursor: "pointer" }}>{text}</Box>
300
+ {desktop ? <Box paddingX={1} minWidth={0} backgroundColor={background} justifyContent="center" style={{ height: "calc(100% - 4px)", borderRadius: 3, cursor: "pointer" }}>{text}</Box>
299
301
  : <Box paddingX={1} height={1} backgroundColor={background}>{text}</Box>}
300
302
  </Box>;
301
303
  }
@@ -317,7 +319,7 @@ function FlowTooltip({ node, focusId, width, desktop, anchor }: { node: Position
317
319
  flexDirection="column" paddingX={1} backgroundColor={colors.panel} border={!desktop} borderColor={ROLE_COLORS[row.role]}
318
320
  style={desktop ? { border: `1px solid ${blendHex(colors.bg, ROLE_COLORS[row.role], 0.7)}`, borderRadius: 4, boxShadow: "0 6px 18px rgba(0,0,0,0.45)", zIndex: 20, pointerEvents: "none" } : undefined}
319
321
  data-gloom-role="supply-flow-tooltip">
320
- <Text fg={colors.textBright} attributes={TextAttributes.BOLD}>{truncateToDisplayWidth(counterpartyLabel(row), tooltipWidth - 2)}</Text>
321
- {lines.map((line, index) => <Text key={index} fg={index === 0 ? colors.text : colors.textDim}>{truncateToDisplayWidth(line, tooltipWidth - 2)}</Text>)}
322
+ <ClippedText fg={colors.textBright} attributes={TextAttributes.BOLD} text={counterpartyLabel(row)} width={tooltipWidth - 2} />
323
+ {lines.map((line, index) => <ClippedText key={index} fg={index === 0 ? colors.text : colors.textDim} text={line} width={tooltipWidth - 2} />)}
322
324
  </Box>;
323
325
  }
@@ -8,7 +8,8 @@ import { useThemeColors } from "../../../theme/theme-context";
8
8
  import { useShortcut } from "../../../react/input";
9
9
  import { useScrollBoxLayout } from "../../../components/use-scrollbox-layout";
10
10
  import { isPlainKey } from "../../../utils/keyboard";
11
- import { displayWidth, truncateToDisplayWidth } from "../../../utils/format";
11
+ import { displayWidth } from "../../../utils/format";
12
+ import { ClippedText } from "../../../components/ui/display";
12
13
  import { useStaticChartBitmapSize } from "../../../components/chart/composite/bitmap";
13
14
  import { drawLine, fillOpaque, parseHex } from "../../../components/chart/native/raster/primitives";
14
15
  import { entityLabel, graphNodes } from "./graph-model";
@@ -137,7 +138,8 @@ export function SupplyGraph({ data, width, height, focused, selectedId, selected
137
138
  const tickerWidth = node.entity.ticker ? displayWidth(node.entity.ticker) + 3 : 0;
138
139
  const showTicker = company && tickerWidth > 0 && inner >= tickerWidth + 5;
139
140
  const prefix = collapsed.includes(node.entity.id) ? "+ " : "";
140
- const name = truncateToDisplayWidth(prefix + (showTicker ? node.entity.name : entityLabel(node.entity)), inner - (showTicker ? tickerWidth : 0));
141
+ const name = prefix + (showTicker ? node.entity.name : entityLabel(node.entity));
142
+ const nameWidth = inner - (showTicker ? tickerWidth : 0);
141
143
  const relation = kind ?? (node.related ? `${node.hops > 1 ? "via " : ""}${role}` : role);
142
144
  const confidence = `${Math.round((node.path?.confidence ?? 0) * 100)}%`;
143
145
  const detail = node.hops ? `${node.hops}H · ${relation}${inner >= displayWidth(relation ?? "") + 12 ? ` · ${confidence}` : ""}` : showTicker ? "Focus company" : node.entity.name;
@@ -150,10 +152,10 @@ export function SupplyGraph({ data, width, height, focused, selectedId, selected
150
152
  <Box flexDirection="column" width="100%">
151
153
  <Box flexDirection="row" height={1} gap={desktop ? 1 : 0} overflow="hidden">
152
154
  {!desktop ? <Text fg={accent}>▍</Text> : null}
153
- <Text fg={active && !desktop ? colors.selectedText : company ? colors.textBright : colors.textDim} attributes={company ? TextAttributes.BOLD : 0}>{name}</Text>
155
+ <ClippedText fg={active && !desktop ? colors.selectedText : company ? colors.textBright : colors.textDim} attributes={company ? TextAttributes.BOLD : 0} text={name} width={nameWidth} />
154
156
  {showTicker ? desktop ? <Badge label={node.entity.ticker!} tone="accent" /> : <Text fg={active ? colors.selectedText : colors.textDim}>{` ${node.entity.ticker}`}</Text> : null}
155
157
  </Box>
156
- <Box height={1} paddingLeft={desktop ? 0 : 1} overflow="hidden"><Text fg={active && !desktop ? colors.selectedText : colors.textDim}>{truncateToDisplayWidth(detail, inner)}</Text></Box>
158
+ <Box height={1} paddingLeft={desktop ? 0 : 1} overflow="hidden"><ClippedText fg={active && !desktop ? colors.selectedText : colors.textDim} text={detail} width={inner} /></Box>
157
159
  </Box>
158
160
  </Button>
159
161
  </Box>;
@@ -8,6 +8,7 @@ import {
8
8
  Badge, buildSectionedRows, DataTableView, EmptyState, isSectionedItemRow, PageStackView, renderSectionedRowHeader, PaneStatusBody, QueryBar, StatGrid, statGridRows, usePaneNoticeFooter,
9
9
  usePaneStatusFooter, usePaneTabs, type DataTableCell, type DataTableColumn, type PaneHint, type SectionedRow, type StatItem,
10
10
  } from "../../../components";
11
+ import { ClippedText } from "../../../components/ui/display";
11
12
  import { RatioBar } from "../../../components/ui/ratio-bar";
12
13
  import { getTableWidth } from "../../../components/ui/table-layout";
13
14
  import { usePaneRefreshKey } from "../../../components/data-table/table-pane";
@@ -16,7 +17,7 @@ import { usePaneTickerIdentity } from "../../../state/hooks/pane-ticker";
16
17
  import { useThemeColors } from "../../../theme/theme-context";
17
18
  import type { PaneProps } from "../../../types/plugin";
18
19
  import { Box, Text, TextAttributes, useRendererHost, useUiCapabilities } from "../../../ui";
19
- import { displayWidth, truncateToDisplayWidth } from "../../../utils/format";
20
+ import { displayWidth } from "../../../utils/format";
20
21
  import { SignInWall } from "../cloud/auth-actions";
21
22
  import { CLOUD_PLAN_KEY, useCloudUpgradeAction } from "../shared/cloud-upgrade";
22
23
  import { useOpenDescription } from "../shared/open-description";
@@ -241,13 +242,15 @@ function SupplyView({ symbol, width, height, focused }: Pick<PaneProps, "width"
241
242
  case "name": {
242
243
  const company = counterpartyKind(row) === "company";
243
244
  // The kind label never gives way to a long cohort name; a column too narrow for the name says it in one word.
245
+ // The desktop always says it in one word: the Role column beside it names the role, and the name takes the room.
244
246
  const full = counterpartyKindLabel(row);
245
- const kind = full && displayWidth(counterpartyLabel(row)) + displayWidth(full) + 4 <= column.width ? full : counterpartyKindLabel(row, true);
247
+ const kind = !desktop && full && displayWidth(counterpartyLabel(row)) + displayWidth(full) + 4 <= column.width ? full : counterpartyKindLabel(row, true);
246
248
  const ticker = !columns.some((entry) => entry.id === "ticker") ? row.counterparty.ticker : null;
247
249
  const room = Math.max(8, column.width - (kind ? displayWidth(kind) + 4 : 0) - (ticker ? displayWidth(ticker) + 1 : 0));
248
- return { text: cellText(row, "name"), content: <Box flexDirection="row" height={1} gap={ticker ? 1 : 2} overflow="hidden">
249
- <Text fg={ink ?? (company ? colors.textBright : colors.textDim)}>{truncateToDisplayWidth(counterpartyLabel(row), room)}</Text>
250
- {ticker ? <Text fg={ink ?? colors.textDim}>{ticker}</Text> : null}
250
+ // On the desktop the name gives way at the column's edge by CSS and the ticker and kind keep their room.
251
+ return { text: cellText(row, "name"), content: <Box flexDirection="row" height={1} gap={ticker ? 1 : 2} overflow="hidden" {...(desktop ? { flexShrink: 1, minWidth: 0 } : {})}>
252
+ <ClippedText fg={ink ?? (company ? colors.textBright : colors.textDim)} text={counterpartyLabel(row)} width={room} />
253
+ {ticker ? <Text fg={ink ?? colors.textDim} style={desktop ? { flex: "0 0 auto" } : undefined}>{ticker}</Text> : null}
251
254
  {kind ? desktop ? <Badge label={kind} /> : <Text fg={ink ?? colors.textMuted}>{kind}</Text> : null}
252
255
  </Box> };
253
256
  }
@@ -1,5 +1,6 @@
1
- import type { FinancialStatement } from "../../../../types/financials";
1
+ import type { EarningsField, FinancialStatement } from "../../../../types/financials";
2
2
  import { completeAvailability, statementFieldAvailability } from "../../../../utils/financial-statements";
3
+ import { hasStatementGap } from "../../../../utils/statement-gaps";
3
4
 
4
5
  export type FinancialPeriod = "annual" | "quarterly";
5
6
 
@@ -91,6 +92,8 @@ const FLOW_KEYS = new Set<string>([
91
92
  "effectOfExchangeRateChanges",
92
93
  ]);
93
94
 
95
+ const EARNINGS_KEYS = ["basicEps", "eps"] as const satisfies readonly EarningsField[];
96
+
94
97
  const BALANCE_KEYS = new Set<string>([
95
98
  "totalAssets",
96
99
  "currentAssets",
@@ -195,7 +198,17 @@ function aggregateQuarterlyStatements(
195
198
  const availableAt = completeAvailability(sources.map((source) => statementFieldAvailability(source, key)));
196
199
  if (availableAt) availability[key] = availableAt;
197
200
  };
201
+ // A quarter that declares an EPS line unavailable, or whose EPS share basis
202
+ // is unresolved, leaves the year's EPS unknown even when it still carries a
203
+ // number: the sum is withheld and the aggregate says why, as a quarter would.
204
+ const unresolvedEps = statements.find((statement) => statement.epsBasis?.status === "unresolved");
205
+ const gapped = EARNINGS_KEYS.filter((key) => statements.some((statement) => hasStatementGap(statement, key)));
206
+ if (gapped.length > 0) aggregate.unavailableEarnings = [...gapped];
207
+ if (unresolvedEps) aggregate.epsBasis = unresolvedEps.epsBasis;
208
+ const withheld = new Set<string>(gapped);
209
+ if (unresolvedEps) withheld.add("eps");
198
210
  for (const key of FLOW_KEYS) {
211
+ if (withheld.has(key)) continue;
199
212
  const values = statements
200
213
  .map((statement) => (statement as unknown as Record<string, unknown>)[key])
201
214
  .filter((value): value is number => typeof value === "number" && Number.isFinite(value));
@@ -63,8 +63,9 @@ interface RatioInputDef {
63
63
  /** A flow over the period. Quarterly columns multiply it by four. */
64
64
  annualized?: boolean;
65
65
  /**
66
- * A flow over a year: quarterly columns read the trailing four quarters
67
- * (TTM), and show "< 4 quarters" rather than annualize a short history.
66
+ * A flow over a year (leverage and valuation ratios): quarterly columns read
67
+ * the trailing four quarters (TTM), and show "< 4 quarters" rather than
68
+ * annualize a short history.
68
69
  */
69
70
  trailing?: boolean;
70
71
  /** A shorter name for quarterly columns, so the label, TTM and its unit fit the label column. */
@@ -189,7 +190,7 @@ const flow = (label: string, read: BalanceLine, operator?: RatioInputDef["operat
189
190
  label, operator, format: "money", annualized: true, value: ({ statement }) => reported(read(statement)),
190
191
  });
191
192
 
192
- /** A flow a leverage ratio reads over a year: the trailing four quarters in a quarterly column. */
193
+ /** A flow a ratio reads over a year: the trailing four quarters in a quarterly column. */
193
194
  const trailingFlow = (
194
195
  label: string,
195
196
  read: BalanceLine,
@@ -474,7 +475,8 @@ export const RATIO_TABS: RatioTabDef[] = [
474
475
  format: "multiple",
475
476
  inputs: [
476
477
  { label: "Price", format: "perShare", value: ({ price }) => price },
477
- { label: "Diluted EPS", operator: "÷", format: "perShare", annualized: true, value: dilutedEps },
478
+ // The summed quarters withhold EPS as a quarter does, when one has a gap or an unresolved basis.
479
+ { label: "Diluted EPS", operator: "÷", format: "perShare", trailing: true, value: dilutedEps },
478
480
  ],
479
481
  compute: ([price, eps]) => over(price!, eps!),
480
482
  },
@@ -484,7 +486,7 @@ export const RATIO_TABS: RatioTabDef[] = [
484
486
  format: "multiple",
485
487
  inputs: [
486
488
  { label: "Enterprise Value", format: "money", value: enterpriseValue },
487
- flow("EBITDA", line("ebitda"), "÷"),
489
+ trailingFlow("EBITDA", line("ebitda"), "÷"),
488
490
  ],
489
491
  // A negative enterprise value makes the multiple meaningless too.
490
492
  compute: ([value, ebitda]) => (value! > 0 ? over(value!, ebitda!) : "not-meaningful"),
@@ -493,7 +495,7 @@ export const RATIO_TABS: RatioTabDef[] = [
493
495
  id: "ps",
494
496
  label: "P/S",
495
497
  format: "multiple",
496
- inputs: [{ label: "Market Cap", format: "money", value: marketCap }, flow("Revenue", revenue, "÷")],
498
+ inputs: [{ label: "Market Cap", format: "money", value: marketCap }, trailingFlow("Revenue", revenue, "÷")],
497
499
  compute: ([cap, sales]) => over(cap!, sales!),
498
500
  },
499
501
  {
@@ -511,7 +513,7 @@ export const RATIO_TABS: RatioTabDef[] = [
511
513
  label: "FCF Yield",
512
514
  format: "percent",
513
515
  signed: true,
514
- inputs: [flow("Free Cash Flow", freeCashFlow), { label: "Market Cap", operator: "÷", format: "money", value: marketCap }],
516
+ inputs: [trailingFlow("Free Cash Flow", freeCashFlow), { label: "Market Cap", operator: "÷", format: "money", value: marketCap }],
515
517
  compute: ([cash, cap]) => over(cash!, cap!),
516
518
  },
517
519
  ],
@@ -1,4 +1,4 @@
1
- import { FINANCIAL_VINTAGE_NOTICE } from "../../../utils/financial-statements";
1
+ import { FINANCIAL_VINTAGE_NOTICE, statementHistoryNotes } from "../../../utils/financial-statements";
2
2
  import { hasValidQuoteObservationTime } from "../../../market-data/quotes/freshness";
3
3
  import { EXTENDED_SESSION_LABELS, getQuoteSessionFields, type ExtendedSession } from "../../../market-data/market/status";
4
4
  import type { HeadlessPaneColumn, HeadlessPaneDefinition } from "../../../types/headless";
@@ -107,6 +107,8 @@ export const financialStatementsHeadless: HeadlessPaneDefinition<"rows"> = {
107
107
  return {
108
108
  rows, columns, unavailableSymbols: rows.length ? [] : [symbol],
109
109
  ...(!hasRequestedStatements ? { errors: [`${symbol}: No ${requestedPeriod} financial statement coverage.`] } : {}),
110
+ // The report closes with how far back its periods go, and how to read further.
111
+ notes: statementHistoryNotes(requestedPeriod, dates.map(({ date }) => date), symbol),
110
112
  metadata: {
111
113
  symbol, name: financials.quote?.name ?? symbol, currency: statementCurrency ?? null, unit: table?.unit ?? null, quoteCurrency: financials.quote?.currency ?? null,
112
114
  statement: table?.subTab.key ?? options.statement, statementLabel: table?.subTab.name ?? null,
@@ -177,6 +179,7 @@ async function loadRatioReport(
177
179
  rows, columns, unavailableSymbols: rows.length ? [] : [symbol],
178
180
  ...(!hasRequestedStatements ? { errors: [`${symbol}: No ${period} financial statement coverage.`] } : {}),
179
181
  ...(tab.key === "valuation" && hasRequestedStatements && !history?.length ? { complete: false } : {}),
182
+ notes: statementHistoryNotes(period, dates.map(({ date }) => date), symbol),
180
183
  metadata: {
181
184
  symbol, name: financials.quote?.name ?? symbol, currency: statementCurrency ?? null,
182
185
  quoteCurrency: financials.quote?.currency ?? null,
@@ -1,11 +1,11 @@
1
1
  import type { ReactNode } from "react";
2
- import { ExternalLinkText, RangeTrack, usePaneLinkMenuEntry } from "../../../../components";
2
+ import { ColumnGrid, ColumnGridPair, RangeTrack, usePaneLinkMenuEntry, type ColumnGridCell, type ColumnGridColumn } from "../../../../components";
3
3
  import { t, tf } from "../../../../i18n";
4
4
  import { formatMarketPriceWithCurrency, liveQuoteFormatOptions, type MarketFormatOptions } from "../../../../market-data/market/format";
5
5
  import { colors, hoverBg, priceColor } from "../../../../theme/colors";
6
6
  import type { Quote } from "../../../../types/financials";
7
- import { Box, Text } from "../../../../ui";
8
- import { displayWidth, formatNumber, padTo, truncateToDisplayWidth } from "../../../../utils/format";
7
+ import { Box, Text, useUiCapabilities } from "../../../../ui";
8
+ import { displayWidth, formatNumber, truncateToDisplayWidth } from "../../../../utils/format";
9
9
  import type { OverviewFunctionLink, PositionTableRow, StatField } from "./types";
10
10
  import { portfolioPnlLabel } from "../../../../portfolio/position-metrics";
11
11
 
@@ -17,6 +17,8 @@ const RANGE_ENDPOINT_WIDTH = 11;
17
17
  /** Shortest track worth drawing; below it the label shortens. */
18
18
  const RANGE_MIN_TRACK_WIDTH = 6;
19
19
  const POSITION_COLUMN_GAP = 1;
20
+ /** The account column never gets narrower than this. */
21
+ const ACCOUNT_MIN_WIDTH = 8;
20
22
 
21
23
  interface PositionColumn {
22
24
  key: keyof Omit<PositionTableRow, "pnlValue" | "pnlBasis" | "quantityUnit">;
@@ -105,27 +107,24 @@ export function CompactRangeBar({
105
107
  );
106
108
  }
107
109
 
108
- function BookRow({
109
- label,
110
- value,
111
- width,
112
- valueColor,
113
- }: {
114
- label: string;
115
- value: string;
116
- width: number;
117
- valueColor: string;
118
- }) {
119
- return (
120
- <Box flexDirection="row" height={1} width={width}>
121
- <Text fg={colors.textDim}>{padTo(label, BOOK_LABEL_WIDTH)}</Text>
122
- {/* Right-aligned, so a size changing length does not slide the price. */}
123
- <Text fg={valueColor}>{padTo(value, Math.max(0, width - BOOK_LABEL_WIDTH), "right")}</Text>
124
- </Box>
125
- );
110
+ /** The quote book's columns: its labels, then the value right-aligned in the rest. */
111
+ function bookColumns(width: number): ColumnGridColumn[] {
112
+ return [
113
+ { width: BOOK_LABEL_WIDTH },
114
+ // Right-aligned, so a size changing length does not slide the price.
115
+ { width: Math.max(0, width - BOOK_LABEL_WIDTH), growFrom: 0, align: "right" },
116
+ ];
126
117
  }
127
118
 
128
- export function QuoteBook({ quote, assetCategory, width }: { quote: Quote; assetCategory?: string; width: number }) {
119
+ export function QuoteBook({ quote, assetCategory, width, desktopWidth }: {
120
+ quote: Quote;
121
+ assetCategory?: string;
122
+ /** Cells on the terminal. */
123
+ width: number;
124
+ /** The desktop's width for the book, in CSS. */
125
+ desktopWidth?: string;
126
+ }) {
127
+ const { nativePaneChrome } = useUiCapabilities();
129
128
  // Bid, ask and spread keep the instrument's decimals on every tick ($224.58 / $224.60 / $0.02).
130
129
  const priceOptions = liveQuoteFormatOptions(quote, quote.currency, assetCategory);
131
130
  const bidPrice = quote.bid != null ? formatMarketPriceWithCurrency(quote.bid, quote.currency, priceOptions) : "—";
@@ -140,12 +139,20 @@ export function QuoteBook({ quote, assetCategory, width }: { quote: Quote; asset
140
139
  spreadText = `${formatMarketPriceWithCurrency(spread, quote.currency, priceOptions)}${spreadPercent}`;
141
140
  }
142
141
 
142
+ const row = (label: string, value: string, color: string): ColumnGridCell[] => [{ text: label, color: colors.textDim }, { text: value, color }];
143
143
  return (
144
- <Box flexDirection="column" width={width} flexShrink={0}>
145
- {/* Neutral: a red ask would read as a loss. */}
146
- <BookRow label={t("Bid")} value={bidText} width={width} valueColor={colors.textBright} />
147
- <BookRow label={t("Ask")} value={askText} width={width} valueColor={colors.textBright} />
148
- <BookRow label={t("Spr")} value={spreadText} width={width} valueColor={colors.textDim} />
144
+ <Box flexDirection="column" width={nativePaneChrome ? undefined : width} flexShrink={0} style={nativePaneChrome ? { width: desktopWidth } : undefined}>
145
+ <ColumnGrid
146
+ columns={bookColumns(width)}
147
+ width={width}
148
+ role="quote-book"
149
+ rows={[
150
+ // Neutral: a red ask would read as a loss.
151
+ row(t("Bid"), bidText, colors.textBright),
152
+ row(t("Ask"), askText, colors.textBright),
153
+ row(t("Spr"), spreadText, colors.textDim),
154
+ ]}
155
+ />
149
156
  </Box>
150
157
  );
151
158
  }
@@ -192,9 +199,12 @@ function LinkedStatCell({
192
199
  }) {
193
200
  // The pane menu lists each function once, so the keyboard reaches what a click opens.
194
201
  usePaneLinkMenuEntry(`function:${link.templateId}`, tf("Open {label}", { label: t(link.name) }), () => onOpen(link));
202
+ const { nativePaneChrome } = useUiCapabilities();
195
203
  return (
196
204
  <Box
197
- width={width}
205
+ width={nativePaneChrome ? undefined : width}
206
+ flexGrow={nativePaneChrome ? 1 : undefined}
207
+ style={nativePaneChrome ? { minWidth: 0 } : undefined}
198
208
  flexDirection="row"
199
209
  hoverBackgroundColor={hoverBg()}
200
210
  onMouseDown={(event?: { button?: number }) => {
@@ -223,65 +233,64 @@ export function FundamentalsGrid({
223
233
  /** Opens a figure's research function; without it figures are plain. */
224
234
  onOpenLink?: (link: OverviewFunctionLink) => void;
225
235
  }) {
236
+ const { nativePaneChrome } = useUiCapabilities();
226
237
  const columnCount = columns ?? fundamentalsGridColumns(width);
227
238
  const availableWidth = width - STAT_COLUMN_GAP * (columnCount - 1);
228
239
  const baseColWidth = Math.floor(availableWidth / columnCount);
229
240
  const extraWidth = availableWidth - baseColWidth * columnCount;
230
241
  const colWidths = Array.from({ length: columnCount }, (_, index) => baseColWidth + (index === columnCount - 1 ? extraWidth : 0));
231
- const rows: Array<Array<StatField | null>> = [];
242
+ // Equal columns on the desktop too, sharing the pane's width there.
243
+ // A column never gets narrower than its labels, so a label cannot reach the next column.
244
+ const gridColumns: ColumnGridColumn[] = colWidths.map((colWidth, index) => ({
245
+ width: colWidth,
246
+ growFrom: gridLabelWidth(colWidth),
247
+ gapBefore: index > 0 ? STAT_COLUMN_GAP : 0,
248
+ }));
249
+ const rows: ColumnGridCell[][] = [];
232
250
  for (let i = 0; i < fields.length; i += columnCount) {
233
- rows.push(Array.from({ length: columnCount }, (_, offset) => fields[i + offset] ?? null));
251
+ rows.push(Array.from({ length: columnCount }, (_, offset): ColumnGridCell => {
252
+ const field = fields[i + offset];
253
+ if (!field) return null;
254
+ const colWidth = colWidths[offset] ?? baseColWidth;
255
+ const labelWidth = gridLabelWidth(colWidth);
256
+ const valueWidth = Math.max(1, colWidth - labelWidth);
257
+ // A long value (an industry, a SIC title) is cut from the end, never from the label side.
258
+ const value = truncateToDisplayWidth(field.value, valueWidth);
259
+ const detailRoom = valueWidth - displayWidth(value) - 1;
260
+ const details = typeof field.detail === "string" ? [field.detail] : field.detail ?? [];
261
+ // A cut "in 4..." misleads; a title cut to "Semicond..." still names it.
262
+ const detail = details.find((text) => displayWidth(text) <= detailRoom)
263
+ ?? (field.clipDetail && details[0] && detailRoom >= 4 ? truncateToDisplayWidth(details[0], detailRoom) : "");
264
+ const content = (
265
+ <ColumnGridPair
266
+ label={t(field.label)}
267
+ labelWidth={labelWidth}
268
+ width={colWidth}
269
+ value={field.value}
270
+ valueColor={field.valueColor}
271
+ detail={detail || undefined}
272
+ url={field.url}
273
+ />
274
+ );
275
+ return {
276
+ content: field.link && onOpenLink
277
+ ? <LinkedStatCell link={field.link} width={colWidth} onOpen={onOpenLink}>{content}</LinkedStatCell>
278
+ : (
279
+ <Box
280
+ width={nativePaneChrome ? undefined : colWidth}
281
+ flexGrow={nativePaneChrome ? 1 : undefined}
282
+ flexDirection="row"
283
+ data-gloom-ui="key-value-row"
284
+ style={nativePaneChrome ? { minWidth: 0 } : undefined}
285
+ >
286
+ {content}
287
+ </Box>
288
+ ),
289
+ };
290
+ }));
234
291
  }
235
292
 
236
- return (
237
- <Box flexDirection="column">
238
- {rows.map((row, i) => (
239
- <Box key={i} flexDirection="row" height={1}>
240
- {row.map((field, j) => {
241
- const colWidth = colWidths[j] ?? baseColWidth;
242
- if (!field) {
243
- return (
244
- <Box key={j} flexDirection="row">
245
- {j > 0 && <Box width={STAT_COLUMN_GAP} />}
246
- <Box width={colWidth} />
247
- </Box>
248
- );
249
- }
250
- const labelWidth = gridLabelWidth(colWidth);
251
- const valueWidth = Math.max(1, colWidth - labelWidth);
252
- // A long value (an industry, a SIC title) is cut from the end, never from the label side.
253
- const value = truncateToDisplayWidth(field.value, valueWidth);
254
- const detailRoom = valueWidth - displayWidth(value) - 1;
255
- const details = typeof field.detail === "string" ? [field.detail] : field.detail ?? [];
256
- // A cut "in 4..." misleads; a title cut to "Semicond..." still names it.
257
- const detail = details.find((text) => displayWidth(text) <= detailRoom)
258
- ?? (field.clipDetail && details[0] && detailRoom >= 4 ? truncateToDisplayWidth(details[0], detailRoom) : "");
259
- const content = (
260
- <>
261
- <Box width={labelWidth} overflow="hidden">
262
- <Text fg={colors.textDim}>{t(field.label)}</Text>
263
- </Box>
264
- <Box flexDirection="row" width={valueWidth} justifyContent="flex-end" overflow="hidden">
265
- {field.url
266
- ? <ExternalLinkText url={field.url} label={value} color={field.valueColor ?? colors.text} />
267
- : <Text fg={field.valueColor ?? colors.text}>{value}</Text>}
268
- {detail ? <Text fg={colors.textDim}>{` ${detail}`}</Text> : null}
269
- </Box>
270
- </>
271
- );
272
- return (
273
- <Box key={j} flexDirection="row">
274
- {j > 0 && <Box width={STAT_COLUMN_GAP} />}
275
- {field.link && onOpenLink
276
- ? <LinkedStatCell link={field.link} width={colWidth} onOpen={onOpenLink}>{content}</LinkedStatCell>
277
- : <Box width={colWidth} flexDirection="row" data-gloom-ui="key-value-row">{content}</Box>}
278
- </Box>
279
- );
280
- })}
281
- </Box>
282
- ))}
283
- </Box>
284
- );
293
+ return <ColumnGrid columns={gridColumns} rows={rows} role="fundamentals-grid" />;
285
294
  }
286
295
 
287
296
  const POSITION_COLUMNS: readonly PositionColumn[] = [
@@ -301,36 +310,28 @@ function createPositionColumns(width: number, hasFaceQuantity: boolean): Positio
301
310
  }));
302
311
  const fixedWidth = columns.reduce((sum, column) => sum + column.width, 0) + POSITION_COLUMN_GAP * (columns.length - 1);
303
312
  const accountColumn = columns[0]!;
304
- accountColumn.width = Math.max(8, width - fixedWidth);
313
+ accountColumn.width = Math.max(ACCOUNT_MIN_WIDTH, width - fixedWidth);
305
314
  return columns;
306
315
  }
307
316
 
308
317
  export function PositionTable({ rows, width }: { rows: PositionTableRow[]; width: number }) {
309
318
  const columns = createPositionColumns(width, rows.some(row => row.quantityUnit === "face"));
310
319
  const pnlLabel = portfolioPnlLabel(rows.map((row) => row.pnlBasis));
320
+ const gridColumns: ColumnGridColumn[] = columns.map((column, index) => ({
321
+ width: column.width,
322
+ align: column.align,
323
+ gapBefore: index > 0 ? POSITION_COLUMN_GAP : 0,
324
+ // The account takes what the figures leave, on the desktop too.
325
+ ...(column.key === "account" ? { growFrom: ACCOUNT_MIN_WIDTH } : {}),
326
+ }));
327
+ const header: ColumnGridCell[] = columns.map((column) => ({
328
+ text: t(column.key === "pnl" ? pnlLabel : column.label),
329
+ color: colors.textDim,
330
+ }));
331
+ const body = rows.map((row): ColumnGridCell[] => columns.map((column) => ({
332
+ text: row[column.key],
333
+ color: column.color?.(row) ?? (column.key === "account" ? colors.textBright : colors.text),
334
+ })));
311
335
 
312
- return (
313
- <Box flexDirection="column" width={width}>
314
- <Box flexDirection="row" height={1}>
315
- {columns.map((column, index) => (
316
- <Box key={column.key} flexDirection="row">
317
- {index > 0 && <Box width={POSITION_COLUMN_GAP} />}
318
- <Text fg={colors.textDim}>{padTo(t(column.key === "pnl" ? pnlLabel : column.label), column.width, column.align)}</Text>
319
- </Box>
320
- ))}
321
- </Box>
322
- {rows.map((row, rowIndex) => (
323
- <Box key={rowIndex} flexDirection="row" height={1}>
324
- {columns.map((column, index) => (
325
- <Box key={column.key} flexDirection="row">
326
- {index > 0 && <Box width={POSITION_COLUMN_GAP} />}
327
- <Text fg={column.color?.(row) ?? (column.key === "account" ? colors.textBright : colors.text)}>
328
- {padTo(row[column.key], column.width, column.align)}
329
- </Text>
330
- </Box>
331
- ))}
332
- </Box>
333
- ))}
334
- </Box>
335
- );
336
+ return <ColumnGrid columns={gridColumns} rows={[header, ...body]} width={width} role="position-table" />;
336
337
  }
@@ -23,7 +23,7 @@ import { getPortfolioPositionMetrics, getPortfolioQuoteDisplay, resolvePortfolio
23
23
  import { liveDividendYield, liveForwardPE, liveMarketCapitalization, liveTrailingPE } from "../../portfolio-list/live-valuation";
24
24
  import { atRegularSessionPrice } from "../../../../market-data/quotes/regular-session-valuation";
25
25
  import { formatReportedMoney } from "../../../../utils/reported-money";
26
- import { fundamentalsCurrency, fundamentalsEpsCurrency, reportedEnterpriseValue } from "../../../../utils/fundamentals";
26
+ import { enterpriseValueCheck, fundamentalsCurrency, fundamentalsEpsCurrency, reportedEnterpriseValue } from "../../../../utils/fundamentals";
27
27
  import { formatShortDate } from "../../../../utils/datetime-format";
28
28
  import { safeExternalUrl } from "../../../../utils/external-url";
29
29
 
@@ -188,7 +188,14 @@ export function buildOverviewStats({
188
188
  if (fundamentals?.unavailableFields?.includes("enterpriseValue")) {
189
189
  stats.push({ label: "EV", value: "—" });
190
190
  } else if (enterpriseValue != null) {
191
- stats.push({ label: "EV", value: formatCompactCurrency(enterpriseValue, quoteCurrency) });
191
+ // More than 5% from market cap + debt - cash: the provider's, with the computed figure where it fits.
192
+ const check = enterpriseValueCheck(fundamentals);
193
+ const computed = check?.status === "differs" && check.computed != null ? formatCompact(check.computed) : undefined;
194
+ stats.push({
195
+ label: "EV",
196
+ value: formatCompactCurrency(enterpriseValue, quoteCurrency),
197
+ detail: computed ? [`provider vs ${computed}`, "provider"] : undefined,
198
+ });
192
199
  }
193
200
  if (finite(fundamentals?.beta)) {
194
201
  stats.push({ label: "Beta", value: formatNumber(fundamentals.beta, 2), link: RELATIONSHIP_GRAPH });