gloomberb 0.17.1 → 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 (101) 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/earnings.ts +4 -2
  4. package/src/app/global-shortcuts.ts +10 -0
  5. package/src/app/keybindings/actions.ts +16 -0
  6. package/src/app/runtime/desktop-deeplink.ts +1 -1
  7. package/src/app.tsx +1 -1
  8. package/src/architecture/core-builtin-allowlist.json +4 -9
  9. package/src/cli/commands/market.ts +34 -16
  10. package/src/cli/commands/options-chain.ts +29 -33
  11. package/src/cli/commands/options-leaps.ts +462 -63
  12. package/src/cli/commands/search.ts +10 -7
  13. package/src/cli/commands/ticker.ts +1 -1
  14. package/src/cli/known-gaps.ts +22 -0
  15. package/src/cli/pane-functions/catalog.ts +6 -3
  16. package/src/cli/pane-functions/dom.ts +17 -2
  17. package/src/cli/pane-functions/function-help.ts +1 -1
  18. package/src/cli/pane-functions/index.ts +5 -3
  19. package/src/cli/pane-functions/report-notices.ts +15 -0
  20. package/src/cli/symbol-args.ts +19 -0
  21. package/src/components/command-bar/workflow/builtin.ts +2 -2
  22. package/src/components/command-bar/workflow/collection-actions.ts +1 -1
  23. package/src/components/feedback-dialog.tsx +1 -1
  24. package/src/components/layout/shell/index.tsx +1 -1
  25. package/src/components/layout/status-bar-groups.ts +1 -1
  26. package/src/components/layout/status-bar.tsx +2 -2
  27. package/src/components/markdown-text.tsx +14 -2
  28. package/src/components/reader/article-reader.tsx +1 -1
  29. package/src/components/reader/detail-reader.tsx +10 -7
  30. package/src/components/reader/reader-text.tsx +32 -17
  31. package/src/layout-marketplace/gallery.tsx +2 -2
  32. package/src/layout-marketplace/linked-sync.ts +1 -1
  33. package/src/market-data/market/freshness.ts +3 -0
  34. package/src/market-data/published-apac-sessions.ts +12 -2
  35. package/src/plugins/builtin/chart-composer/index.tsx +1 -1
  36. package/src/plugins/builtin/chart-composer/presets.ts +33 -1
  37. package/src/plugins/builtin/chat/channel-labels.ts +2 -2
  38. package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -1
  39. package/src/plugins/builtin/chat/content/index.tsx +2 -2
  40. package/src/plugins/builtin/chat/conversations.ts +1 -1
  41. package/src/plugins/builtin/chat/index.tsx +2 -2
  42. package/src/plugins/builtin/chat/share/flow.tsx +1 -1
  43. package/src/plugins/builtin/chat/sidebar-rows.ts +1 -1
  44. package/src/plugins/builtin/chat/sidebar.tsx +2 -2
  45. package/src/plugins/builtin/chat/unread-inbox-pane.tsx +2 -2
  46. package/src/plugins/builtin/cloud/auth-actions.tsx +1 -1
  47. package/src/plugins/builtin/cloud/auth-commands.ts +1 -1
  48. package/src/plugins/builtin/cloud/mcp-connect/dialog.tsx +1 -1
  49. package/src/plugins/builtin/cloud/team/acm-tab.tsx +3 -3
  50. package/src/plugins/builtin/cloud/team/capability.ts +2 -2
  51. package/src/plugins/builtin/cloud/team/command.ts +4 -4
  52. package/src/plugins/builtin/cloud/team/module.tsx +3 -3
  53. package/src/plugins/builtin/cloud/team/pane-model.ts +2 -2
  54. package/src/plugins/builtin/cloud/team/pane-sections.tsx +1 -1
  55. package/src/plugins/builtin/cloud/team/pane-ui.tsx +1 -1
  56. package/src/plugins/builtin/cloud/team/pane.tsx +3 -3
  57. package/src/plugins/builtin/cloud/team/status-widget.tsx +3 -3
  58. package/src/plugins/builtin/cloud/team/team-state-host.ts +1 -1
  59. package/src/plugins/builtin/cloud/team/views.ts +2 -2
  60. package/src/plugins/builtin/cloud/thesis/board-pane.tsx +1 -1
  61. package/src/plugins/builtin/cloud/thesis/detail.tsx +1 -1
  62. package/src/plugins/builtin/cloud/thesis/ticker-tab.tsx +1 -1
  63. package/src/plugins/builtin/cloud/upgrade-command.ts +1 -1
  64. package/src/plugins/builtin/custom-view/pane.tsx +2 -2
  65. package/src/plugins/builtin/dividend-yield/headless.ts +8 -2
  66. package/src/plugins/builtin/dividend-yield/pane.tsx +4 -2
  67. package/src/plugins/builtin/docs/blocks.tsx +13 -2
  68. package/src/plugins/builtin/docs/pane.tsx +6 -5
  69. package/src/plugins/builtin/earnings/board-model.ts +18 -1
  70. package/src/plugins/builtin/earnings/board-table.ts +18 -3
  71. package/src/plugins/builtin/earnings/headless.ts +13 -2
  72. package/src/plugins/builtin/earnings/history-model.ts +1 -1
  73. package/src/plugins/builtin/earnings/history-table.ts +2 -2
  74. package/src/plugins/builtin/help/feedback-tab.tsx +1 -1
  75. package/src/plugins/builtin/notes/index.tsx +1 -1
  76. package/src/plugins/builtin/notes/owner.tsx +2 -2
  77. package/src/plugins/builtin/options/carry.ts +36 -0
  78. package/src/plugins/builtin/options/open-interest-as-of.ts +16 -5
  79. package/src/plugins/builtin/options/view.tsx +19 -7
  80. package/src/plugins/builtin/options-calculator/pane.tsx +56 -1
  81. package/src/plugins/builtin/options-calculator/premium-history.ts +34 -0
  82. package/src/plugins/builtin/portfolio-list/mutations.ts +1 -1
  83. package/src/plugins/builtin/portfolio-list/pane/remove-ticker.ts +1 -1
  84. package/src/plugins/builtin/research/corporate-actions-pane.tsx +4 -2
  85. package/src/plugins/builtin/research/event-model.ts +7 -4
  86. package/src/plugins/builtin/series-inventory.ts +1 -0
  87. package/src/plugins/builtin/shared/earnings-report.ts +10 -4
  88. package/src/renderers/dom/styles.css +4 -2
  89. package/src/sources/gloomberb-cloud/normalizers.ts +2 -0
  90. package/src/{plugins/builtin/cloud/team → team}/collections-sync.ts +6 -6
  91. package/src/{plugins/builtin/cloud/team → team}/collections.ts +3 -3
  92. package/src/{plugins/builtin/cloud/team → team}/model.ts +2 -2
  93. package/src/{plugins/builtin/cloud/team → team}/pane-request.ts +1 -1
  94. package/src/{plugins/builtin/cloud/team → team}/store.ts +3 -3
  95. package/src/types/data-provider.ts +3 -0
  96. package/src/types/financials.ts +6 -0
  97. package/src/utils/decimal-comma.ts +18 -0
  98. package/src/utils/exchanges.ts +7 -0
  99. package/src/utils/forward-dividend.ts +39 -9
  100. package/src/utils/option-expiry.ts +16 -0
  101. package/src/utils/sec.ts +2 -1
