@columenlabs/lumen-ui 0.0.0-stage → 0.2.1
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/LICENSE +21 -0
- package/NOTICE +12 -0
- package/README.md +4 -2
- package/dist/add-column.d.ts +23 -0
- package/dist/add-column.js +62 -0
- package/dist/api-fetch.d.ts +9 -0
- package/dist/api-fetch.js +60 -0
- package/dist/app-switcher.d.ts +21 -0
- package/dist/app-switcher.js +12 -0
- package/dist/app-views.d.ts +18 -0
- package/dist/app-views.js +41 -0
- package/dist/assistant-context.d.ts +72 -0
- package/dist/assistant-context.js +30 -0
- package/dist/assistant-panel.d.ts +58 -0
- package/dist/assistant-panel.js +244 -0
- package/dist/assistant-screen.d.ts +124 -0
- package/dist/assistant-screen.js +252 -0
- package/dist/card-switch.d.ts +16 -0
- package/dist/card-switch.js +16 -0
- package/dist/color-dot.d.ts +14 -0
- package/dist/color-dot.js +19 -0
- package/dist/confirm.d.ts +18 -0
- package/dist/confirm.js +5 -0
- package/dist/connections.d.ts +45 -0
- package/dist/connections.js +275 -0
- package/dist/coverage-calendar-map.d.ts +27 -0
- package/dist/coverage-calendar-map.js +84 -0
- package/dist/coverage-cells.d.ts +55 -0
- package/dist/coverage-cells.js +85 -0
- package/dist/coverage-map.d.ts +112 -0
- package/dist/coverage-map.js +428 -0
- package/dist/details-table.d.ts +20 -0
- package/dist/details-table.js +13 -0
- package/dist/drawer-section.d.ts +27 -0
- package/dist/drawer-section.js +23 -0
- package/dist/floating-head.d.ts +2 -0
- package/dist/floating-head.js +57 -0
- package/dist/fold.d.ts +16 -0
- package/dist/fold.js +70 -0
- package/dist/form-issues.d.ts +32 -0
- package/dist/form-issues.js +60 -0
- package/dist/formula-input.d.ts +17 -0
- package/dist/formula-input.js +104 -0
- package/dist/frame-script.d.ts +7 -0
- package/dist/frame-script.js +103 -0
- package/dist/freshness.d.ts +24 -0
- package/dist/freshness.js +99 -0
- package/dist/group-button.d.ts +33 -0
- package/dist/group-button.js +67 -0
- package/dist/growth-chart.d.ts +77 -0
- package/dist/growth-chart.js +195 -0
- package/dist/hooks/use-keyboard-fit.d.ts +15 -0
- package/dist/hooks/use-keyboard-fit.js +29 -0
- package/dist/hooks/use-mobile.d.ts +1 -0
- package/dist/hooks/use-mobile.js +15 -0
- package/dist/icon-button.d.ts +21 -0
- package/dist/icon-button.js +22 -0
- package/dist/icon-picker.d.ts +14 -0
- package/dist/icon-picker.js +36 -0
- package/dist/index.d.ts +83 -0
- package/dist/index.js +90 -0
- package/dist/integrations.d.ts +57 -0
- package/dist/integrations.js +73 -0
- package/dist/joins.d.ts +128 -0
- package/dist/joins.js +767 -0
- package/dist/kit-config.d.ts +13 -0
- package/dist/kit-config.js +15 -0
- package/dist/lib/utils.d.ts +2 -0
- package/dist/lib/utils.js +5 -0
- package/dist/lumen-boundary.d.ts +7 -0
- package/dist/lumen-boundary.js +10 -0
- package/dist/lumen-code-editor-impl.d.ts +2 -0
- package/dist/lumen-code-editor-impl.js +16 -0
- package/dist/lumen-code-editor.d.ts +11 -0
- package/dist/lumen-code-editor.js +11 -0
- package/dist/lumen-code.d.ts +22 -0
- package/dist/lumen-code.js +75 -0
- package/dist/lumen-drawer.d.ts +30 -0
- package/dist/lumen-drawer.js +27 -0
- package/dist/lumen-echart.d.ts +58 -0
- package/dist/lumen-echart.js +129 -0
- package/dist/lumen-filter.d.ts +75 -0
- package/dist/lumen-filter.js +546 -0
- package/dist/lumen-finder.d.ts +62 -0
- package/dist/lumen-finder.js +275 -0
- package/dist/lumen-form.d.ts +34 -0
- package/dist/lumen-form.js +81 -0
- package/dist/lumen-geo-map.d.ts +9 -0
- package/dist/lumen-geo-map.js +135 -0
- package/dist/lumen-grid.d.ts +49 -0
- package/dist/lumen-grid.js +203 -0
- package/dist/lumen-layout.d.ts +150 -0
- package/dist/lumen-layout.js +661 -0
- package/dist/lumen-links.d.ts +96 -0
- package/dist/lumen-links.js +213 -0
- package/dist/lumen-page-state.d.ts +20 -0
- package/dist/lumen-page-state.js +15 -0
- package/dist/lumen-selector.d.ts +139 -0
- package/dist/lumen-selector.js +308 -0
- package/dist/lumen-source.d.ts +137 -0
- package/dist/lumen-source.js +327 -0
- package/dist/lumen-tab-drawer.d.ts +27 -0
- package/dist/lumen-tab-drawer.js +26 -0
- package/dist/lumen-tabs.d.ts +21 -0
- package/dist/lumen-tabs.js +6 -0
- package/dist/lumen-toast.d.ts +11 -0
- package/dist/lumen-toast.js +16 -0
- package/dist/lumen-trend.d.ts +18 -0
- package/dist/lumen-trend.js +157 -0
- package/dist/lumen-view-card.d.ts +35 -0
- package/dist/lumen-view-card.js +50 -0
- package/dist/lumen-view.d.ts +172 -0
- package/dist/lumen-view.js +645 -0
- package/dist/lumen-widget-charts.d.ts +57 -0
- package/dist/lumen-widget-charts.js +69 -0
- package/dist/lumen-widget.d.ts +720 -0
- package/dist/lumen-widget.js +609 -0
- package/dist/market/compare-chart.d.ts +34 -0
- package/dist/market/compare-chart.js +292 -0
- package/dist/market/indicator-picker.d.ts +29 -0
- package/dist/market/indicator-picker.js +125 -0
- package/dist/market/indicators.d.ts +63 -0
- package/dist/market/indicators.js +314 -0
- package/dist/market/live.d.ts +120 -0
- package/dist/market/live.js +444 -0
- package/dist/market/lumen-chart.d.ts +88 -0
- package/dist/market/lumen-chart.js +645 -0
- package/dist/market/market-widget.d.ts +45 -0
- package/dist/market/market-widget.js +346 -0
- package/dist/market/option-chain.d.ts +19 -0
- package/dist/market/option-chain.js +53 -0
- package/dist/market/price-chart.d.ts +128 -0
- package/dist/market/price-chart.js +619 -0
- package/dist/mcp-settings.d.ts +20 -0
- package/dist/mcp-settings.js +65 -0
- package/dist/nav.d.ts +77 -0
- package/dist/nav.js +73 -0
- package/dist/note-line.d.ts +13 -0
- package/dist/note-line.js +5 -0
- package/dist/notifications.d.ts +9 -0
- package/dist/notifications.js +166 -0
- package/dist/origin-legend.d.ts +14 -0
- package/dist/origin-legend.js +38 -0
- package/dist/overview.d.ts +84 -0
- package/dist/overview.js +105 -0
- package/dist/page-inputs.d.ts +14 -0
- package/dist/page-inputs.js +16 -0
- package/dist/page.d.ts +32 -0
- package/dist/page.js +45 -0
- package/dist/pager.d.ts +10 -0
- package/dist/pager.js +14 -0
- package/dist/prefs.d.ts +71 -0
- package/dist/prefs.js +126 -0
- package/dist/query-client.d.ts +7 -0
- package/dist/query-client.js +19 -0
- package/dist/refresh.d.ts +4 -0
- package/dist/refresh.js +19 -0
- package/dist/rename-field.d.ts +11 -0
- package/dist/rename-field.js +26 -0
- package/dist/reset-button.d.ts +9 -0
- package/dist/reset-button.js +10 -0
- package/dist/route-error.d.ts +42 -0
- package/dist/route-error.js +229 -0
- package/dist/schedules.d.ts +88 -0
- package/dist/schedules.js +221 -0
- package/dist/settings.d.ts +51 -0
- package/dist/settings.js +181 -0
- package/dist/shell.d.ts +99 -0
- package/dist/shell.js +137 -0
- package/dist/source-health.d.ts +12 -0
- package/dist/source-health.js +58 -0
- package/dist/sourced-card.d.ts +10 -0
- package/dist/sourced-card.js +69 -0
- package/dist/spotlight.d.ts +23 -0
- package/dist/spotlight.js +138 -0
- package/dist/status-health.d.ts +16 -0
- package/dist/status-health.js +41 -0
- package/dist/status.d.ts +9 -0
- package/dist/status.js +12 -0
- package/dist/summary-cards.d.ts +56 -0
- package/dist/summary-cards.js +270 -0
- package/dist/symbol-sheet-context.d.ts +4 -0
- package/dist/symbol-sheet-context.js +5 -0
- package/dist/symbol-sheet.d.ts +9 -0
- package/dist/symbol-sheet.js +24 -0
- package/dist/tasks.d.ts +29 -0
- package/dist/tasks.js +278 -0
- package/dist/theme.css +606 -0
- package/dist/tile.d.ts +37 -0
- package/dist/tile.js +23 -0
- package/dist/ui/alert-dialog.d.ts +18 -0
- package/dist/ui/alert-dialog.js +41 -0
- package/dist/ui/alert.d.ts +10 -0
- package/dist/ui/alert.js +27 -0
- package/dist/ui/avatar.d.ts +11 -0
- package/dist/ui/avatar.js +22 -0
- package/dist/ui/badge.d.ts +9 -0
- package/dist/ui/badge.js +27 -0
- package/dist/ui/breadcrumb.d.ts +11 -0
- package/dist/ui/breadcrumb.js +27 -0
- package/dist/ui/button-group.d.ts +11 -0
- package/dist/ui/button-group.js +27 -0
- package/dist/ui/button.d.ts +10 -0
- package/dist/ui/button.js +38 -0
- package/dist/ui/card.d.ts +11 -0
- package/dist/ui/card.js +24 -0
- package/dist/ui/chart.d.ts +44 -0
- package/dist/ui/chart.js +117 -0
- package/dist/ui/checkbox.d.ts +4 -0
- package/dist/ui/checkbox.js +8 -0
- package/dist/ui/collapsible.d.ts +5 -0
- package/dist/ui/collapsible.js +12 -0
- package/dist/ui/combobox.d.ts +24 -0
- package/dist/ui/combobox.js +60 -0
- package/dist/ui/command.d.ts +18 -0
- package/dist/ui/command.js +34 -0
- package/dist/ui/dialog.d.ts +23 -0
- package/dist/ui/dialog.js +50 -0
- package/dist/ui/dropdown-menu.d.ts +29 -0
- package/dist/ui/dropdown-menu.js +50 -0
- package/dist/ui/emoji-picker.d.ts +6 -0
- package/dist/ui/emoji-picker.js +28 -0
- package/dist/ui/empty.d.ts +11 -0
- package/dist/ui/empty.js +33 -0
- package/dist/ui/field.d.ts +24 -0
- package/dist/ui/field.js +65 -0
- package/dist/ui/hover-card.d.ts +6 -0
- package/dist/ui/hover-card.js +13 -0
- package/dist/ui/input-group.d.ts +16 -0
- package/dist/ui/input-group.js +56 -0
- package/dist/ui/input.d.ts +3 -0
- package/dist/ui/input.js +6 -0
- package/dist/ui/item.d.ts +23 -0
- package/dist/ui/item.js +67 -0
- package/dist/ui/kbd.d.ts +3 -0
- package/dist/ui/kbd.js +9 -0
- package/dist/ui/label.d.ts +4 -0
- package/dist/ui/label.js +7 -0
- package/dist/ui/popover.d.ts +10 -0
- package/dist/ui/popover.js +25 -0
- package/dist/ui/progress.d.ts +4 -0
- package/dist/ui/progress.js +9 -0
- package/dist/ui/resizable.d.ts +7 -0
- package/dist/ui/resizable.js +13 -0
- package/dist/ui/scroll-area.d.ts +5 -0
- package/dist/ui/scroll-area.js +10 -0
- package/dist/ui/select.d.ts +15 -0
- package/dist/ui/select.js +36 -0
- package/dist/ui/separator.d.ts +4 -0
- package/dist/ui/separator.js +7 -0
- package/dist/ui/sheet.d.ts +14 -0
- package/dist/ui/sheet.js +54 -0
- package/dist/ui/sidebar.d.ts +69 -0
- package/dist/ui/sidebar.js +206 -0
- package/dist/ui/skeleton.d.ts +2 -0
- package/dist/ui/skeleton.js +6 -0
- package/dist/ui/slider.d.ts +4 -0
- package/dist/ui/slider.js +9 -0
- package/dist/ui/sonner.d.ts +3 -0
- package/dist/ui/sonner.js +24 -0
- package/dist/ui/spinner.d.ts +2 -0
- package/dist/ui/spinner.js +7 -0
- package/dist/ui/switch.d.ts +6 -0
- package/dist/ui/switch.js +7 -0
- package/dist/ui/table.d.ts +10 -0
- package/dist/ui/table.js +31 -0
- package/dist/ui/tabs.d.ts +7 -0
- package/dist/ui/tabs.js +22 -0
- package/dist/ui/textarea.d.ts +3 -0
- package/dist/ui/textarea.js +6 -0
- package/dist/ui/toggle-group.d.ts +10 -0
- package/dist/ui/toggle-group.js +22 -0
- package/dist/ui/toggle.d.ts +9 -0
- package/dist/ui/toggle.js +25 -0
- package/dist/ui/tooltip.d.ts +7 -0
- package/dist/ui/tooltip.js +16 -0
- package/dist/view-edit-cell.d.ts +49 -0
- package/dist/view-edit-cell.js +198 -0
- package/dist/view-editor.d.ts +52 -0
- package/dist/view-editor.js +1081 -0
- package/dist/view-formulas.d.ts +61 -0
- package/dist/view-formulas.js +247 -0
- package/dist/view-icons.d.ts +20 -0
- package/dist/view-icons.js +70 -0
- package/dist/view-model.d.ts +390 -0
- package/dist/view-model.js +316 -0
- package/dist/whats-new.d.ts +3 -0
- package/dist/whats-new.js +53 -0
- package/dist/widget-controls.d.ts +9 -0
- package/dist/widget-controls.js +26 -0
- package/dist/widget-data.d.ts +4 -0
- package/dist/widget-data.js +32 -0
- package/dist/widget-settings.d.ts +516 -0
- package/dist/widget-settings.js +290 -0
- package/dist/widget-thumbs.d.ts +3 -0
- package/dist/widget-thumbs.js +29 -0
- package/dist/workspace-keys.d.ts +12 -0
- package/dist/workspace-keys.js +13 -0
- package/dist/workspaces.d.ts +57 -0
- package/dist/workspaces.js +473 -0
- package/package.json +82 -4
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { OUTLOOK_INDEXES, marketWindows, } from '@columenlabs/lumen-ui-types/market';
|
|
3
|
+
// LumenSavedWidget (as blocks; renamed): a page (LumenLayout) is an ordered list of cards, each a third, half, two thirds or the full width.
|
|
4
|
+
// A card is the view (LumenView) or a widget. Chart and top-N widgets read every row of the view
|
|
5
|
+
// (the table's filter is the table's), narrowed by their own tokens, and work their chart out here in the browser:
|
|
6
|
+
// nothing new is fetched; market and price chart widgets read their own data. LumenLayout lays the
|
|
7
|
+
// cards out; this file draws the card frame every card shares (its header, ↻ and ⋯ menu), each
|
|
8
|
+
// widget, the Edit layout bar, Add widget (Pre-built and Build your own) and Edit widget.
|
|
9
|
+
import { ALLOCATION_MAX, VIEW_CARD_KIND_NAMES, WIDGET_STYLES, controlOn, isDollarFormat, isRowCard, isTableCard, trendBuckets, viewCardKinds, viewCardSchema, viewWidgetCatalog, widgetStyleId, widgetStyleOf, } from '@columenlabs/lumen-ui-types/views';
|
|
10
|
+
import { ActivityIcon, ArrowDownIcon, ArrowUpIcon, BarChart3Icon, CandlestickChartIcon, ChartLineIcon, ChartPieIcon, CheckIcon, CodeIcon, CopyIcon, DatabaseIcon, EyeOffIcon, FoldVerticalIcon, FunnelIcon, GripVerticalIcon, InfoIcon, LayoutGridIcon, ListOrderedIcon, MoreHorizontalIcon, PackageIcon, PencilIcon, PlusIcon, RotateCcwIcon, Rows3Icon, SparklesIcon, SquareIcon, TableIcon, Trash2Icon, TrendingUpIcon, WrenchIcon, XIcon, } from 'lucide-react';
|
|
11
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
12
|
+
import { useAssistant, useAssistantName } from './assistant-context.js';
|
|
13
|
+
import { LumenConfirm } from './confirm.js';
|
|
14
|
+
import { FoldChevron, useFolded, usePageKey } from './fold.js';
|
|
15
|
+
import { GrowthChart } from './growth-chart.js';
|
|
16
|
+
import { useIsMobile } from './hooks/use-mobile.js';
|
|
17
|
+
import { IconButton } from './icon-button.js';
|
|
18
|
+
import { useDomainReads } from './joins.js';
|
|
19
|
+
import { cn } from './lib/utils.js';
|
|
20
|
+
import { LumenBoundary } from './lumen-boundary.js';
|
|
21
|
+
import { LumenTokenizedFilter, filterByTokens, useLumenFilter, } from './lumen-filter.js';
|
|
22
|
+
import { useGridCell } from './lumen-grid.js';
|
|
23
|
+
import { CardIdProvider, LinkDot, LinkPicker, linkVar, useLinks, } from './lumen-links.js';
|
|
24
|
+
import { LumenSelector } from './lumen-selector.js';
|
|
25
|
+
import { LumenFreshness, LumenLazy, LumenLoadFooter, LumenRefresh, LumenStates, } from './lumen-source.js';
|
|
26
|
+
import { TrendBody } from './lumen-trend.js';
|
|
27
|
+
import { AllocationBar, Donut, Headline, RankedBars, STACK_MAX, StackedBars, } from './lumen-widget-charts.js';
|
|
28
|
+
import { useHasMarket } from './market/live.js';
|
|
29
|
+
import { useBarsUrl, useLumenChart } from './market/lumen-chart.js';
|
|
30
|
+
import { MarketWidget } from './market/market-widget.js';
|
|
31
|
+
import { maskAmount, useAmountsHidden } from './shell.js';
|
|
32
|
+
import { SummaryCardsEditor, summaryCardIssues } from './summary-cards.js';
|
|
33
|
+
import { Badge } from './ui/badge.js';
|
|
34
|
+
import { Button } from './ui/button.js';
|
|
35
|
+
import { Card } from './ui/card.js';
|
|
36
|
+
import { ChartTooltip, ChartTooltipContent } from './ui/chart.js';
|
|
37
|
+
import { Checkbox } from './ui/checkbox.js';
|
|
38
|
+
import { Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, dialogBody, } from './ui/dialog.js';
|
|
39
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from './ui/dropdown-menu.js';
|
|
40
|
+
import { Input } from './ui/input.js';
|
|
41
|
+
import { Label } from './ui/label.js';
|
|
42
|
+
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover.js';
|
|
43
|
+
import { Skeleton } from './ui/skeleton.js';
|
|
44
|
+
import { Switch } from './ui/switch.js';
|
|
45
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table.js';
|
|
46
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs.js';
|
|
47
|
+
import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.js';
|
|
48
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip.js';
|
|
49
|
+
import { fieldOptions } from './view-formulas.js';
|
|
50
|
+
import { computedOf, label, summaryCash, } from './view-model.js';
|
|
51
|
+
import { WidgetControlsEditor } from './widget-controls.js';
|
|
52
|
+
import { PAGE_SOURCE, useWidgetDataOptions } from './widget-data.js';
|
|
53
|
+
import { dateFields } from './widget-settings.js';
|
|
54
|
+
/** Each style's icon, by its id in `WIDGET_STYLES`; a style left out takes its kind's. */
|
|
55
|
+
const STYLE_ICONS = {
|
|
56
|
+
'table:table': TableIcon,
|
|
57
|
+
'table:top_n': ListOrderedIcon,
|
|
58
|
+
'metric:number': SquareIcon,
|
|
59
|
+
'metric:row': LayoutGridIcon,
|
|
60
|
+
'metric:spark': ActivityIcon,
|
|
61
|
+
'metric:tracker': ActivityIcon,
|
|
62
|
+
'allocation:bar': Rows3Icon,
|
|
63
|
+
'allocation:donut': ChartPieIcon,
|
|
64
|
+
'allocation:bar_list': BarChart3Icon,
|
|
65
|
+
'chart:price': ChartLineIcon,
|
|
66
|
+
'chart:growth': TrendingUpIcon,
|
|
67
|
+
'chart:market': CandlestickChartIcon,
|
|
68
|
+
};
|
|
69
|
+
const KIND_ICONS = {
|
|
70
|
+
table: TableIcon,
|
|
71
|
+
metric: SquareIcon,
|
|
72
|
+
allocation: ChartPieIcon,
|
|
73
|
+
chart: ActivityIcon,
|
|
74
|
+
};
|
|
75
|
+
/** A card's icon, from its kind and style. */
|
|
76
|
+
export const cardIcon = (c) => STYLE_ICONS[widgetStyleId(c)] ?? KIND_ICONS[c.kind];
|
|
77
|
+
/** The widths a card can take, in columns of the desktop's 6. */
|
|
78
|
+
export const CARD_COLUMNS = [1, 2, 3, 4, 5, 6];
|
|
79
|
+
/** The longest name a widget takes. */
|
|
80
|
+
export const CARD_TITLE_MAX = 40;
|
|
81
|
+
export const cardTitle = (b, viewName) => b.title ??
|
|
82
|
+
(isTableCard(b) ? viewName : b.kind === 'page' ? b.id : (widgetStyleOf(b)?.name ?? b.kind));
|
|
83
|
+
/** The domain's fields as the filter box reads them, for a card's own tokens. */
|
|
84
|
+
export function cardFilterFields(fields) {
|
|
85
|
+
return fields.map((f) => ({
|
|
86
|
+
key: f.key,
|
|
87
|
+
label: f.label,
|
|
88
|
+
icon: f.icon,
|
|
89
|
+
values: f.values ??
|
|
90
|
+
((row) => {
|
|
91
|
+
const v = f.value(row);
|
|
92
|
+
return v === null ? [] : [String(v)];
|
|
93
|
+
}),
|
|
94
|
+
number: f.format === 'text'
|
|
95
|
+
? undefined
|
|
96
|
+
: (row) => {
|
|
97
|
+
const v = f.value(row);
|
|
98
|
+
return typeof v === 'number' ? v : null;
|
|
99
|
+
},
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
// ── Values ─────────────────────────────────────────────────────────────────────────────────────
|
|
103
|
+
const MINUS = '−';
|
|
104
|
+
const COMPACT = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 });
|
|
105
|
+
/** A chart value in short form: $12.3K, −4.1%, 1.2M; masked while Hide amounts is on. */
|
|
106
|
+
function short(v, format, hidden) {
|
|
107
|
+
if (hidden)
|
|
108
|
+
return maskAmount('0');
|
|
109
|
+
const sign = v < 0 ? MINUS : '';
|
|
110
|
+
const abs = Math.abs(v);
|
|
111
|
+
if (format === 'percent' || format === 'weight')
|
|
112
|
+
return `${sign}${abs.toFixed(1)}%`;
|
|
113
|
+
if (isDollarFormat(format))
|
|
114
|
+
return `${sign}$${COMPACT.format(abs)}`;
|
|
115
|
+
return `${sign}${COMPACT.format(abs)}`;
|
|
116
|
+
}
|
|
117
|
+
/** A chart value in full, for tooltips: $491,712.34, −4.1%; masked while Hide amounts is on. */
|
|
118
|
+
function exact(v, format, hidden) {
|
|
119
|
+
if (hidden)
|
|
120
|
+
return maskAmount('0');
|
|
121
|
+
const sign = v < 0 ? MINUS : '';
|
|
122
|
+
const abs = Math.abs(v);
|
|
123
|
+
if (format === 'percent' || format === 'weight')
|
|
124
|
+
return `${sign}${abs.toFixed(2)}%`;
|
|
125
|
+
const n = abs.toLocaleString('en-US', {
|
|
126
|
+
maximumFractionDigits: format === 'rounded' || format === 'price_rounded' ? 0 : 2,
|
|
127
|
+
useGrouping: format !== 'integer',
|
|
128
|
+
});
|
|
129
|
+
return `${sign}${isDollarFormat(format) ? '$' : ''}${n}`;
|
|
130
|
+
}
|
|
131
|
+
const pct = (v, total) => `${total ? ((v / total) * 100).toFixed(1) : '0.0'}%`;
|
|
132
|
+
/** One colour per name, by size, so the largest never share one; Cash is always grey. */
|
|
133
|
+
const PALETTE = [1, 2, 3, 4, 5, 6, 9, 7, 10].map((i) => `var(--chart-${i})`);
|
|
134
|
+
const colorOf = (name, i) => /cash/i.test(name) ? 'var(--chart-8)' : (PALETTE[i % PALETTE.length] ?? 'var(--chart-1)');
|
|
135
|
+
/** `value` summed by `by` over the rows, largest first; rows with no `by` read as —. */
|
|
136
|
+
function sumBy(rows, by, value, read) {
|
|
137
|
+
const m = new Map();
|
|
138
|
+
for (const r of rows) {
|
|
139
|
+
const v = read(value, r);
|
|
140
|
+
if (typeof v !== 'number' || !Number.isFinite(v))
|
|
141
|
+
continue;
|
|
142
|
+
const k = label(read(by, r));
|
|
143
|
+
m.set(k, (m.get(k) ?? 0) + v);
|
|
144
|
+
}
|
|
145
|
+
return [...m].map(([name, v]) => ({ name, v })).sort((a, b) => b.v - a.v);
|
|
146
|
+
}
|
|
147
|
+
// ── Widget bodies ──────────────────────────────────────────────────────────────────────────────
|
|
148
|
+
const Empty = ({ children = 'Nothing to show for these rows.' }) => (_jsx("p", { className: "py-6 text-center text-sm text-muted-foreground", children: children }));
|
|
149
|
+
/** A chart, top-N or market widget's body over `rows` (the view's shown rows). */
|
|
150
|
+
export function WidgetBody({ card, domain, rows, fields, merge, onDetails, }) {
|
|
151
|
+
const hidden = useAmountsHidden();
|
|
152
|
+
const own = useMemo(() => filterByTokens(rows, fields, card.filter), [rows, fields, card.filter]);
|
|
153
|
+
// Shares and formulas read against every shown row, not just the card's.
|
|
154
|
+
const computed = useMemo(() => computedOf(domain, rows, null), [domain, rows]);
|
|
155
|
+
const read = computed.value;
|
|
156
|
+
const field = (k) => domain.fields.find((f) => f.key === k);
|
|
157
|
+
if (card.kind === 'page')
|
|
158
|
+
return null;
|
|
159
|
+
if (card.kind === 'chart' && card.style === 'market')
|
|
160
|
+
return _jsx(MarketWidget, { symbols: card.symbols, style: card.layout });
|
|
161
|
+
if ('date' in card) {
|
|
162
|
+
const date = field(card.date);
|
|
163
|
+
const values = card.values.map(field).filter((f) => !!f);
|
|
164
|
+
if (!date || !values.length)
|
|
165
|
+
return _jsx(Empty, { children: "This widget's fields are not in this view." });
|
|
166
|
+
return (_jsx(TrendBody, { style: card.style, date: date, values: values, bucket: card.bucket, agg: card.agg, rows: own, read: read, legend: controlOn(card, 'legend') }));
|
|
167
|
+
}
|
|
168
|
+
if (card.kind === 'table' && card.style === 'top_n') {
|
|
169
|
+
const sort = field(card.sort);
|
|
170
|
+
const cols = card.columns.map(field).filter((f) => !!f);
|
|
171
|
+
if (!sort || !cols.length)
|
|
172
|
+
return _jsx(Empty, { children: "This widget's fields are not in this view." });
|
|
173
|
+
const top = merge(own)
|
|
174
|
+
.map((r) => ({ r, v: read(sort, r) }))
|
|
175
|
+
.filter((x) => x.v !== null)
|
|
176
|
+
.sort((a, b) => {
|
|
177
|
+
const d = typeof a.v === 'number' && typeof b.v === 'number'
|
|
178
|
+
? a.v - b.v
|
|
179
|
+
: String(a.v).localeCompare(String(b.v));
|
|
180
|
+
return card.desc ? -d : d;
|
|
181
|
+
})
|
|
182
|
+
.slice(0, card.n);
|
|
183
|
+
if (!top.length)
|
|
184
|
+
return _jsx(Empty, {});
|
|
185
|
+
return (_jsxs(Table, { children: [_jsx(TableHeader, { children: _jsx(TableRow, { children: cols.map((f) => (_jsx(TableHead, { className: f.format === 'text' ? '' : 'text-right', children: f.label }, f.key))) }) }), _jsx(TableBody, { children: top.map(({ r }) => (_jsx(TableRow, { className: onDetails ? 'cursor-pointer' : undefined, onClick: onDetails && (() => onDetails(r)), children: cols.map((f) => (_jsx(TableCell, { className: f.format === 'text' ? '' : 'text-right', children: _jsx(WidgetValue, { value: read(f, r), field: f }) }, f.key))) }, domain.rowKey(r)))) })] }));
|
|
186
|
+
}
|
|
187
|
+
if (card.kind !== 'allocation')
|
|
188
|
+
return null;
|
|
189
|
+
const by = field(card.by);
|
|
190
|
+
const value = field(card.value);
|
|
191
|
+
if (!by || !value)
|
|
192
|
+
return _jsx(Empty, { children: "This widget's fields are not in this view." });
|
|
193
|
+
const masked = hidden && !!value.amount;
|
|
194
|
+
const fmt = (v) => short(v, value.format, masked);
|
|
195
|
+
const sums = sumBy(own, by, value, read);
|
|
196
|
+
// An empty value reads "No sector", not "—"; counts say "1 sector", "3 sectors".
|
|
197
|
+
const shown = (name, f) => name === label(null) ? `No ${f.label.toLowerCase()}` : name;
|
|
198
|
+
const count = (n) => `${n} ${by.label.toLowerCase()}${n === 1 ? '' : 's'}`;
|
|
199
|
+
if (card.style === 'bar_list') {
|
|
200
|
+
const bars = sums
|
|
201
|
+
.filter((x) => x.v !== 0)
|
|
202
|
+
.sort((a, b) => (card.desc ? b.v - a.v : a.v - b.v))
|
|
203
|
+
.slice(0, card.n);
|
|
204
|
+
if (!bars.length)
|
|
205
|
+
return _jsx(Empty, {});
|
|
206
|
+
const net = bars.reduce((t, x) => t + x.v, 0);
|
|
207
|
+
const abs = bars.reduce((t, x) => t + Math.abs(x.v), 0);
|
|
208
|
+
const up = bars.filter((b) => b.v > 0).length;
|
|
209
|
+
// Split by: each bar's parts by a text field, its 5 largest overall coloured as a donut of
|
|
210
|
+
// that field colours them, the rest Other.
|
|
211
|
+
const stackF = !card.signed && card.stack ? field(card.stack) : undefined;
|
|
212
|
+
const stack = (() => {
|
|
213
|
+
if (!stackF || stackF.key === by.key)
|
|
214
|
+
return null;
|
|
215
|
+
const order = sumBy(own, stackF, value, read).filter((x) => x.v > 0);
|
|
216
|
+
// A split needs two known parts; with fewer (no sectors set yet) plain bars read better.
|
|
217
|
+
if (order.filter((x) => x.name !== label(null)).length < 2)
|
|
218
|
+
return null;
|
|
219
|
+
const top = order.slice(0, STACK_MAX).map((x) => x.name);
|
|
220
|
+
const parts = [...top, ...(order.length > STACK_MAX ? ['Other'] : [])].map((name, i) => ({
|
|
221
|
+
name: shown(name, stackF),
|
|
222
|
+
key: `p${i}`,
|
|
223
|
+
fill: name === 'Other' ? 'var(--muted-foreground)' : colorOf(name, i),
|
|
224
|
+
}));
|
|
225
|
+
const keyOf = (n) => `p${top.includes(n) ? top.indexOf(n) : top.length}`;
|
|
226
|
+
const rows = bars.map((b) => {
|
|
227
|
+
const r = { name: shown(b.name, by), total: b.v };
|
|
228
|
+
for (const x of own) {
|
|
229
|
+
if (label(read(by, x)) !== b.name)
|
|
230
|
+
continue;
|
|
231
|
+
const v = read(value, x);
|
|
232
|
+
if (typeof v !== 'number' || v <= 0)
|
|
233
|
+
continue;
|
|
234
|
+
const k = keyOf(label(read(stackF, x)));
|
|
235
|
+
r[k] = (r[k] ?? 0) + v;
|
|
236
|
+
}
|
|
237
|
+
return r;
|
|
238
|
+
});
|
|
239
|
+
return { rows, parts };
|
|
240
|
+
})();
|
|
241
|
+
return (_jsxs(_Fragment, { children: [_jsx(Headline, { value: `${card.signed && net > 0 ? '+' : ''}${fmt(net)}`, tone: card.signed ? (net >= 0 ? 'gain' : 'loss') : undefined, caption: card.signed
|
|
242
|
+
? `net · ${up} up, ${bars.length - up} down`
|
|
243
|
+
: `${value.label.toLowerCase()} across ${count(bars.length)}` }), stack ? (_jsx(StackedBars, { rows: stack.rows, parts: stack.parts, fmt: fmt, exact: (v) => exact(v, value.format, masked), share: (v) => pct(v, abs) })) : (_jsx(RankedBars, { id: card.id, items: bars.map((b, i) => ({
|
|
244
|
+
...b,
|
|
245
|
+
name: shown(b.name, by),
|
|
246
|
+
fill: card.signed ? (b.v >= 0 ? 'var(--gain)' : 'var(--loss)') : colorOf(b.name, i),
|
|
247
|
+
})), signed: card.signed, fmt: fmt, share: (v) => pct(Math.abs(v), abs) }))] }));
|
|
248
|
+
}
|
|
249
|
+
const parts = sums.filter((x) => x.v > 0).map((x) => ({ ...x, name: shown(x.name, by) }));
|
|
250
|
+
const total = parts.reduce((t, x) => t + x.v, 0);
|
|
251
|
+
const known = parts.filter((x) => x.name !== shown(label(null), by)).length;
|
|
252
|
+
if (!total)
|
|
253
|
+
return _jsx(Empty, {});
|
|
254
|
+
// Donut: `max` slices in all, Other the last. Allocation: the largest `max`, then Other.
|
|
255
|
+
const keep = card.style === 'donut' ? card.max - 1 : (card.max ?? ALLOCATION_MAX);
|
|
256
|
+
const limit = card.style === 'donut' ? card.max : keep;
|
|
257
|
+
const rest = parts.length > limit ? parts.slice(keep) : [];
|
|
258
|
+
const slices = rest.length
|
|
259
|
+
? [...parts.slice(0, keep), { name: 'Other', v: rest.reduce((t, x) => t + x.v, 0) }]
|
|
260
|
+
: parts;
|
|
261
|
+
const colored = slices.map((s, i) => ({
|
|
262
|
+
...s,
|
|
263
|
+
key: `s${i}`,
|
|
264
|
+
fill: s.name === 'Other' ? 'var(--muted-foreground)' : colorOf(s.name, i),
|
|
265
|
+
}));
|
|
266
|
+
if (card.style === 'donut')
|
|
267
|
+
return (_jsx(Donut, { items: colored, total: total, fmt: fmt, share: (v) => pct(v, total), caption: count(known), legend: controlOn(card, 'legend') }));
|
|
268
|
+
// Allocation: one stacked bar, no legend; each wide slice names itself, and hovering a slice
|
|
269
|
+
// gives its exact value and share, Other its 10 largest. The tooltip may leave the card.
|
|
270
|
+
const config = Object.fromEntries(colored.map((s) => [s.key, { label: s.name, color: s.fill }]));
|
|
271
|
+
const line = (name, v, fill) => (_jsxs("span", { className: "flex w-full items-center gap-2", children: [fill && _jsx("span", { className: "size-2 shrink-0 rounded-full", style: { background: fill } }), _jsx("span", { className: "truncate font-medium", children: name }), _jsxs("span", { className: "num ml-auto pl-3 whitespace-nowrap", children: [exact(v, value.format, masked), " \u00B7 ", pct(v, total)] })] }, name));
|
|
272
|
+
return (_jsxs("div", { className: "py-1", children: [_jsx(Headline, { value: fmt(total), caption: `${count(known)} · largest ${colored[0]?.name ?? ''} ${pct(colored[0]?.v ?? 0, total)}` }), _jsx(AllocationBar, { items: colored, total: total, config: config, share: (v) => pct(v, total), tooltip: _jsx(ChartTooltip, { cursor: false, shared: false, allowEscapeViewBox: { x: false, y: true }, wrapperStyle: { zIndex: 50 }, content: _jsx(ChartTooltipContent, { hideLabel: true, className: "min-w-48 max-w-72", formatter: (v, name) => {
|
|
273
|
+
const s = colored.find((c) => c.key === name);
|
|
274
|
+
if (s?.name !== 'Other')
|
|
275
|
+
return line(s?.name ?? String(name), Number(v), s?.fill);
|
|
276
|
+
return (_jsxs("span", { className: "flex w-full flex-col gap-1", children: [line(`Other (${rest.length})`, Number(v), s.fill), _jsxs("span", { className: "flex flex-col gap-0.5 border-t pt-1 text-muted-foreground", children: [rest.slice(0, 10).map((x) => line(x.name, x.v)), rest.length > 10 && _jsxs("span", { children: ["+", rest.length - 10, " more"] })] })] }));
|
|
277
|
+
} }) }) })] }));
|
|
278
|
+
}
|
|
279
|
+
/** A top-N cell as its field reads, short where the field is short. */
|
|
280
|
+
function WidgetValue({ value, field }) {
|
|
281
|
+
const hidden = useAmountsHidden() && !!field.amount;
|
|
282
|
+
if (value === null)
|
|
283
|
+
return _jsx("span", { className: "num text-muted-foreground", children: "\u2014" });
|
|
284
|
+
if (typeof value === 'string')
|
|
285
|
+
return _jsx("span", { className: "whitespace-nowrap", children: hidden ? maskAmount(value) : value });
|
|
286
|
+
const signed = field.format === 'signed' || field.format === 'percent' || field.format === 'price_signed';
|
|
287
|
+
const text = field.format === 'integer'
|
|
288
|
+
? `${value < 0 ? MINUS : ''}${Math.abs(value).toFixed(0)}`
|
|
289
|
+
: field.format === 'percent' || field.format === 'weight'
|
|
290
|
+
? `${value < 0 ? MINUS : signed && value > 0 ? '+' : ''}${Math.abs(value).toFixed(1)}%`
|
|
291
|
+
: `${value < 0 ? MINUS : signed && value > 0 ? '+' : ''}${isDollarFormat(field.format) ? '$' : ''}${field.compact || field.format === 'compact'
|
|
292
|
+
? COMPACT.format(Math.abs(value))
|
|
293
|
+
: Math.abs(value).toLocaleString('en-US', {
|
|
294
|
+
maximumFractionDigits: field.format === 'rounded' || field.format === 'price_rounded' ? 0 : 2,
|
|
295
|
+
})}`;
|
|
296
|
+
return (_jsx("span", { className: cn('num whitespace-nowrap', signed && (value > 0 ? 'text-gain' : value < 0 ? 'text-loss' : '')), children: hidden ? maskAmount('0') : text }));
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* The frame every card shares, widget or view: drag handle while
|
|
300
|
+
* the layout is edited, icon, title, a short note, the card's own tools (a view's pager), its ↻ and
|
|
301
|
+
* load time when it has a source, the width switch while editing, and its ⋯ menu. The body sits in
|
|
302
|
+
* a `relative` box so a view's progress bar runs along its top.
|
|
303
|
+
*/
|
|
304
|
+
/** Wider than this, a card's filter sits in its header line; narrower, on a line of its own. */
|
|
305
|
+
const FILTER_INLINE_PX = 720;
|
|
306
|
+
/** The header's width, so its filter knows whether it fits on the title's line. */
|
|
307
|
+
function useWidth() {
|
|
308
|
+
const ref = useRef(null);
|
|
309
|
+
const [width, setWidth] = useState(0);
|
|
310
|
+
useEffect(() => {
|
|
311
|
+
const el = ref.current;
|
|
312
|
+
if (!el)
|
|
313
|
+
return;
|
|
314
|
+
const ro = new ResizeObserver(([e]) => setWidth(e?.contentRect.width ?? 0));
|
|
315
|
+
ro.observe(el);
|
|
316
|
+
return () => ro.disconnect();
|
|
317
|
+
}, []);
|
|
318
|
+
return [ref, width];
|
|
319
|
+
}
|
|
320
|
+
export function LumenWidget({ id, kind, icon, title, note, controls, tools, filter, filterCount = 0, load, rows, loadNote, pad = true, pinned = false, compact = false, className, children, link: given, help, assistant, strip, shown, style, }) {
|
|
321
|
+
const [removing, setRemoving] = useState(false);
|
|
322
|
+
const cell = useGridCell();
|
|
323
|
+
const links = useLinks();
|
|
324
|
+
const link = given === false
|
|
325
|
+
? undefined
|
|
326
|
+
: (given ??
|
|
327
|
+
(links ? { color: links.colorOf(id), onPick: (c) => links.setColor(id, c) } : undefined));
|
|
328
|
+
const Icon = icon ?? (kind ? cardIcon({ kind, style }) : LayoutGridIcon);
|
|
329
|
+
const layout = !!controls?.layout;
|
|
330
|
+
// A small card (one number) shows only its title until the page is in Edit layout.
|
|
331
|
+
const bare = compact && !layout;
|
|
332
|
+
const on = (k) => kind === null || controlOn({ kind, style, controls: shown }, k);
|
|
333
|
+
// A page's own card (kind null) says it is a table when it draws one (`style` table).
|
|
334
|
+
const styleId = kind
|
|
335
|
+
? widgetStyleId({ kind, style })
|
|
336
|
+
: style === 'table'
|
|
337
|
+
? 'table:table'
|
|
338
|
+
: 'page';
|
|
339
|
+
const table = kind === 'table' && style === 'table';
|
|
340
|
+
const ask = useAskAssistant(id, title, on('ask') ? assistant : false, styleId);
|
|
341
|
+
const assistantName = useAssistantName();
|
|
342
|
+
// A card folds to its header on its chevron only (a click elsewhere on the
|
|
343
|
+
// header does nothing); the browser remembers it per page. Never while arranging cards.
|
|
344
|
+
const [stored, setFolded] = useFolded(`card:${usePageKey()}:${id}`);
|
|
345
|
+
// Header off (Edit widget ▸ Controls): the card is its body alone, a clean
|
|
346
|
+
// card; Edit layout shows every header, so its link colour and menu stay in reach there.
|
|
347
|
+
const headerOff = !compact && !on('header');
|
|
348
|
+
const headless = headerOff && !layout;
|
|
349
|
+
const folded = stored && !layout && !bare && !headless;
|
|
350
|
+
const toggle = () => setFolded(!folded);
|
|
351
|
+
const own = kind !== null;
|
|
352
|
+
const mobile = useIsMobile();
|
|
353
|
+
const [head, headWidth] = useWidth();
|
|
354
|
+
const [filterOpen, setFilterOpen] = useState(false);
|
|
355
|
+
const showFilter = !!filter && !layout && !folded && on('filter');
|
|
356
|
+
const inline = showFilter && !mobile && headWidth >= FILTER_INLINE_PX;
|
|
357
|
+
const below = showFilter && !inline && (!mobile || filterOpen);
|
|
358
|
+
// A row of numbers with no header drops its own frame: its cards sit on the page.
|
|
359
|
+
const flat = headless && styleId === 'metric:row';
|
|
360
|
+
return (_jsx(CardIdProvider, { id: id, children: _jsxs(Card, { "data-slot": "lumen-card", "data-card": id, "data-card-title": title, "data-card-kind": styleId, className: cn(
|
|
361
|
+
// A border, not the card's ring: a ring draws outside the box, so the page's scroller
|
|
362
|
+
// clipped it wherever a card touched its edge (the top row's top line).
|
|
363
|
+
'h-full gap-0 border border-foreground/10 py-0 ring-0', folded && 'h-auto',
|
|
364
|
+
// The allocation bar's tooltip reaches past its short card, over the cards below.
|
|
365
|
+
styleId === 'allocation:bar' ? 'relative overflow-visible hover:z-20' : 'overflow-clip',
|
|
366
|
+
// The edit outline sits inside the card, over its pinned rows: an outer one is clipped by
|
|
367
|
+
// the page's scroller, and an inset outline paints under the sticky header and group rows.
|
|
368
|
+
layout &&
|
|
369
|
+
"relative after:pointer-events-none after:absolute after:inset-0 after:z-30 after:rounded-[inherit] after:border-2 after:border-primary/40 after:border-dashed after:content-['']", flat && 'border-0 bg-transparent shadow-none', className), children: [!headless && (_jsxs("div", { ref: head, className: cn('flex shrink-0 items-center gap-2 px-3', compact ? 'h-8' : 'h-10 border-b', (folded || below) && 'border-b-0', pinned && 'sticky left-0 w-(--view-w) max-w-full'), children: [layout && (_jsx(Button, { variant: "ghost", size: "icon-xs", className: "lumen-drag -ml-1 cursor-grab touch-none text-muted-foreground active:cursor-grabbing", "aria-label": `Drag ${title}`, children: _jsx(GripVerticalIcon, {}) })), link && !bare && (_jsx(LinkPicker, { color: link.color, onPick: link.onPick, label: title, className: "-mr-1" })), !compact && _jsx(Icon, { className: "size-4 shrink-0 text-muted-foreground" }), _jsx("span", { className: cn('truncate', compact ? 'text-xs text-muted-foreground' : 'text-sm font-semibold'), children: title }), link?.color && link.follows && !folded && (_jsxs("span", { className: "min-w-0 shrink truncate text-xs", style: { color: linkVar(link.color) }, title: `Follows ${link.follows}`, children: ["\u00B7 ", link.follows] })), layout && headerOff && (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsxs(Badge, { variant: "secondary", className: "shrink-0 gap-1 text-muted-foreground", children: [_jsx(EyeOffIcon, {}), " Header off"] }) }), _jsx(TooltipContent, { children: "Shown only in Edit layout. Change it in Edit widget \u25B8 Controls." })] })), !layout && !bare && (_jsx(FoldChevron, { folded: folded, label: title, onToggle: toggle, className: "-ml-1" })), folded && rows != null && (_jsxs("span", { className: "shrink-0 text-xs text-muted-foreground num", children: [rows.toLocaleString(), " ", rows === 1 ? 'row' : 'rows'] })), inline && (_jsx("div", { "data-card-filter": true, className: "flex min-w-40 max-w-xl flex-1 items-center gap-2", children: filter })), note && !layout && !folded && (_jsx("span", { className: "hidden truncate text-xs text-muted-foreground sm:inline", children: note })), _jsxs("span", { className: cn('ml-auto flex shrink-0 items-center gap-1', bare && 'hidden'), children: [!layout && load?.updated && _jsx(LumenFreshness, { load: load }), !layout && !folded && tools, showFilter && mobile && link?.color && !!link.linked && (_jsxs("span", { className: "num flex items-center gap-1 text-xs text-muted-foreground", title: `${link.linked} linked`, children: [_jsx(LinkDot, { color: link.color, className: "size-2" }), link.linked] })), showFilter && mobile && (_jsxs("span", { className: "relative", children: [_jsx(IconButton, { icon: FunnelIcon, label: filterOpen ? 'Hide filter' : 'Filter', "aria-expanded": filterOpen, className: cn(filterOpen && 'text-primary'), onClick: () => setFilterOpen(!filterOpen) }), filterCount > 0 && (_jsx(Badge, { className: "num pointer-events-none absolute -top-1 -right-1 h-4 min-w-4 px-1 text-[10px]", children: filterCount }))] })), !layout && !folded && help && _jsx(CardHelp, { title: title, children: help }), !layout && load && on('refresh') && (_jsx(LumenRefresh, { load: load, label: `Refresh ${title}` })), (controls?.editable || (!layout && (ask || load?.editScript))) && (_jsxs(DropdownMenu, { modal: false, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(IconButton, { icon: MoreHorizontalIcon, label: `${title} menu` }) }), _jsxs(DropdownMenuContent, { align: "end", className: "w-44", children: [ask && !layout && (_jsxs(DropdownMenuItem, { onSelect: ask, children: [_jsx(SparklesIcon, {}), " Ask ", assistantName] })), load?.editScript && !layout && (_jsxs(DropdownMenuItem, { onSelect: load.editScript, children: [_jsx(CodeIcon, {}), " Script"] })), (ask || load?.editScript) && !layout && controls?.editable && (_jsx(DropdownMenuSeparator, {})), controls?.editable && (_jsxs(_Fragment, { children: [!controls.fixed && (_jsxs(_Fragment, { children: [_jsxs(DropdownMenuItem, { onSelect: () => controls.onAction('edit'), children: [_jsx(PencilIcon, {}), " Edit widget"] }), _jsxs(DropdownMenuItem, { disabled: !own, onSelect: () => controls.onAction('duplicate'), children: [_jsx(CopyIcon, {}), " Duplicate"] })] })), _jsxs(DropdownMenuItem, { disabled: controls.first, onSelect: () => controls.onAction('up'), children: [_jsx(ArrowUpIcon, {}), " Move up"] }), _jsxs(DropdownMenuItem, { disabled: controls.last, onSelect: () => controls.onAction('down'), children: [_jsx(ArrowDownIcon, {}), " Move down"] }), controls.onFit && (_jsxs(DropdownMenuItem, { onSelect: controls.onFit, children: [_jsx(FoldVerticalIcon, {}), " Fit height"] })), !controls.fixed && (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { className: "text-muted-foreground focus:text-destructive", onSelect: () => setRemoving(true), children: [_jsx(Trash2Icon, {}), " Remove"] })] }))] }))] })] }))] })] })), below && !headless && (_jsx("div", { "data-card-filter": true, className: cn('flex items-center gap-2 border-b px-3 pb-2', pinned && 'sticky left-0 w-(--view-w) max-w-full'), children: filter })), strip && !layout && !folded && !headless ? (_jsx("div", { "data-card-strip": true, className: cn('flex min-w-0 items-center gap-2 border-b px-3 py-1.5 empty:hidden', pinned && 'sticky left-0 w-(--view-w) max-w-full'), children: strip })) : null, _jsx("div", { hidden: folded, className: cn('relative min-w-0',
|
|
370
|
+
// On the grid a card fills its cell: with a height of its own its body scrolls; one as
|
|
371
|
+
// tall as it draws keeps its body's height, so the grid can measure it.
|
|
372
|
+
!cell ? 'flex-1' : cell.fixed && 'min-h-0 flex-1 overflow-auto', pad && !table && !flat && (compact ? 'px-3 pb-3' : 'p-3')), children: _jsx(LumenBoundary, { where: id, children: children }) }), !layout && !folded && load && on('footer') && (_jsx("div", { className: cn('mt-auto shrink-0', pinned && 'sticky left-0 w-(--view-w) max-w-full'), children: _jsx(LumenLoadFooter, { load: load, rows: rows, note: loadNote }) })), controls && (_jsx(LumenConfirm, { open: removing, onOpenChange: setRemoving, title: `Remove ${title}?`, description: table ? 'Add widget brings a table back.' : 'Add widget brings it back.', action: "Remove", onConfirm: () => controls.onAction('remove') }))] }) }));
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* One widget in its card. Widgets over the view's rows (tiles,
|
|
376
|
+
* charts, top N) load with the view: its states, its ↻. Market and price chart widgets read their own
|
|
377
|
+
* data and start once their card scrolls into view.
|
|
378
|
+
*/
|
|
379
|
+
export function LumenSavedWidget({ widget, title, domain, rows, fields, merge, onDetails, load, tiles, controls, link, }) {
|
|
380
|
+
const row = isRowCard(widget);
|
|
381
|
+
const number = widget.kind === 'metric' && 'tile' in widget;
|
|
382
|
+
const head = row ? domain.tilesHead?.(rows) : undefined;
|
|
383
|
+
const compact = number;
|
|
384
|
+
const frame = {
|
|
385
|
+
id: widget.id,
|
|
386
|
+
kind: widget.kind,
|
|
387
|
+
title: head?.title ?? title,
|
|
388
|
+
compact,
|
|
389
|
+
controls,
|
|
390
|
+
link,
|
|
391
|
+
shown: widget.controls,
|
|
392
|
+
style: widget.style,
|
|
393
|
+
note: head?.note ??
|
|
394
|
+
(widget.filter.length
|
|
395
|
+
? widget.filter.map((t) => `${t.not ? 'not ' : ''}${t.value}`).join(', ')
|
|
396
|
+
: undefined),
|
|
397
|
+
tools: head?.tools,
|
|
398
|
+
};
|
|
399
|
+
if (widget.kind === 'chart' && widget.style === 'price')
|
|
400
|
+
return _jsx(ChartCard, { widget: widget, frame: frame });
|
|
401
|
+
if (widget.kind === 'chart' && widget.style === 'growth')
|
|
402
|
+
return _jsx(GrowthCard, { widget: widget, frame: frame, domain: domain, rows: rows, load: load });
|
|
403
|
+
if (widget.kind === 'chart' && widget.style === 'market')
|
|
404
|
+
return (_jsx(LumenWidget, { ...frame, children: _jsx(LumenLazy, { children: _jsx(MarketWidget, { symbols: widget.symbols, style: widget.layout }) }) }));
|
|
405
|
+
const body = row || number ? (tiles) : (_jsx(WidgetBody, { card: widget, domain: domain, rows: rows, fields: fields, merge: merge, onDetails: onDetails }));
|
|
406
|
+
return (_jsx(LumenWidget, { ...frame, pad: widgetStyleId(widget) !== 'table:top_n', load: load, loadNote: "from the view's rows", children: load ? (_jsx(LumenStates, { load: { ...load, error: null }, empty: load.error ? _jsx(Empty, { children: "Shows once the view's rows load." }) : undefined, kind: "widget", skeleton: row || number ? body : _jsx(Skeleton, { className: "h-40 w-full" }), children: body })) : (body) }));
|
|
407
|
+
}
|
|
408
|
+
/** Growth of $1: the domain's lines for the shown rows, its bands and markers. */
|
|
409
|
+
function GrowthCard({ widget, frame, domain, rows, load, }) {
|
|
410
|
+
const g = domain.growth;
|
|
411
|
+
const lines = useMemo(() => g?.lines(rows) ?? [], [g, rows]);
|
|
412
|
+
const bands = useMemo(() => g?.bands?.(rows) ?? [], [g, rows]);
|
|
413
|
+
const loading = !!load?.loading || !!g?.loading;
|
|
414
|
+
return (_jsx(LumenWidget, { ...frame, tools: g?.tools, note: g?.note ?? frame.note, load: load, rows: lines.length, children: !g ? (_jsx(Empty, { children: "This view has no curves to draw." })) : loading && !lines.length ? (_jsx(Skeleton, { className: "h-64 w-full" })) : lines.length ? (_jsx(GrowthChart, { lines: lines, scale: widget.scale, bands: bands, markers: g.markers, ytd: false, className: "aspect-auto h-64 w-full md:h-80" })) : (_jsx(Empty, { children: "No lines yet: run a row to draw it." })) }));
|
|
415
|
+
}
|
|
416
|
+
/** The price chart widget: LumenChart in a card, with its own source, ↻ and load time. A change to
|
|
417
|
+
* its symbol, window or type starts the chart over. */
|
|
418
|
+
function ChartCard(props) {
|
|
419
|
+
const { symbol, window, type } = props.widget;
|
|
420
|
+
return _jsx(ChartCardBody, { ...props }, `${symbol}|${window}|${type}`);
|
|
421
|
+
}
|
|
422
|
+
function ChartCardBody({ widget, frame, }) {
|
|
423
|
+
const chart = useLumenChart({
|
|
424
|
+
symbols: [widget.symbol],
|
|
425
|
+
window: widget.window,
|
|
426
|
+
type: widget.type,
|
|
427
|
+
indicators: [],
|
|
428
|
+
lazy: true,
|
|
429
|
+
windows: controlOn(widget, 'window'),
|
|
430
|
+
});
|
|
431
|
+
return (_jsx("div", { ref: chart.ref, className: "h-full", children: _jsx(LumenWidget, { ...frame, load: chart.noDesk ? undefined : chart.load, rows: chart.rows, children: _jsx("div", { className: "flex flex-col gap-3", children: chart.element }) }) }));
|
|
432
|
+
}
|
|
433
|
+
/** The page header's layout slot while a page's layout is edited: Add widget, Cancel and Save,
|
|
434
|
+
* icons only (in the header, not a bar of their own: Cancel
|
|
435
|
+
* drops the changes and leaves, in place of Reset). */
|
|
436
|
+
export function LayoutActions({ onCancel, onAdd, onSave, onReset, }) {
|
|
437
|
+
return (_jsxs(_Fragment, { children: [onReset && (_jsx(LumenConfirm, { title: "Reset this page to its default?", description: "Your changes to its widgets, columns and filters go, and the built-in page comes back.", action: "Reset to default", destructive: false, onConfirm: onReset, children: _jsx(IconButton, { icon: RotateCcwIcon, label: "Reset to default" }) })), _jsx(IconButton, { icon: PlusIcon, label: "Add widget", onClick: onAdd }), _jsx(IconButton, { icon: XIcon, label: "Cancel layout changes", onClick: onCancel }), _jsx(IconButton, { icon: CheckIcon, label: "Save layout", variant: "default", onClick: onSave })] }));
|
|
438
|
+
}
|
|
439
|
+
/** The chip beside the title while a page's layout is edited. */
|
|
440
|
+
export function LayoutChip() {
|
|
441
|
+
return (_jsxs(Badge, { variant: "secondary", className: "font-normal", children: [_jsx(PencilIcon, {}), " Editing layout"] }));
|
|
442
|
+
}
|
|
443
|
+
// ── Add and edit ───────────────────────────────────────────────────────────────────────────────
|
|
444
|
+
/** An id not yet used in `cards`: `base`, else `base_2`, `base_3`... */
|
|
445
|
+
export function freeId(cards, base) {
|
|
446
|
+
const taken = new Set(cards.map((b) => b.id));
|
|
447
|
+
if (!taken.has(base))
|
|
448
|
+
return base;
|
|
449
|
+
let n = 2;
|
|
450
|
+
while (taken.has(`${base}_${n}`))
|
|
451
|
+
n++;
|
|
452
|
+
return `${base}_${n}`;
|
|
453
|
+
}
|
|
454
|
+
const isText = (f) => f.format === 'text' && !f.render;
|
|
455
|
+
const isNumber = (f) => f.format !== 'text' && !f.render;
|
|
456
|
+
/** A new card of a style (`kind:style`) with sensible fields of this domain. */
|
|
457
|
+
export function draftOf(styleId, domain, cards) {
|
|
458
|
+
const [kind = 'table', style = 'table'] = styleId.split(':');
|
|
459
|
+
const texts = domain.fields.filter(isText);
|
|
460
|
+
const nums = domain.fields.filter(isNumber);
|
|
461
|
+
const by = (texts.find((f) => f.groupable) ?? texts[0])?.key ?? '';
|
|
462
|
+
const value = (nums.find((f) => f.key === 'market_value' || f.key === 'value') ??
|
|
463
|
+
nums.find((f) => f.amount) ??
|
|
464
|
+
nums[0])?.key ?? '';
|
|
465
|
+
const base = { id: freeId(cards, style), title: null, filter: [], kind, style };
|
|
466
|
+
const trend = { date: dateFields(domain)[0]?.key ?? '', values: [value] };
|
|
467
|
+
switch (styleId) {
|
|
468
|
+
case 'table:top_n':
|
|
469
|
+
return viewCardSchema.parse({
|
|
470
|
+
...base,
|
|
471
|
+
sort: value,
|
|
472
|
+
columns: [texts[0]?.key, value].filter(Boolean),
|
|
473
|
+
w: 3,
|
|
474
|
+
});
|
|
475
|
+
case 'table:table':
|
|
476
|
+
case 'metric:row':
|
|
477
|
+
return viewCardSchema.parse(base);
|
|
478
|
+
case 'chart:price':
|
|
479
|
+
return viewCardSchema.parse({ ...base, symbol: 'SPX', w: 3 });
|
|
480
|
+
case 'chart:growth':
|
|
481
|
+
return viewCardSchema.parse({ ...base, w: 6 });
|
|
482
|
+
case 'chart:market':
|
|
483
|
+
return viewCardSchema.parse({ ...base, symbols: [...OUTLOOK_INDEXES], layout: 'lines' });
|
|
484
|
+
case 'metric:number':
|
|
485
|
+
case 'metric:progress_bar':
|
|
486
|
+
case 'metric:progress_circle': {
|
|
487
|
+
const f = nums.find((x) => x.key === value);
|
|
488
|
+
return viewCardSchema.parse({
|
|
489
|
+
...base,
|
|
490
|
+
tile: { key: 'tile_1', label: f?.label ?? 'Card', field: value, agg: 'sum' },
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
case 'allocation:bar':
|
|
494
|
+
case 'allocation:donut':
|
|
495
|
+
case 'allocation:bar_list':
|
|
496
|
+
return viewCardSchema.parse({ ...base, by, value, w: style === 'bar' ? 6 : 2 });
|
|
497
|
+
default:
|
|
498
|
+
return viewCardSchema.parse({ ...base, ...trend });
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/** Whether the app can draw a style: Market needs its live feed, Price its history route, Growth
|
|
502
|
+
* of $1 a domain that draws curves. */
|
|
503
|
+
export function useOffered(domain) {
|
|
504
|
+
const market = useHasMarket();
|
|
505
|
+
const charts = !!useBarsUrl();
|
|
506
|
+
return (styleId) => styleId === 'chart:market'
|
|
507
|
+
? market
|
|
508
|
+
: styleId === 'chart:price'
|
|
509
|
+
? charts
|
|
510
|
+
: styleId === 'chart:growth'
|
|
511
|
+
? !!domain.growth
|
|
512
|
+
: true;
|
|
513
|
+
}
|
|
514
|
+
function PickCard({ icon: Icon, name, note, added, disabled, onClick, }) {
|
|
515
|
+
return (_jsxs(Button, { variant: "outline", disabled: disabled, onClick: onClick, className: "h-auto justify-start gap-3 p-3 text-left font-normal whitespace-normal", children: [_jsx("span", { className: "grid size-8 shrink-0 place-items-center rounded-md bg-primary/10 text-primary", children: _jsx(Icon, { className: "size-4" }) }), _jsxs("span", { className: "flex min-w-0 flex-col gap-0.5", children: [_jsx("span", { className: "font-medium", children: name }), _jsx("span", { className: "text-xs text-muted-foreground", children: note })] }), added && (_jsxs(Badge, { variant: "secondary", className: "ml-auto font-normal", children: [_jsx(CheckIcon, {}), " Added"] }))] }));
|
|
516
|
+
}
|
|
517
|
+
/** Add widget: the domain's pre-built widgets, or a kind to build. */
|
|
518
|
+
export function AddWidgetDialog({ open, onOpenChange, domain, cards, onAdd, onBuild, name = 'This page', }) {
|
|
519
|
+
// Step 1: the widget's main data, this page's rows unless another is picked.
|
|
520
|
+
const [data, setData] = useState(PAGE_SOURCE);
|
|
521
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: starts on this page each time it opens.
|
|
522
|
+
useEffect(() => setData(PAGE_SOURCE), [open]);
|
|
523
|
+
const pageLabel = `${name} (this page)`;
|
|
524
|
+
const dataOptions = useWidgetDataOptions(open, pageLabel);
|
|
525
|
+
const own = data !== PAGE_SOURCE;
|
|
526
|
+
const ids = useMemo(() => (own ? [data] : []), [own, data]);
|
|
527
|
+
const read = useDomainReads(ids, ids)[data];
|
|
528
|
+
const shapeDomain = (own ? (read?.domain ?? { id: data, rowKey: String, fields: [] }) : domain);
|
|
529
|
+
const sourced = (c) => (own ? { ...c, source: data } : c);
|
|
530
|
+
const catalog = own ? [] : (viewWidgetCatalog[domain.id] ?? []);
|
|
531
|
+
const keys = new Set(domain.fields.map((f) => f.key));
|
|
532
|
+
const hasRow = !own && cards.some(isRowCard);
|
|
533
|
+
const full = cards.length >= 12;
|
|
534
|
+
const offered = useOffered(shapeDomain);
|
|
535
|
+
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "sm:max-w-2xl", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: "Add widget" }), _jsxs(DialogDescription, { children: ["Pick its data, then its shape", full ? '. A page holds 12 cards at most.' : '.'] })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "add-widget-data", className: "text-xs font-medium text-muted-foreground", children: "Data" }), _jsx(LumenSelector, { id: "add-widget-data", icon: DatabaseIcon, label: "Widget data", search: true, value: data, options: dataOptions, onChange: setData }), own && read?.state !== 'ok' && (_jsx("p", { className: cn('text-xs', read?.state === 'loading' || !read ? 'text-muted-foreground' : 'text-destructive'), children: read?.state === 'loading' || !read
|
|
536
|
+
? 'Reading its fields…'
|
|
537
|
+
: (read.detail ?? 'Its rows could not be read.') }))] }), _jsxs(Tabs, { defaultValue: catalog.length ? 'prebuilt' : 'own', children: [_jsxs(TabsList, { children: [!own && (_jsxs(TabsTrigger, { value: "prebuilt", children: [_jsx(PackageIcon, {}), "Pre-built"] })), _jsxs(TabsTrigger, { value: "own", children: [_jsx(WrenchIcon, {}), "Build your own"] })] }), _jsx(TabsContent, { value: "prebuilt", className: dialogBody, children: catalog.length ? (_jsx("div", { className: "grid grid-cols-1 gap-2 sm:grid-cols-2", children: catalog.map((it) => {
|
|
538
|
+
const added = cards.some((b) => b.id === it.widget.id);
|
|
539
|
+
const fits = [
|
|
540
|
+
...('by' in it.widget ? [it.widget.by, it.widget.value] : []),
|
|
541
|
+
...(it.widget.kind === 'table' && it.widget.style === 'top_n'
|
|
542
|
+
? [it.widget.sort, ...it.widget.columns]
|
|
543
|
+
: []),
|
|
544
|
+
].every((k) => keys.has(k));
|
|
545
|
+
return (_jsx(PickCard, { icon: cardIcon(it.widget), name: it.name, note: it.note, added: added, disabled: added || full || !fits, onClick: () => onAdd(it.widget) }, it.widget.id));
|
|
546
|
+
}) })) : (_jsx(Empty, { children: "No pre-built widgets for this view yet: build your own." })) }), _jsx(TabsContent, { value: "own", className: cn(dialogBody, 'flex flex-col gap-4'), children: viewCardKinds.map((kind) => {
|
|
547
|
+
const styles = WIDGET_STYLES.filter((w) => w.kind === kind && offered(w.id));
|
|
548
|
+
if (!styles.length)
|
|
549
|
+
return null;
|
|
550
|
+
return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("span", { className: "text-xs font-medium text-muted-foreground", children: VIEW_CARD_KIND_NAMES[kind] }), _jsx("div", { className: "grid grid-cols-1 gap-2 sm:grid-cols-2", children: styles.map((w) => {
|
|
551
|
+
// A table goes on as it is; the page's first row of numbers shows the
|
|
552
|
+
// view's cards, any other starts empty in Edit widget.
|
|
553
|
+
const ready = w.id === 'table:table' || (w.id === 'metric:row' && !hasRow);
|
|
554
|
+
return (_jsx(PickCard, { icon: cardIcon(w), name: w.name, note: w.note, disabled: full || (own && read?.state !== 'ok'), onClick: () => ready
|
|
555
|
+
? onAdd(sourced(draftOf(w.id, shapeDomain, cards)))
|
|
556
|
+
: onBuild(sourced(w.id === 'metric:row'
|
|
557
|
+
? viewCardSchema.parse({
|
|
558
|
+
...draftOf(w.id, shapeDomain, cards),
|
|
559
|
+
tiles: [],
|
|
560
|
+
})
|
|
561
|
+
: draftOf(w.id, shapeDomain, cards))) }, w.id));
|
|
562
|
+
}) })] }, kind));
|
|
563
|
+
}) })] }, data)] }) }));
|
|
564
|
+
}
|
|
565
|
+
/** A page's cards after `action` on card `i`, or null for Edit (the caller opens a dialog). */
|
|
566
|
+
export function cardsAfter(cards, i, action) {
|
|
567
|
+
const b = cards[i];
|
|
568
|
+
if (!b || action === 'edit')
|
|
569
|
+
return null;
|
|
570
|
+
const next = [...cards];
|
|
571
|
+
if (action === 'remove')
|
|
572
|
+
next.splice(i, 1);
|
|
573
|
+
if (action === 'duplicate')
|
|
574
|
+
next.splice(i + 1, 0, { ...b, id: freeId(cards, b.id.replace(/_\d+$/, '')) });
|
|
575
|
+
// Moving up or down swaps places with the card before or after it in reading order.
|
|
576
|
+
const swap = (a, c) => {
|
|
577
|
+
const A = next[a];
|
|
578
|
+
const C = next[c];
|
|
579
|
+
next[a] = { ...C, x: A.x, y: A.y, phone: A.phone };
|
|
580
|
+
next[c] = { ...A, x: C.x, y: C.y, phone: C.phone };
|
|
581
|
+
};
|
|
582
|
+
if (action === 'up' && i > 0)
|
|
583
|
+
swap(i - 1, i);
|
|
584
|
+
if (action === 'down' && i < next.length - 1)
|
|
585
|
+
swap(i, i + 1);
|
|
586
|
+
if (action === 'duplicate') {
|
|
587
|
+
const { x: _x, y: _y, phone: _p, ...copy } = next[i + 1];
|
|
588
|
+
next[i + 1] = copy;
|
|
589
|
+
}
|
|
590
|
+
return next;
|
|
591
|
+
}
|
|
592
|
+
/** ⓘ How to read: the card's own few lines on what it shows and what to look for. */
|
|
593
|
+
function CardHelp({ title, children }) {
|
|
594
|
+
return (_jsxs(Popover, { children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx(IconButton, { icon: InfoIcon, label: `How to read ${title}` }) }), _jsxs(PopoverContent, { align: "end", className: "w-80 text-sm", children: [_jsxs("p", { className: "mb-1.5 font-medium", children: ["How to read ", title] }), _jsx("div", { className: "flex flex-col gap-1.5 text-muted-foreground", children: children })] })] }));
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Ask the assistant, the card's ⋯ menu on every card: opens the panel pointed at this
|
|
598
|
+
* card with a suggested question in the composer, which the person edits and sends. Reading charts
|
|
599
|
+
* and verdicts is judgement, so the question asks for Bolt, the tier the assistant calls in for it.
|
|
600
|
+
*/
|
|
601
|
+
function useAskAssistant(id, title, hint, card) {
|
|
602
|
+
const z = useAssistant();
|
|
603
|
+
if (!z?.ready || hint === false)
|
|
604
|
+
return null;
|
|
605
|
+
return () => {
|
|
606
|
+
const where = typeof document === 'undefined' ? undefined : document.title || undefined;
|
|
607
|
+
z.draft(`Explain the "${title}" card on this page: what it shows, what stands out, and what I should do next.${hint ? ` ${hint}` : ''} Use Bolt to read it.`, [{ kind: 'card', label: title, id, card, where }]);
|
|
608
|
+
};
|
|
609
|
+
}
|