@@ -23,7 +23,7 @@ import { fail } from "../errors";
23
23
  import type { MarketContext } from "../types";
24
24
  import type { CliCommandContext, GloomPlugin } from "../../types/plugin";
25
25
  import { indexRootFor, indexRootTryLine } from "../index-roots";
26
- import { knownGapMessage } from "../known-gaps";
26
+ import { knownGapMessage, settingsPointer } from "../known-gaps";
27
27
  import { instrumentTypeLabel } from "../../tickers/instrument-kind";
28
28
  import { canonicalExchange, isUsListingExchange } from "../../utils/exchanges";
29
29
  import { groupByCompany } from "../../tickers/company-name";
@@ -325,9 +325,9 @@ function catalogHint(query: string): string {
325
325
 
326
326
  /**
327
327
  * The notes beside the results: the index a bare root names, data the query
328
- * asks for that no source carries yet, the central bank it names, and for an
329
- * empty search the catalog, unless a known gap, the bank or the functions
330
- * listed already said where to look instead.
328
+ * asks for that no source carries yet, the central bank it names, the setting
329
+ * it names ("light theme"), and for an empty search the catalog, unless one of
330
+ * those or the functions listed already said where to look instead.
331
331
  */
332
332
  function searchNotes(
333
333
  query: string,
@@ -337,9 +337,10 @@ function searchNotes(
337
337
  ): string[] {
338
338
  const gap = knownGapMessage(query);
339
339
  const bank = searchedCentralBank(query);
340
+ const setting = settingsPointer(query);
340
341
  const empty = candidates.length === 0 && !fate.stopped;
341
- return [...fate.notes, indexRootNote(query, candidates), gap, bank ? centralBankLine(bank) : null,
342
- empty && !gap && !bank && functions.length === 0 ? catalogHint(query) : null]
342
+ return [...fate.notes, indexRootNote(query, candidates), gap, bank ? centralBankLine(bank) : null, setting,
343
+ empty && !gap && !bank && !setting && functions.length === 0 ? catalogHint(query) : null]
343
344
  .filter((note): note is string => !!note);
344
345
  }
345
346
 
@@ -379,6 +380,8 @@ export function buildSearchReport({
379
380
  if (gap) lines.push("", gap);
380
381
  const bank = searchedCentralBank(query);
381
382
  if (bank) lines.push("", centralBankLine(bank));
383
+ const setting = settingsPointer(query);
384
+ if (setting) lines.push("", setting);
382
385
  const charted = renderSeriesMatches(query, series, SEARCH_SERIES_LIMIT, cliTerminalWidth() ?? 100, "catalog");
383
386
  // A query that names the data ("carbon", "day-ahead") lists its series first; words that happen to
384
387
  // be in a series name ("apple") list them after the tickers.
@@ -390,7 +393,7 @@ export function buildSearchReport({
390
393
  if (candidates.length === 0) {
391
394
  if (fate.stopped) return lines.join("\n");
392
395
  if (functions.length > 0) lines.push("", renderFunctionMatches(functions));
393
- else if (!gap && !bank && !charted) lines.push(cliStyles.muted("No matches found."), cliStyles.muted(catalogHint(query)));
396
+ else if (!gap && !bank && !setting && !charted) lines.push(cliStyles.muted("No matches found."), cliStyles.muted(catalogHint(query)));
394
397
  return lines.join("\n");
395
398
  }
396
399
 
@@ -347,7 +347,7 @@ function percentFigure(fraction: number | null | undefined, format: (value: numb
347
347
  return fraction != null ? { text: format(fraction), value: fraction * 100, unit: "%" } : { text: "—" };
348
348
  }
349
349
 
350
- /** A forward yield says what it annualizes and when, as DVD does; any other yield is the figure alone. */
350
+ /** A forward yield says what it annualizes and when, as DVD does (a declared distribution by its ex-date); any other yield is the figure alone. */
351
351
  function forwardYieldFigure(fundamentals: TickerFinancials["fundamentals"]): MetricFigure {
352
352
  const figure = percentFigure(fundamentals?.dividendYield, formatFractionPercentCell);
353
353
  return fundamentals?.dividendYieldBasis === "forward" && fundamentals.dividendYield != null
@@ -82,6 +82,28 @@ export function topicPointer(query: string): { message: string; codes: string[]
82
82
  return { message: `${pointer.topic}: not in the terminal yet; closest: ${closest}`, codes: pointer.closest.map(([code]) => code) };
83
83
  }
84
84
 
85
+ /**
86
+ * Settings people look for by name that are a `config` command, not a function or a ticker: the line `search` and
87
+ * `catalog` print beside whatever else the query finds. A query asks for one when every word is a word of that
88
+ * setting and one of them names it: "light theme", "dark", "colour-blind", but not "ai theme" (THEM's basket) or
89
+ * "dark pool".
90
+ */
91
+ const SETTING_POINTERS: ReadonlyArray<{ names: readonly string[]; words: readonly string[]; line: string }> = [
92
+ {
93
+ names: ["theme", "themes", "light", "dark", "colorblind", "colourblind", "blind"],
94
+ words: ["mode", "color", "colour", "colors", "colours", "high", "contrast", "change", "switch", "set", "setting", "settings"],
95
+ line: "Themes: gloomberb config themes",
96
+ },
97
+ ];
98
+
99
+ /** The `config` command for a query that names a setting ("light theme"), else null. */
100
+ export function settingsPointer(query: string): string | null {
101
+ const asked = words(query);
102
+ if (asked.length === 0) return null;
103
+ return SETTING_POINTERS.find(({ names, words: other }) => asked.some((word) => names.includes(word))
104
+ && asked.every((word) => names.includes(word) || other.includes(word)))?.line ?? null;
105
+ }
106
+
85
107
  /** Letters and digits only, lower case: `EU-ETS`, `eu ets` and `EUETS` are one lookup. */
86
108
  function lookupKey(text: string): string {
87
109
  return text.toLowerCase().replace(/[^a-z0-9]/g, "");
@@ -575,25 +575,28 @@ export function renderPaneCatalogReport(
575
575
  glossary: GlossaryLookup = { exact: [], partial: [] },
576
576
  gap: string | null = null,
577
577
  series: readonly SeriesMatch[] = [],
578
+ /** The `config` command a query names a setting of ("light theme"), printed last, beside the functions. */
579
+ setting: string | null = null,
578
580
  ): string {
579
581
  const query = args.query.trim().toLowerCase();
580
582
  const exact = query
581
583
  ? entries.find((entry) => entry.token.toLowerCase() === query)
582
584
  ?? entries.find((entry) => entry.aliases.some((alias) => alias.toLowerCase() === query))
583
585
  : undefined;
584
- if (exact) return renderCatalogEntry(exact);
586
+ const withSetting = (text: string) => [text, setting ?? ""].filter(Boolean).join("\n\n");
587
+ if (exact) return withSetting(renderCatalogEntry(exact));
585
588
 
586
589
  const width = Math.min(cliTerminalWidth() ?? CATALOG_TEXT_WIDTH, CATALOG_TEXT_WIDTH);
587
590
  const terms = renderCatalogGlossary(glossary, width);
588
591
  const charted = renderSeriesMatches(args.query.trim(), series, Number.isFinite(args.limit) ? CATALOG_SERIES_LIMIT : series.length, width);
589
- const withGap = (text: string) => [gap ? wrapText(gap, width).join("\n") : "", text].filter(Boolean).join("\n\n");
592
+ const withGap = (text: string) => withSetting([gap ? wrapText(gap, width).join("\n") : "", text].filter(Boolean).join("\n\n"));
590
593
  const withTerms = (text: string) => withGap([text, charted, terms].filter(Boolean).join("\n\n"));
591
594
  if (args.query && entries.length === 1) return withTerms(renderCatalogEntry(entries[0]!));
592
595
 
593
596
  const note = (text: string) => wrapText(text, width).map((line) => cliStyles.muted(line)).join("\n");
594
597
  if (entries.length === 0) {
595
598
  if (charted) return withGap([charted, terms].filter(Boolean).join("\n\n"));
596
- if (glossary.exact.length > 0 || gap) return withGap(terms);
599
+ if (glossary.exact.length > 0 || gap || setting) return withGap(terms);
597
600
  return withTerms(note(args.query
598
601
  ? `No functions match "${args.query}". Run gloomberb catalog to browse them all.`
599
602
  : "No functions are available."));
@@ -10,7 +10,7 @@ import { lockNoteReason } from "../../plugins/builtin/shared/pro-lock-note";
10
10
  import { renderDesktopShot, type PaneScreenshotResult } from "./screenshot";
11
11
  import { deriveRenderedFreshness, formatFreshnessLine, type ReportFreshness } from "./freshness";
12
12
  import { exportTextTable, reportFooterLines, type CliReportTables } from "../report-tables";
13
- import { renderedReportFreshness, renderedReportNotices, renderedReportObservations } from "./report-notices";
13
+ import { renderedReportFailure, renderedReportFreshness, renderedReportNotices, renderedReportObservations } from "./report-notices";
14
14
 
15
15
  const DOM_REPORT_WIDTH = 1280;
16
16
  const DOM_REPORT_HEIGHT = 720;
@@ -332,13 +332,21 @@ function failedDomReport(
332
332
  };
333
333
  }
334
334
 
335
+ /** A report the rendered pane says it cannot give; the command fails with the pane's words and advice line. */
336
+ class RenderedReportFailure extends Error {
337
+ constructor(message: string, readonly details?: string) {
338
+ super(message);
339
+ }
340
+ }
341
+
335
342
  export async function buildDomFunctionReport(
336
343
  resolved: ResolvedPaneFunction,
337
344
  context: MarketContext,
338
345
  rawArg: string,
339
346
  ): Promise<PaneFunctionReport> {
347
+ let result: PaneScreenshotResult;
340
348
  try {
341
- const result = await renderDesktopShot({
349
+ result = await renderDesktopShot({
342
350
  resolved,
343
351
  context,
344
352
  rawArg,
@@ -351,6 +359,13 @@ export async function buildDomFunctionReport(
351
359
  options: {},
352
360
  captureImage: false,
353
361
  });
362
+ } catch (error) {
363
+ return failedDomReport(resolved, rawArg, error);
364
+ }
365
+ // No header and no partial view: the error alone, and a failed exit, as the plain command gives.
366
+ const failure = renderedReportFailure(result.render.semanticUi ?? []);
367
+ if (failure) throw new RenderedReportFailure(failure.message, failure.details);
368
+ try {
354
369
  return buildDomPaneReportFromRender(resolved, result);
355
370
  } catch (error) {
356
371
  return failedDomReport(resolved, rawArg, error);
@@ -395,7 +395,7 @@ export const FUNCTION_HELP: Readonly<Record<string, FunctionHelp>> = {
395
395
  bloomberg: ["ERN"],
396
396
  },
397
397
  DVD: {
398
- summary: "Cash distributions, trailing yield, the forward yield fundamentals show (latest distribution annualized), and payout growth. Future payments are not forecast.",
398
+ summary: "Cash distributions, trailing yield, the forward yield fundamentals show (the latest distribution annualized, or the declared one once announced), and payout growth. Future payments are not forecast.",
399
399
  usage: ["DVD KO"],
400
400
  keys: [STEP],
401
401
  data: same("Daily"),
@@ -26,7 +26,7 @@ import {
26
26
  import { incompleteReportGateMessage } from "./access-gate";
27
27
  import { withPersistedCloudSession } from "./cloud-session";
28
28
  import { ListingArgError, listingReadNotes, loadForListing, quotedListingChoiceNotes } from "../listing-arg";
29
- import { knownGapMessage, topicPointer } from "../known-gaps";
29
+ import { knownGapMessage, settingsPointer, topicPointer } from "../known-gaps";
30
30
  import { selectReportTables } from "../report-tables";
31
31
  import { cliOutputWritten } from "../result";
32
32
  import { CLI_EXIT_CODES } from "../exit-codes";
@@ -316,6 +316,8 @@ export async function runPaneCatalog(args: string[], ctx: CliCommandContext) {
316
316
  const glossary = lookupGlossary(parsed.query);
317
317
  // Data no source carries yet is said once: in the text, else as the envelope's warning.
318
318
  const gap = knownGapMessage(parsed.query);
319
+ // A setting is a command, not a function: "light theme" names `config themes` beside THEM.
320
+ const setting = settingsPointer(parsed.query);
319
321
  // The data catalog series the query names, which `fn G` charts.
320
322
  const series = findCatalogSeries(ctx.plugins, parsed.query);
321
323
  const listed = pointer
@@ -324,9 +326,9 @@ export async function runPaneCatalog(args: string[], ctx: CliCommandContext) {
324
326
  ctx.printResult({
325
327
  data: listed,
326
328
  ...series.length ? { metadata: { series } } : {},
327
- ...gap && ctx.cliOptions.format !== "text" ? { warnings: [gap] } : {},
329
+ ...(gap || setting) && ctx.cliOptions.format !== "text" ? { warnings: [gap, setting].filter((note): note is string => !!note) } : {},
328
330
  }, {
329
- text: () => renderPaneCatalogReport(filtered, effectiveParsed, glossary, gap, series),
331
+ text: () => renderPaneCatalogReport(filtered, effectiveParsed, glossary, gap, series, setting),
330
332
  });
331
333
  } finally {
332
334
  registry.destroy();
@@ -26,6 +26,21 @@ export function renderedReportNotices(semanticUi: readonly RemoteUiNodeSnapshot[
26
26
  return { notices, facts };
27
27
  }
28
28
 
29
+ /**
30
+ * Why a rendered pane cannot give the report it was asked for, from the
31
+ * `report-failure` node it publishes: `{ message, details? }`. OMON opened on
32
+ * an expiry the chain does not list says so in its body; the report fails with
33
+ * the error the plain command (`options --expiration`) prints, instead of
34
+ * reading that body back as a view. Null when the pane publishes none.
35
+ */
36
+ export function renderedReportFailure(semanticUi: readonly RemoteUiNodeSnapshot[]): { message: string; details?: string } | null {
37
+ const metadata = semanticUi.find((node) => node.role === "report-failure")?.metadata;
38
+ const message = typeof metadata?.message === "string" ? metadata.message.trim() : "";
39
+ if (!message) return null;
40
+ const details = typeof metadata?.details === "string" && metadata.details.trim() ? metadata.details.trim() : undefined;
41
+ return details ? { message, details } : { message };
42
+ }
43
+
29
44
  const FRESHNESS_STATUSES: readonly HeadlessFreshnessStatus[] = ["live", "delayed", "stale", "not-a-feed"];
30
45
 
31
46
  /** The `sources` a pane publishes: each a name and a time. */
@@ -13,6 +13,25 @@ export function symbolInputKey(symbol: string): string {
13
13
  return symbol.trim().replace(/^\$/, "").toUpperCase();
14
14
  }
15
15
 
16
+ /**
17
+ * How a command's symbol list spots one security typed twice: `symbolInputKey`,
18
+ * with a share class written with a dot or a dash as one (BRK.B and BRK-B).
19
+ */
20
+ function securityInputKey(symbol: string): string {
21
+ return symbolInputKey(symbol).replace(/-/g, ".");
22
+ }
23
+
24
+ /** The securities of a symbol list, each once, in the first spelling typed: `AAPL aapl BRK.B BRK-B` is AAPL and BRK.B. */
25
+ export function uniqueSecuritySymbols(symbols: readonly string[]): string[] {
26
+ const seen = new Set<string>();
27
+ return symbols.filter((symbol) => {
28
+ const key = securityInputKey(symbol);
29
+ if (!key || seen.has(key)) return false;
30
+ seen.add(key);
31
+ return true;
32
+ });
33
+ }
34
+
16
35
  /** The symbols without repeats, first spelling kept, and how many repeats were dropped. */
17
36
  export function uniqueSymbols(symbols: readonly string[]): { symbols: string[]; repeated: number } {
18
37
  const seen = new Set<string>();
@@ -5,8 +5,8 @@ import {
5
5
  } from "../../../plugins/builtin/portfolio-list/command-bar";
6
6
  import type { CommandBarFieldOption, CommandBarWorkflowField, CommandBarWorkflowRoute } from "./types";
7
7
  import { buildCommandBarWorkflowRoute } from "./route-builder";
8
- import { teamPrefix } from "../../../plugins/builtin/cloud/team/model";
9
- import { teamStore } from "../../../plugins/builtin/cloud/team/store";
8
+ import { teamPrefix } from "../../../team/model";
9
+ import { teamStore } from "../../../team/store";
10
10
 
11
11
  /**
12
12
  * Where a new collection lives: me, or one of my teams. Only asked when there
@@ -1,7 +1,7 @@
1
1
  import type { Dispatch } from "react";
2
2
  import { apiClient } from "../../../api-client";
3
3
  import { t } from "../../../i18n";
4
- import { teamCollectionLocalId } from "../../../plugins/builtin/cloud/team/collections";
4
+ import { teamCollectionLocalId } from "../../../team/collections";
5
5
  import type { DataProvider } from "../../../types/data-provider";
6
6
  import type { AppTickerRepositoryPort } from "../../../core/app-service-ports";
7
7
  import type { PluginRegistry } from "../../../plugins/registry";
@@ -19,7 +19,7 @@ import {
19
19
  } from "../feedback/types";
20
20
  import { t, tf } from "../i18n";
21
21
  import { useAppLanguage } from "../i18n/react";
22
- import { requestAuthDialog } from "../plugins/builtin/cloud/auth-dialog";
22
+ import { requestAuthDialog } from "../account/auth-dialog";
23
23
  import { getSharedRegistry } from "../plugins/registry";
24
24
  import { useViewport } from "../react/input";
25
25
  import { useAppStateRef } from "../state/app/context";
@@ -61,7 +61,7 @@ import {
61
61
  useShellVisibleLayout,
62
62
  } from "./layout-state";
63
63
  import { restoreShellHiddenPanes } from "./visible-layout";
64
- import { AuthDialogHost } from "../../../plugins/builtin/cloud/auth-dialog";
64
+ import { AuthDialogHost } from "../../../account/auth-dialog";
65
65
  import { DeviceSignInDialogHost } from "../../../account/device-signin-dialog";
66
66
  import { McpConnectDialogHost } from "../../../plugins/builtin/cloud/mcp-connect/dialog";
67
67
  import { BrokerSignInDialogHost } from "../../../brokers/signed-in/sign-in-dialog";
@@ -1,6 +1,6 @@
1
1
  import type { TeamSummary } from "../../api-client";
2
2
  import type { SavedLayout } from "../../types/config";
3
- import type { TeamStoreSnapshot } from "../../plugins/builtin/cloud/team/store";
3
+ import type { TeamStoreSnapshot } from "../../team/store";
4
4
 
5
5
  export type TabGroupId = "personal" | `team:${string}`;
6
6
 
@@ -43,8 +43,8 @@ import { confirmDialog } from "../ui/confirm-dialog";
43
43
  import { Tabs } from "../ui/tabs";
44
44
  import { useTransientLayout } from "./transient-layout";
45
45
  import { linkedLayoutMarker, linkedLayoutStatus, linkedLayoutUpdates } from "../../layout-marketplace/linked";
46
- import { teamAccentHex } from "../../plugins/builtin/cloud/team/model";
47
- import { teamStore } from "../../plugins/builtin/cloud/team/store";
46
+ import { teamAccentHex } from "../../team/model";
47
+ import { teamStore } from "../../team/store";
48
48
  import { buildStatusBarTabGroups, groupIdFromMarkerValue, groupMarkerValue } from "./status-bar-groups";
49
49
  import { requestFeedbackDialog } from "../feedback-dialog";
50
50
  import { openFormModal } from "../form-modal";
@@ -17,6 +17,8 @@ export interface MarkdownTextProps {
17
17
  lineWidth?: number;
18
18
  /** Desktop and web: the space a blank line leaves between paragraphs, in CSS units; one line when unset. */
19
19
  paragraphGap?: string;
20
+ /** Desktop and web: the size, weight and space of a heading line, such as a reader's section heading; the text's own when unset. */
21
+ headingStyle?: Readonly<Record<string, string | number>>;
20
22
  catalog?: Record<string, InlineTickerCatalogEntry>;
21
23
  textColor?: string;
22
24
  openTicker?: (symbol: string) => void;
@@ -86,9 +88,11 @@ function parseInlineMarkdown(text: string, links = false): StyledSegment[] {
86
88
  return segments;
87
89
  }
88
90
 
91
+ const HEADING_RE = /^(#{1,3})\s+(.+)$/;
92
+
89
93
  function parseLine(line: string, links: boolean): ParsedLine {
90
94
  // Headings
91
- const headingMatch = line.match(/^(#{1,3})\s+(.+)$/);
95
+ const headingMatch = line.match(HEADING_RE);
92
96
  if (headingMatch) {
93
97
  return {
94
98
  heading: true,
@@ -595,6 +599,7 @@ export function MarkdownText({
595
599
  openTicker = () => {},
596
600
  onOpenLink,
597
601
  paragraphGap,
602
+ headingStyle,
598
603
  }: MarkdownTextProps) {
599
604
  const { nativePaneChrome } = useUiCapabilities();
600
605
  const links = onOpenLink !== undefined;
@@ -620,12 +625,15 @@ export function MarkdownText({
620
625
  }
621
626
  const line = block.text;
622
627
  if (line.trim() === "") {
628
+ // Under a heading set in its own style, the heading's margin is the space: it sits with what it names.
629
+ const previous = blocks[index - 1];
630
+ if (nativePaneChrome && headingStyle && previous?.kind === "line" && HEADING_RE.test(previous.text)) return null;
623
631
  return nativePaneChrome && paragraphGap
624
632
  ? <Box key={index} flexShrink={0} style={{ height: paragraphGap }} />
625
633
  : <Text key={index}>{" "}</Text>;
626
634
  }
627
635
  const parsed = parseLine(line, links);
628
- return (
636
+ const markdownLine = (
629
637
  <MarkdownLine
630
638
  key={index}
631
639
  parsed={parsed}
@@ -638,6 +646,10 @@ export function MarkdownText({
638
646
  onOpenLink={onOpenLink}
639
647
  />
640
648
  );
649
+ // A line's height comes from --cell-h, in em of its own size.
650
+ return nativePaneChrome && headingStyle && parsed.heading
651
+ ? <Box key={index} flexDirection="column" width="100%" style={{ ...headingStyle, "--cell-h": `${headingStyle.lineHeight ?? 1.35}em` }}>{markdownLine}</Box>
652
+ : markdownLine;
641
653
  })}
642
654
  </Box>
643
655
  );
@@ -375,7 +375,7 @@ function ArticleReaderView({
375
375
  <ReaderText text={lead} geometry={geometry} color={colors.textBright} variant="lead" />
376
376
  ) : null}
377
377
  {!raw && facts.length > 0 ? (
378
- <Box flexDirection="column" style={desktop ? { margin: "0.3em 0 1.3em" } : undefined}>
378
+ <Box flexDirection="column" style={desktop ? { margin: "0.3em 0 1em" } : undefined}>
379
379
  <ReaderText text={readerLabel("Key facts")} geometry={geometry} color={colors.textDim} variant="label" />
380
380
  {facts.map((fact, index) => (
381
381
  <ReaderText key={index} text={fact} geometry={geometry} color={colors.text} variant="fact" segments={figureSegments(fact)} figureColor={colors.textBright} />
@@ -9,7 +9,7 @@ import { DetailScrollBody } from "../ui/detail-scroll-body";
9
9
  import { KeyValueRow } from "../ui/display";
10
10
  import { ExternalLink, usePaneLinkMenuEntry } from "../ui/external-link";
11
11
  import { parseArticle } from "./article-text";
12
- import { DESKTOP_LINE_HEIGHT, READER_APP_FONT, READER_COLUMN_FONT, ReaderBlock, ReaderText, type ReaderGeometry } from "./reader-text";
12
+ import { DESKTOP_LINE_HEIGHT, READER_APP_FONT, READER_COLUMN_FONT, READER_HEADING_STYLE, ReaderBlock, ReaderText, type ReaderGeometry } from "./reader-text";
13
13
 
14
14
  export interface DetailFact {
15
15
  label: string;
@@ -50,14 +50,17 @@ const URL_LINE = /^https?:\/\/\S+$/;
50
50
 
51
51
  const COLUMN_STYLE = { fontSize: READER_COLUMN_FONT, lineHeight: DESKTOP_LINE_HEIGHT } as const;
52
52
  const META_STYLE = { marginBottom: "0.8em" } as const;
53
- /** Its columns come from styles.css: one pair to a row, or two in a wide reader. */
53
+ /**
54
+ * At the app's own size, as metadata is in every reader. Its columns come from
55
+ * styles.css: one pair to a row, or two in a wide reader.
56
+ */
54
57
  const FACTS_STYLE = {
55
58
  display: "grid",
56
- columnGap: "1.6em",
57
- rowGap: "0.2em",
58
- fontSize: "0.8em",
59
+ columnGap: "2em",
60
+ rowGap: "0.3em",
61
+ fontSize: READER_APP_FONT,
59
62
  lineHeight: 1.5,
60
- marginBottom: "1.4em",
63
+ marginBottom: "1.6em",
61
64
  } as const;
62
65
  const FACT_TEXT_STYLE = { display: "block", lineHeight: "inherit", minWidth: 0 } as const;
63
66
  /** Markdown lines take the reader's line height (the host sets Text line height from --cell-h). */
@@ -118,7 +121,7 @@ function NativeDetail({ meta, facts, body, bodyFormat, note }: DetailContentProp
118
121
  ) : null}
119
122
  {bodyFormat === "markdown" && body ? (
120
123
  <Box flexDirection="column" width="100%" style={MARKDOWN_STYLE}>
121
- <MarkdownText text={body} paragraphGap={PARAGRAPH_GAP} />
124
+ <MarkdownText text={body} paragraphGap={PARAGRAPH_GAP} headingStyle={READER_HEADING_STYLE} />
122
125
  </Box>
123
126
  ) : blocks.map((block, index) => (
124
127
  <Box key={index} flexDirection="column">
@@ -12,18 +12,32 @@ import type { ArticleBlock } from "./article-text";
12
12
  * detail reader of list panes.
13
13
  */
14
14
 
15
- /** Line height of reading text on the desktop, against its own size. */
16
- export const DESKTOP_LINE_HEIGHT = 1.65;
17
-
18
15
  /**
19
- * The reading size on the desktop against the app's own (12px by default):
20
- * 17.4px, or 16.2px in a column under 480px, where a phone sets its body. A
21
- * container query in styles.css picks it from the column's own width, so a
22
- * resize changes it with the layout and nothing counts cells.
16
+ * The reader's type scale on the desktop and the web, one for every reader
17
+ * (the article reader, the detail reader, the markdown and docs headings in
18
+ * them). Reading text sits a step above the app's own size (12px by default)
19
+ * and every other level is a modest step from it, so the page reads as part
20
+ * of the app and the levels differ by weight and space as much as by size:
21
+ *
22
+ * | Level | Wide | Column under 480px |
23
+ * |---|---|---|
24
+ * | Metadata, facts, tables, coverage | 12px, dimmer | 12px |
25
+ * | Body | 14px, line height 1.6 | 13.2px |
26
+ * | Lead | 15px | 14.1px |
27
+ * | Section heading | 16px bold | 15.2px |
28
+ * | Title | 20px bold | 18.9px |
29
+ *
30
+ * The body size comes from `--reader-em`, which a container query in
31
+ * styles.css lowers in a narrow column, so a resize changes it with the
32
+ * layout and nothing counts cells. The levels above it are in em of it.
23
33
  */
24
- export const READER_COLUMN_FONT = "calc(var(--reader-em, 1.45) * 1em)";
25
- /** Back to the app's own size inside a reading column: chips, controls, tables. */
26
- export const READER_APP_FONT = "calc(1em / var(--reader-em, 1.45))";
34
+ export const READER_COLUMN_FONT = "calc(var(--reader-em, 1.1667) * 1em)";
35
+ /** Back to the app's own size inside a reading column: metadata, chips, controls, tables. */
36
+ export const READER_APP_FONT = "calc(1em / var(--reader-em, 1.1667))";
37
+ /** Line height of reading text on the desktop, against its own size. */
38
+ export const DESKTOP_LINE_HEIGHT = 1.6;
39
+ /** A section heading in reading text: a step above the body, set apart by weight and the space above it. */
40
+ export const READER_HEADING_STYLE = { fontSize: "1.15em", fontWeight: 700, lineHeight: 1.35, margin: "0.9em 0 0.4em" } as const;
27
41
 
28
42
  export type Segment = { text: string; marked?: boolean; figure?: boolean };
29
43
 
@@ -58,14 +72,15 @@ function hasHeaderRow(rows: readonly string[][]): boolean {
58
72
  export type TextVariant = "headline" | "meta" | "lead" | "body" | "heading" | "label" | "fact" | "item";
59
73
 
60
74
  const DESKTOP_VARIANT_STYLE: Record<TextVariant, Record<string, string | number>> = {
61
- headline: { fontSize: "1.45em", lineHeight: 1.22, letterSpacing: "-0.01em" },
62
- meta: { fontSize: "0.78em", lineHeight: 1.5 },
63
- lead: { fontSize: "1.1em", lineHeight: 1.55, marginBottom: "1.1em" },
75
+ headline: { fontSize: "1.43em", lineHeight: 1.25 },
76
+ meta: { fontSize: READER_APP_FONT, lineHeight: 1.5 },
77
+ lead: { fontSize: "1.07em", lineHeight: 1.55, marginBottom: "1em" },
64
78
  body: { lineHeight: DESKTOP_LINE_HEIGHT, marginBottom: "0.9em" },
65
- heading: { fontSize: "1.08em", lineHeight: 1.35, margin: "0.6em 0 0.35em" },
66
- label: { fontSize: "0.8em", lineHeight: 1.6, marginBottom: "0.3em" },
67
- fact: { fontSize: "0.95em", lineHeight: 1.55, marginBottom: "0.35em" },
68
- item: { lineHeight: 1.6, marginBottom: "0.3em" },
79
+ heading: READER_HEADING_STYLE,
80
+ // Names what follows (key facts, coverage) as a section heading does, without the air above a section.
81
+ label: { ...READER_HEADING_STYLE, margin: "0 0 0.4em" },
82
+ fact: { fontSize: "0.93em", lineHeight: 1.55, marginBottom: "0.35em" },
83
+ item: { lineHeight: DESKTOP_LINE_HEIGHT, marginBottom: "0.3em" },
69
84
  };
70
85
 
71
86
  function styled(segments: readonly Segment[], color: string, markColor: string, figureColor: string | undefined, attributes: number): StyledText {
@@ -11,8 +11,8 @@ import { useAppDispatch, useAppSelector } from "../state/app/context";
11
11
  import { useDialog, useDialogState, type PromptContext } from "../ui/dialog";
12
12
  import { useRendererHost, useUiCapabilities } from "../ui";
13
13
  import { apiClient, type TeamSummary } from "../api-client";
14
- import { requestAuthDialog } from "../plugins/builtin/cloud/auth-dialog";
15
- import { teamStore } from "../plugins/builtin/cloud/team/store";
14
+ import { requestAuthDialog } from "../account/auth-dialog";
15
+ import { teamStore } from "../team/store";
16
16
  import { usePlanAccess } from "../api-client/plan-access";
17
17
  import { getMarketplaceHost } from "../plugins/marketplace/store";
18
18
  import { rememberLayoutRequirements } from "../components/layout/missing-pane";
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
2
2
  import { apiClient } from "../api-client";
3
3
  import { rememberLayoutRequirements } from "../components/layout/missing-pane";
4
4
  import type { PluginRegistry } from "../plugins/registry";
5
- import { teamStore } from "../plugins/builtin/cloud/team/store";
5
+ import { teamStore } from "../team/store";
6
6
  import { useAppDispatch, useAppSelector } from "../state/app/context";
7
7
  import { decideLinkedAction, linkedLayoutStatus, linkedLayoutUpdates, originFromEntry } from "./linked";
8
8
  import { materializeMarketplaceLayout } from "../shares/portable-layout";
@@ -50,6 +50,7 @@ const REGULAR_OPEN_MINUTES: Record<string, number> = {
50
50
  FWB: 8 * 60,
51
51
  SWX: 9 * 60,
52
52
  VIE: 9 * 60,
53
+ WBDM: 9 * 60,
53
54
  OSL: 9 * 60,
54
55
  ICEX: 9 * 60 + 30,
55
56
  WSE: 9 * 60,
@@ -89,6 +90,8 @@ const REGULAR_CLOSE_MINUTES: Record<string, number> = {
89
90
  JPX: 15 * 60 + 30, HKEX: 16 * 60 + 10, TWSE: 14 * 60 + 30, TPEX: 14 * 60 + 30,
90
91
  NSE: 16 * 60, BSE: 16 * 60, ASX: 16 * 60 + 15, SGX: 17 * 60 + 20, KRX: 16 * 60, KOSDAQ: 16 * 60,
91
92
  NZX: 17 * 60, SSE: 15 * 60 + 30, SZSE: 15 * 60 + 30,
93
+ // The Vienna MTF's ETPs and notes trade in continuous auctions to 17:30, with no closing auction.
94
+ WBDM: 17 * 60 + 30,
92
95
  BMV: 15 * 60 + 10, B3: 18 * 60 + 30, BYMA: 17 * 60 + 10, JSE: 17 * 60 + 15,
93
96
  // Monday to Thursday: trading at last ends 17:30, after the closing auction.
94
97
  // Not later: a complete 5-minute copy ends with its 17:09 bar, which must
@@ -1,5 +1,5 @@
1
- // Published full-day closures (weekend dates omitted) for Asia-Pacific venues and EGX,
2
- // not a holiday-rule engine. A year a venue has not published is unknown, not
1
+ // Published full-day closures (weekend dates omitted) for Asia-Pacific venues, EGX and Wiener
2
+ // Boerse, not a holiday-rule engine. A year a venue has not published is unknown, not
3
3
  // open: every weekday of it reads as a session. Half-day sessions (HKEX and
4
4
  // SGX eves, ASX's 14:10 closes) are not modelled. Checked 2026-10-09:
5
5
  // KRX, also KOSDAQ: http://open.krx.co.kr/contents/MKD/01/0110/01100305/MKD01100305.jsp
@@ -9,6 +9,9 @@
9
9
  // (some moved, as its list warns), and on National Day, 16 and 17 Dec. Its week is Sunday to Thursday.
10
10
  // HKEX: https://www.hkex.com.hk/-/media/HKEX-Market/Services/Circulars-and-Notices/Participant-and-Members-Circulars/SEHK/2025/ce_SEHK_CT_075_2025.pdf
11
11
  // and https://www.hkex.com.hk/-/media/HKEX-Market/Services/Circulars-and-Notices/Participant-and-Members-Circulars/SEHK/2026/ce_SEHK_CT_077_2026.pdf
12
+ // Wiener Boerse closes its main market (VIE) and the Vienna MTF (WBDM) on its stock exchange holidays,
13
+ // and trades on Austria's other public holidays:
14
+ // https://www.wienerborse.at/uploads/u/cms/files/trading/stock-exchange-holidays-2026-en.pdf (and -2027-en.pdf)
12
15
  // SGX closes on Singapore's gazetted public holidays, one on a Sunday moving to the Monday:
13
16
  // https://www.sgx.com/stock-exchange/trading and https://www.mom.gov.sg/employment-practices/public-holidays
14
17
  // MSX closes on the holidays its site lists (msx.om exchange holidays, read 10 Oct 2026).
@@ -28,6 +31,11 @@ const KRX_CLOSURES: Record<number, readonly string[]> = {
28
31
  ],
29
32
  };
30
33
 
34
+ const WIENER_BOERSE_CLOSURES: Record<number, readonly string[]> = {
35
+ 2026: ["01-01", "04-03", "04-06", "05-01", "10-26", "12-24", "12-25", "12-31"],
36
+ 2027: ["01-01", "03-26", "03-29", "10-26", "12-24", "12-31"],
37
+ };
38
+
31
39
  const TWSE_CLOSURES: Record<number, readonly string[]> = {
32
40
  2026: [
33
41
  "01-01", "02-12", "02-13", "02-16", "02-17", "02-18", "02-19", "02-20", "02-27",
@@ -61,6 +69,8 @@ export const PUBLISHED_APAC_CLOSURES: Readonly<Record<string, Record<number, rea
61
69
  2026: ["01-01", "02-17", "02-18", "04-03", "05-01", "05-27", "06-01", "08-10", "11-09", "12-25"],
62
70
  2027: ["01-01", "02-08", "03-10", "03-26", "05-17", "05-20", "08-09", "10-28"],
63
71
  },
72
+ VIE: WIENER_BOERSE_CLOSURES,
73
+ WBDM: WIENER_BOERSE_CLOSURES,
64
74
  MSX: {
65
75
  2026: ["01-15", "01-18", "03-19", "03-22", "03-23", "05-26", "05-27", "05-28", "06-18", "08-27", "11-25", "11-26"],
66
76
  },
@@ -260,7 +260,7 @@ const chartComposerTemplates: PaneTemplateDef[] = [
260
260
  type: "text",
261
261
  // Empty falls back to the active ticker in createInstance, as `G` with no argument does.
262
262
  required: false,
263
- body: ["Enter comma-separated SYMBOL:field or FRED:series expressions, or leave empty to chart the active ticker."],
263
+ body: ["Enter comma-separated SYMBOL:field or FRED:series expressions, or leave empty to chart the active ticker. A/B charts the ratio. EUR/USD stays one series."],
264
264
  }],
265
265
  canCreate: () => true,
266
266
  createInstance: (context, options) => {