@columenlabs/lumen-ui 0.1.102 → 0.1.104

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.
@@ -0,0 +1,3 @@
1
+ import type { ViewDomain } from './view-model.js';
2
+ /** A trend's date candidates: text fields, the date-like ones first. */
3
+ export declare const dateFields: <T>(domain: ViewDomain<T>) => import("./view-model.js").ViewField<T>[];
@@ -0,0 +1,10 @@
1
+ const DATE_LIKE = /(^|_)(date|day|at|time|month|week|year)($|_)/i;
2
+ /** A trend's date candidates: text fields, the date-like ones first. */
3
+ export const dateFields = (domain) => {
4
+ // A rendered text field still reads its raw value (a report date drawn as Tue, Oct 20).
5
+ const texts = domain.fields.filter((f) => f.format === 'text');
6
+ return [
7
+ ...texts.filter((f) => DATE_LIKE.test(f.key)),
8
+ ...texts.filter((f) => !DATE_LIKE.test(f.key)),
9
+ ];
10
+ };
@@ -0,0 +1,9 @@
1
+ import type { ReactNode } from 'react';
2
+ type Size = `${number}%` | `${number}px`;
3
+ export declare function DrawerPanels({ defaultSize, minSize, maxSize, children, }: {
4
+ defaultSize: Size;
5
+ minSize: Size;
6
+ maxSize: Size;
7
+ children: ReactNode;
8
+ }): import("react").JSX.Element;
9
+ export {};
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from './ui/resizable.js';
3
+ export function DrawerPanels({ defaultSize, minSize, maxSize, children, }) {
4
+ return (_jsxs(ResizablePanelGroup, { orientation: "horizontal", children: [_jsx(ResizablePanel, { minSize: "40%" }), _jsx(ResizableHandle, { withHandle: true, className: "pointer-events-auto" }), _jsx(ResizablePanel, { defaultSize: defaultSize, minSize: minSize, maxSize: maxSize, "data-slot": "lumen-drawer", className: "pointer-events-auto bg-popover shadow-lg", children: children })] }));
5
+ }
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from 'react';
1
+ import { type ReactNode } from 'react';
2
2
  /** A drawer's width on desktop, as the resizable panel takes it ("32%", "320px"). */
3
3
  type Size = `${number}%` | `${number}px`;
4
4
  export interface LumenDrawerProps {
@@ -1,10 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { XIcon } from 'lucide-react';
3
+ import { useEffect } from 'react';
3
4
  import { useIsMobile } from './hooks/use-mobile.js';
4
5
  import { IconButton } from './icon-button.js';
6
+ import { lazyPart } from './lazy-part.js';
5
7
  import { Button } from './ui/button.js';
6
- import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from './ui/resizable.js';
7
8
  import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from './ui/sheet.js';
9
+ /** The resizable panels load with the first drawer opened; until then it opens at its default width. */
10
+ const loadPanels = () => import('./lumen-drawer-panels.js');
11
+ const DrawerPanels = lazyPart(() => loadPanels().then((m) => m.DrawerPanels));
8
12
  /**
9
13
  * The suite's one pullout drawer (named LumenDrawer), on every app.
10
14
  * Phone: a bottom sheet. Desktop: a right-side panel over the page whose width drags at its left
@@ -16,6 +20,13 @@ import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTi
16
20
  */
17
21
  export function LumenDrawer({ open, onClose, title, description, children, footer, footerStart, defaultSize = '32%', minSize = '320px', maxSize = '60%', }) {
18
22
  const isMobile = useIsMobile();
23
+ // Fetched once the page is idle, so the first drawer opened on a desktop has its panels ready.
24
+ useEffect(() => {
25
+ if (isMobile)
26
+ return;
27
+ const t = setTimeout(loadPanels, 2000);
28
+ return () => clearTimeout(t);
29
+ }, [isMobile]);
19
30
  const head = (_jsxs("div", { className: "flex min-w-0 flex-col gap-0.5", children: [_jsx(SheetTitle, { className: "truncate", title: typeof title === 'string' ? title : undefined, children: title }), _jsx(SheetDescription, { className: description ? 'truncate text-muted-foreground text-xs' : 'sr-only', title: typeof description === 'string' ? description : undefined, children: description ?? 'Details' })] }));
20
31
  const body = _jsx("div", { className: "flex flex-col px-4 pb-4", children: children });
21
32
  // Every drawer ends on a footer, styled like a dialog's: the body scrolls above it and always
@@ -26,5 +37,5 @@ export function LumenDrawer({ open, onClose, title, description, children, foote
26
37
  return (_jsx(Sheet, { open: open, onOpenChange: (o) => !o && onClose(), modal: isMobile, children: isMobile ? (_jsxs(SheetContent, { side: "bottom", className: "max-h-[85svh] gap-0 overflow-hidden", children: [_jsx(SheetHeader, { className: "order-first shrink-0 border-b pt-3", children: head }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto pt-2", children: body }), !plain && foot] })) : (_jsx(SheetContent, { side: "right", showCloseButton: false,
27
38
  // The resize handle's grab zone is wider than the handle, so a drag can start on the
28
39
  // overlay; the panels library marks that pointerdown handled, and then it doesn't close.
29
- onPointerDownOutside: (e) => e.detail.originalEvent.defaultPrevented && e.preventDefault(), className: "pointer-events-none! gap-0 border-0 bg-transparent shadow-none data-[side=right]:left-0 data-[side=right]:w-auto data-[side=right]:border-l-0 data-[side=right]:sm:max-w-none", children: _jsxs(ResizablePanelGroup, { orientation: "horizontal", children: [_jsx(ResizablePanel, { minSize: "40%" }), _jsx(ResizableHandle, { withHandle: true, className: "pointer-events-auto" }), _jsx(ResizablePanel, { defaultSize: defaultSize, minSize: minSize, maxSize: maxSize, "data-slot": "lumen-drawer", className: "pointer-events-auto bg-popover shadow-lg", children: _jsxs("section", { className: "flex h-full flex-col overflow-hidden", children: [_jsxs("header", { className: "flex shrink-0 items-center gap-2 border-b px-4 py-3", children: [head, _jsx(SheetClose, { asChild: true, children: _jsx(IconButton, { icon: XIcon, label: "Close", className: "ml-auto" }) })] }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto pt-2", children: body }), foot] }) })] }) })) }));
40
+ onPointerDownOutside: (e) => e.detail.originalEvent.defaultPrevented && e.preventDefault(), className: "pointer-events-none! gap-0 border-0 bg-transparent shadow-none data-[side=right]:left-0 data-[side=right]:w-auto data-[side=right]:border-l-0 data-[side=right]:sm:max-w-none", children: _jsx(DrawerPanels, { defaultSize: defaultSize, minSize: minSize, maxSize: maxSize, children: _jsxs("section", { className: "flex h-full flex-col overflow-hidden", children: [_jsxs("header", { className: "flex shrink-0 items-center gap-2 border-b px-4 py-3", children: [head, _jsx(SheetClose, { asChild: true, children: _jsx(IconButton, { icon: XIcon, label: "Close", className: "ml-auto" }) })] }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto pt-2", children: body }), foot] }) }) })) }));
30
41
  }
@@ -35,7 +35,6 @@ import { OriginLegend } from './origin-legend.js';
35
35
  import { IconButton, Page } from './page.js';
36
36
  import { SourcedCard } from './sourced-card.js';
37
37
  import { LumenCard, SummaryTiles } from './summary-cards.js';
38
- import { Button } from './ui/button.js';
39
38
  import { computedOf, filterFieldsOf, formulaBlanks, pick, subgroupOf, summaryCash, takesFormulas, withSavedFilters, withViewFields, } from './view-model.js';
40
39
  // Loaded when it first shows (lazy-part.tsx).
41
40
  const ViewEditDialog = lazyPart(() => import('./view-editor.js').then((m) => m.ViewEditDialog), null);
@@ -47,6 +47,8 @@ interface Common<V extends string> {
47
47
  /** The create row's text; `Add "…"` by default. */
48
48
  createLabel?: (query: string) => string;
49
49
  placeholder?: string;
50
+ /** What the closed field shows instead of the picked option's label (a zone's short name, PT). */
51
+ display?: ReactNode;
50
52
  /** Shown when nothing matches. */
51
53
  empty?: string;
52
54
  /** Accessible name; also the phone sheet's title. */
@@ -149,7 +149,7 @@ function Rows({ shown, picked, multiple, full, expand, }) {
149
149
  const FIELD = "flex w-fit min-w-0 items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4";
150
150
  /** The one picker. See the file header for what every use gets. */
151
151
  export function LumenSelector(props) {
152
- const { options, groups, trigger = 'field', icon: LeadIcon, search = 'auto', create, createLabel = (q) => `Add “${q}”`, placeholder = 'Pick one', empty = 'Nothing matches', label, size = 'default', className, disabled, header, align = 'start', id, } = props;
152
+ const { options, groups, trigger = 'field', icon: LeadIcon, search = 'auto', create, createLabel = (q) => `Add “${q}”`, placeholder = 'Pick one', display, empty = 'Nothing matches', label, size = 'default', className, disabled, header, align = 'start', id, } = props;
153
153
  const multiple = props.multiple === true;
154
154
  const values = multiple
155
155
  ? props.value
@@ -236,7 +236,11 @@ export function LumenSelector(props) {
236
236
  };
237
237
  const selected = multiple ? null : (byValue.get(values[0] ?? '') ?? null);
238
238
  const Lead = selected?.icon ?? LeadIcon;
239
- const fieldInside = (_jsxs(_Fragment, { children: [Lead && _jsx(Lead, { className: "text-muted-foreground" }), _jsx("span", { className: cn('min-w-0 flex-1 truncate text-left', !selected && !values.length && 'text-muted-foreground'), children: selected ? (selected.content ?? selected.label) : values[0] ? values[0] : placeholder }), _jsx(ChevronDownIcon, { className: "pointer-events-none size-4 text-muted-foreground" })] }));
239
+ const fieldInside = (_jsxs(_Fragment, { children: [Lead && _jsx(Lead, { className: "text-muted-foreground" }), _jsx("span", { className: cn('min-w-0 flex-1 truncate text-left', !selected && !values.length && 'text-muted-foreground'), children: selected
240
+ ? (display ?? selected.content ?? selected.label)
241
+ : values[0]
242
+ ? values[0]
243
+ : placeholder }), _jsx(ChevronDownIcon, { className: "pointer-events-none size-4 text-muted-foreground" })] }));
240
244
  // On a desktop the popup is a column as tall as the screen allows: the search, header and
241
245
  // footer keep their height and the list between them scrolls.
242
246
  const list = (_jsxs(_Fragment, { children: [header && _jsx("div", { className: "shrink-0", children: header }), _jsx(Base.List, { className: cn('min-h-0 overscroll-contain p-1 data-empty:p-0', phone ? 'flex-1 overflow-y-auto pb-4' : 'max-h-88 flex-1 scroll-pt-8 overflow-y-auto'), children: _jsx(Rows, { shown: rows, picked: picked, multiple: multiple, full: full, expand: expand }) }), _jsx(Base.Empty, { className: "hidden px-2 py-3 text-center text-sm text-muted-foreground group-data-empty/selector:block", children: empty })] }));
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { bytesParts, controlOn, isCountAgg, isDollarFormat, viewPageSizes, viewPaginationSchema, } from '@columenlabs/lumen-ui-types/views';
2
+ import { bytesParts, controlOn, isCountAgg, isDollarFormat, viewPaginationSchema, } from '@columenlabs/lumen-ui-types/views';
3
3
  // Custom views: any number of views on any app, one page for all of them.
4
4
  // A data domain (holdings, chains, bars, series, research results) describes its fields once in a
5
5
  // `ViewDomain`; a view is a saved `CustomViewConfig` over that domain, written in the page's Edit
@@ -7,12 +7,9 @@ import { bytesParts, controlOn, isCountAgg, isDollarFormat, viewPageSizes, viewP
7
7
  // one level deep with collapsible group rows, pivot, totals, sorting, paging), LumenTable and the
8
8
  // details drawer; LumenLayout (lumen-layout.tsx) is the page around it. Rows are the ones the app
9
9
  // already loaded; everything here runs on the client.
10
- import { DndContext, closestCenter } from '@dnd-kit/core';
11
- import { SortableContext, rectSortingStrategy } from '@dnd-kit/sortable';
12
10
  import { createPaginatedRowModel, createSortedRowModel, flexRender, rowPaginationFeature, rowSortingFeature, sortFns, tableFeatures, useTable, } from '@tanstack/react-table';
13
- import { ArrowDownIcon, ArrowUpIcon, ChartCandlestick, ChartSpline, ChevronLeftIcon, ChevronRightIcon, ChevronsDownIcon, ChevronsDownUpIcon, ChevronsUpDownIcon, Columns3CogIcon, CornerDownRightIcon, DownloadIcon, Grid3x3Icon, LayoutDashboardIcon, ListCollapseIcon, PencilIcon, PlusIcon, Settings2Icon, SigmaIcon, SparklesIcon, TableIcon, UserPenIcon, } from 'lucide-react';
11
+ import { ArrowDownIcon, ArrowUpIcon, ChartCandlestick, ChartSpline, ChevronRightIcon, ChevronsDownIcon, ChevronsDownUpIcon, ChevronsUpDownIcon, Columns3CogIcon, CornerDownRightIcon, DownloadIcon, ListCollapseIcon, PlusIcon, SigmaIcon, UserPenIcon, } from 'lucide-react';
14
12
  import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
15
- import { useAssistantNote } from './assistant-context.js';
16
13
  import { ColorChip, ColorDot } from './color-dot.js';
17
14
  import { DrawerSection } from './drawer-section.js';
18
15
  import { useFloatingHead } from './floating-head.js';
@@ -21,31 +18,24 @@ import { useIsMobile } from './hooks/use-mobile.js';
21
18
  import { IconButton } from './icon-button.js';
22
19
  import { lazyPart } from './lazy-part.js';
23
20
  import { cn } from './lib/utils.js';
24
- import { LumenTokenizedFilter, useLumenFilter, } from './lumen-filter.js';
25
21
  import { useGridCell } from './lumen-grid.js';
26
22
  import { LumenSelector } from './lumen-selector.js';
27
- import { LumenLazy, LumenLoadFooter, LumenRefresh, LumenSkeleton, LumenStates, useLiveLoad, } from './lumen-source.js';
23
+ import { LumenSkeleton, LumenStates, useLiveLoad } from './lumen-source.js';
28
24
  import { LumenWidget } from './lumen-widget.js';
29
25
  import { MAX_COMPARE, useSymbolChart } from './market/lumen-chart.js';
30
- import { Page } from './page.js';
31
26
  import { LumenPager } from './pager.js';
32
27
  import { maskAmount, useAmountsHidden } from './shell.js';
33
28
  import { useSymbolSheet } from './symbol-sheet-context.js';
34
- import { Tile } from './tile.js';
35
29
  import { Badge } from './ui/badge.js';
36
30
  import { Button } from './ui/button.js';
37
- import { Card } from './ui/card.js';
38
31
  import { Checkbox } from './ui/checkbox.js';
39
- import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, } from './ui/dropdown-menu.js';
40
- import { Popover, PopoverAnchor, PopoverContent } from './ui/popover.js';
41
- import { Skeleton } from './ui/skeleton.js';
32
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from './ui/dropdown-menu.js';
42
33
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table.js';
43
- import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.js';
44
34
  import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip.js';
45
35
  import { UnitColumns, UnitValue } from './unit-value.js';
46
36
  import { EditCell, EditInputCell, EditManyCell } from './view-edit-cell.js';
47
37
  import { fieldOptions } from './view-formulas.js';
48
- import { CHIP_TONE, FORMULAS_GROUP, chipsOf, computedOf, filterFieldsOf, groupOf, label, pick, rollUp, rollsUp, subgroupOf, withSavedFilters, withViewFields, } from './view-model.js';
38
+ import { CHIP_TONE, FORMULAS_GROUP, chipsOf, computedOf, groupOf, label, pick, rollUp, rollsUp, subgroupOf, } from './view-model.js';
49
39
  // Loaded when it first shows (lazy-part.tsx).
50
40
  const ViewEditDialog = lazyPart(() => import('./view-editor.js').then((m) => m.ViewEditDialog), null);
51
41
  export { CHIP_TONE, FORMULAS_GROUP, RUN_STATE_TONE } from './view-model.js';
@@ -426,7 +416,7 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
426
416
  // Columns a UnitValue has drawn in (`UnitColumns`): their cells share the numbers' edge.
427
417
  const [units, setUnits] = useState(() => new Set());
428
418
  const markUnits = useCallback((key) => setUnits((s) => (s.has(key) ? s : new Set(s).add(key))), []);
429
- const col = colPropsOf(units);
419
+ const colProps = colPropsOf(units);
430
420
  const [flipped, setFlipped] = useState(new Set());
431
421
  const [more, setMore] = useState(new Map());
432
422
  // Under a sub-group's `flat` grouping each row names itself by its sub-group key instead of
@@ -440,6 +430,17 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
440
430
  // Inline chips sit on the first column, next to the row's name, not in their own.
441
431
  const inline = visible.filter((f, i) => i > 0 && chips.some((c) => c.key === f.key && c.placement === 'inline'));
442
432
  const shown = visible.filter((f) => !inline.includes(f));
433
+ // On a narrow phone each row reads as a card (theme.css, `data-card`): its name and one figure
434
+ // (the sorted one, else the first number or date) on the first line, the rest on the second.
435
+ // Numbers and dates keep their width there; only text shortens.
436
+ const short = (f) => f.format !== 'text' || (rows[0] !== undefined && isDateText(f.value(rows[0])));
437
+ const meta = (shown.find((f, i) => i > 0 && f.key === sorting[0]?.id && short(f)) ??
438
+ shown.find((f, i) => i > 0 && short(f)))?.key;
439
+ const cardOf = (f) => f === shown[0] ? 'lead' : f.key === meta ? 'meta' : short(f) ? 'num' : 'text';
440
+ const col = (f, ...more) => ({
441
+ ...colProps(f, ...more),
442
+ 'data-card': f ? cardOf(f) : undefined,
443
+ });
443
444
  const computed = useMemo(() => computedOf(domain, rows, groupBy), [domain, rows, groupBy]);
444
445
  // Values per editable field, offered when a cell is edited: the field's own list when it has
445
446
  // one (every value, in use or not), else the values in the shown rows.
@@ -595,7 +596,7 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
595
596
  const allKeys = rows.map(keyOf);
596
597
  const allOn = allKeys.length > 0 && allKeys.every((k) => selection?.keys.has(k));
597
598
  const someOn = allKeys.some((k) => selection?.keys.has(k));
598
- const selectCell = (key, name) => selection && (_jsx(TableCell, { className: "w-8 pr-0", onClick: (e) => e.stopPropagation(), children: _jsx(Checkbox, { checked: selection.keys.has(key), onCheckedChange: (on) => toggle([key], on === true), "aria-label": `Select ${name}` }) }));
599
+ const selectCell = (key, name) => selection && (_jsx(TableCell, { "data-card": "select", className: "w-8 pr-0", onClick: (e) => e.stopPropagation(), children: _jsx(Checkbox, { checked: selection.keys.has(key), onCheckedChange: (on) => toggle([key], on === true), "aria-label": `Select ${name}` }) }));
599
600
  // The rows whose open state the person flipped: a sub-group that starts open (`open`) lists
600
601
  // the closed ones, any other the open ones.
601
602
  const [partFlips, setOpenParts] = useState(new Set());
@@ -719,7 +720,7 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
719
720
  };
720
721
  const box = useRef(null);
721
722
  useFloatingHead(box, !inView);
722
- return (_jsx("div", { ref: box, className: cn(inView ? IN_VIEW : 'overflow-x-auto overflow-y-hidden [--pin-top:0px]', fit && FIT), children: _jsx(UnitColumns.Provider, { value: markUnits, children: _jsxs(Table, { children: [_jsx(TableHeader, { children: table.getHeaderGroups().map((hg) => (_jsxs(TableRow, { children: [selection && (_jsx(TableHead, { className: cn('w-8 pr-0', PINNED_HEAD), children: _jsx(Checkbox, { checked: allOn ? true : someOn ? 'indeterminate' : false, onCheckedChange: (on) => toggle(allKeys, on === true), "aria-label": "Select every row" }) })), hg.headers.map((h, i) => (_jsxs(TableHead, { ...col(shown[i], PINNED_HEAD), style: ruleOf(colorOf(domain, shown[i])), onPointerDown: pressStart, onPointerUp: pressEnd, onPointerLeave: pressEnd, onPointerCancel: pressEnd, onContextMenu: (e) => {
723
+ return (_jsx("div", { ref: box, className: cn(inView ? IN_VIEW : 'overflow-x-auto overflow-y-hidden [--pin-top:0px]', fit && FIT), children: _jsx(UnitColumns.Provider, { value: markUnits, children: _jsxs(Table, { "data-cards": "", children: [_jsx(TableHeader, { children: table.getHeaderGroups().map((hg) => (_jsxs(TableRow, { children: [selection && (_jsx(TableHead, { className: cn('w-8 pr-0', PINNED_HEAD), children: _jsx(Checkbox, { checked: allOn ? true : someOn ? 'indeterminate' : false, onCheckedChange: (on) => toggle(allKeys, on === true), "aria-label": "Select every row" }) })), hg.headers.map((h, i) => (_jsxs(TableHead, { ...col(shown[i], PINNED_HEAD), style: ruleOf(colorOf(domain, shown[i])), onPointerDown: pressStart, onPointerUp: pressEnd, onPointerLeave: pressEnd, onPointerCancel: pressEnd, onContextMenu: (e) => {
723
724
  if (press.current)
724
725
  e.preventDefault();
725
726
  }, "aria-sort": h.column.getIsSorted() === 'asc'
@@ -1,16 +1,17 @@
1
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';
2
+ import { OUTLOOK_INDEXES } from '@columenlabs/lumen-ui-types/market';
3
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
4
  // A card is the view (LumenView) or a widget. Chart and top-N widgets read every row of the view
5
5
  // (the table's filter is the table's), narrowed by their own tokens, and work their chart out here in the browser:
6
6
  // nothing new is fetched; market and price chart widgets read their own data. LumenLayout lays the
7
7
  // cards out; this file draws the card frame every card shares (its header, ↻ and ⋯ menu), each
8
8
  // widget, the Edit layout bar, Add widget (Pre-built and Build your own) and Edit widget.
9
- import { VIEW_CARD_KIND_NAMES, WIDGET_STYLES, controlOn, isDollarFormat, isOwnTile, isRowCard, isTableCard, trendBuckets, viewCardKinds, viewCardSchema, viewWidgetCatalog, widgetStyleId, widgetStyleOf, } from '@columenlabs/lumen-ui-types/views';
9
+ import { VIEW_CARD_KIND_NAMES, WIDGET_STYLES, controlOn, isDollarFormat, isOwnTile, isRowCard, isTableCard, viewCardKinds, viewCardSchema, viewWidgetCatalog, widgetStyleId, widgetStyleOf, } from '@columenlabs/lumen-ui-types/views';
10
10
  import { ActivityIcon, ArrowDownIcon, ArrowUpIcon, BarChart3Icon, CandlestickChartIcon, ChartLineIcon, ChartPieIcon, CheckIcon, CodeIcon, CopyIcon, DatabaseIcon, EyeOffIcon, FoldVerticalIcon, FunnelIcon, GripVerticalIcon, InfoIcon, LayoutGridIcon, ListOrderedIcon, MoreHorizontalIcon, PackageIcon, PencilIcon, PlusIcon, RefreshCwIcon, RotateCcwIcon, Rows3Icon, SparklesIcon, SquareIcon, TableIcon, Trash2Icon, TrendingUpIcon, WrenchIcon, XIcon, } from 'lucide-react';
11
11
  import { useEffect, useMemo, useRef, useState } from 'react';
12
12
  import { useAssistant, useAssistantName } from './assistant-context.js';
13
13
  import { LumenConfirm } from './confirm.js';
14
+ import { dateFields } from './date-fields.js';
14
15
  import { FoldChevron, useFolded, usePageKey } from './fold.js';
15
16
  import { useIsMobile } from './hooks/use-mobile.js';
16
17
  import { IconButton } from './icon-button.js';
@@ -18,7 +19,7 @@ import { useDomainReads } from './joins.js';
18
19
  import { lazyPart } from './lazy-part.js';
19
20
  import { cn } from './lib/utils.js';
20
21
  import { LumenBoundary } from './lumen-boundary.js';
21
- import { LumenTokenizedFilter, filterByTokens, useLumenFilter, } from './lumen-filter.js';
22
+ import { filterByTokens } from './lumen-filter.js';
22
23
  import { useGridCell } from './lumen-grid.js';
23
24
  import { CardIdProvider, LinkDot, LinkPicker, linkVar, useLinks, } from './lumen-links.js';
24
25
  import { LumenSelector } from './lumen-selector.js';
@@ -26,27 +27,19 @@ import { LumenFreshness, LumenLazy, LumenLoadFooter, LumenRefresh, LumenStates,
26
27
  import { useHasMarket } from './market/live.js';
27
28
  import { useBarsUrl, useLumenChart } from './market/lumen-chart.js';
28
29
  import { maskAmount, useAmountsHidden } from './shell.js';
29
- import { SummaryCardsEditor, summaryCardIssues } from './summary-cards.js';
30
30
  import { Badge } from './ui/badge.js';
31
31
  import { Button } from './ui/button.js';
32
32
  import { Card } from './ui/card.js';
33
- import { Checkbox } from './ui/checkbox.js';
34
- import { Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, dialogBody, } from './ui/dialog.js';
33
+ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, dialogBody, } from './ui/dialog.js';
35
34
  import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from './ui/dropdown-menu.js';
36
- import { Input } from './ui/input.js';
37
35
  import { Label } from './ui/label.js';
38
36
  import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from './ui/popover.js';
39
37
  import { Skeleton } from './ui/skeleton.js';
40
- import { Switch } from './ui/switch.js';
41
38
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table.js';
42
39
  import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs.js';
43
- import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.js';
44
40
  import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip.js';
45
- import { fieldOptions } from './view-formulas.js';
46
- import { computedOf, label, summaryCash, } from './view-model.js';
47
- import { WidgetControlsEditor } from './widget-controls.js';
41
+ import { computedOf } from './view-model.js';
48
42
  import { PAGE_SOURCE, useWidgetDataOptions } from './widget-data.js';
49
- import { dateFields } from './widget-settings.js';
50
43
  import { COMPACT, MINUS } from './widget-values.js';
51
44
  // Loaded when it first shows (lazy-part.tsx).
52
45
  const MarketWidget = lazyPart(() => import('./market/market-widget.js').then((m) => m.MarketWidget), _jsx(Skeleton, { className: "h-full min-h-40 w-full" }));
package/dist/settings.js CHANGED
@@ -6,17 +6,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  // sections (an app: Repos). On phone the list becomes a picker.
7
7
  // A search box tops the list and filters the rows on every section.
8
8
  // `?settings=<section>` in the URL opens it at that section (GitHub's install page returns there).
9
- import { LogOutIcon, MonitorIcon, MoonIcon, PaletteIcon, SearchIcon, ShieldCheckIcon, SlidersHorizontalIcon, SunIcon, UserRoundIcon, XIcon, } from 'lucide-react';
9
+ import { MonitorIcon, MoonIcon, PaletteIcon, SearchIcon, ShieldCheckIcon, SlidersHorizontalIcon, SunIcon, UserRoundIcon, XIcon, } from 'lucide-react';
10
10
  import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, } from 'react';
11
11
  import { createPortal } from 'react-dom';
12
- import { toast } from 'sonner';
13
12
  import { useAssistantMoves } from './assistant-screen.js';
14
13
  import { LumenSelector } from './lumen-selector.js';
15
14
  import { FONTS, FONT_SIZES, setPref, usePrefs, } from './prefs.js';
16
15
  import { TimeZoneSelect } from './status.js';
17
- import { Button } from './ui/button.js';
18
16
  import { Dialog, DialogContent, DialogDescription, DialogTitle } from './ui/dialog.js';
19
- import { Input } from './ui/input.js';
20
17
  import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from './ui/input-group.js';
21
18
  import { Label } from './ui/label.js';
22
19
  import { Separator } from './ui/separator.js';
package/dist/shell.d.ts CHANGED
@@ -98,7 +98,7 @@ export interface LumenShellProps {
98
98
  children: ReactNode;
99
99
  }
100
100
  export declare function LumenShell({ app, appLabel, brand, nav, status, health, statusEnd, releases, releaseApps, settings, shared, sharedLabel, settingsGroups, user, signOutUrl, profile, security, onRefresh, hideAmounts, onLogs, headerActions, panel, finder, wrap, children, }: LumenShellProps): import("react").JSX.Element;
101
- /** A status bar entry: an optional health dot, a label and a mono value. */
101
+ /** A status bar entry: an optional health dot, a label and its value ("Mail · Up to date"). */
102
102
  export declare function StatusItem({ label, value, ok, className, }: {
103
103
  label: ReactNode;
104
104
  value?: ReactNode;
package/dist/shell.js CHANGED
@@ -141,7 +141,7 @@ export function LumenShell({ app, appLabel, brand, nav, status, health, statusEn
141
141
  headerActions,
142
142
  }, children: _jsx(SourceHealthProvider, { children: wrap(_jsx(TooltipProvider, { delayDuration: 200, children: _jsx(SidebarProvider, { defaultOpen: sidebarOpen(), children: _jsxs(LumenFinder, { app: app, settings: findSettings, openSettings: openSettings, config: finder, children: [_jsx(CloseDrawerOnNavigate, {}), _jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: brand }), _jsx(SidebarContent, { children: nav }), _jsx(SidebarFooter, { children: _jsx(NavUser, { who: user, signOutUrl: signOutUrl, onSettings: () => openSettings(), onLogs: onLogs }) }), _jsx(SidebarRail, {})] }), _jsxs(SidebarInset, { className: "flex min-h-svh min-w-0 flex-col", children: [_jsx("div", { className: "flex min-w-0 flex-1 flex-col", children: children }, epoch), _jsxs("footer", { "data-slot": "shell-status", "aria-label": "Status", className: "sticky bottom-0 z-10 flex h-(--shell-status-height) shrink-0 items-center gap-4 overflow-x-auto overflow-y-hidden border-t bg-background px-(--shell-gutter) text-xs whitespace-nowrap text-muted-foreground", children: [status, _jsx("div", { ref: setStatusSlot, className: "flex shrink-0 items-center gap-4" }), _jsxs("span", { className: "ml-auto flex shrink-0 items-center gap-1", children: [health, releases && _jsx(WhatsNew, { api: releases, apps: releaseApps }), statusEnd, _jsx(StatusTimeZone, {})] })] })] }), panel, _jsx(LumenSettings, { open: settingsAt !== null, section: settingsAt ?? lastSection, onOpen: openSettings, onClose: () => setSettingsAt(null), appLabel: appLabel, profile: profile, security: security, sections: settings, shared: shared, sharedLabel: sharedLabel, groups: settingsGroups }), _jsx(Toaster, { position: "bottom-right" })] }) }) })) }) }));
143
143
  }
144
- /** A status bar entry: an optional health dot, a label and a mono value. */
144
+ /** A status bar entry: an optional health dot, a label and its value ("Mail · Up to date"). */
145
145
  export function StatusItem({ label, value, ok, className, }) {
146
- return (_jsxs("span", { className: cn('flex items-center gap-1.5', value === undefined && 'min-w-0', className), children: [ok !== undefined && (_jsx("span", { "aria-hidden": true, className: cn('size-2 shrink-0 rounded-full', ok === null ? 'bg-muted-foreground/40' : ok ? 'bg-gain' : 'bg-destructive') })), _jsx("span", { className: value === undefined ? 'min-w-0 truncate' : 'max-md:hidden', children: label }), value !== undefined && (_jsxs(_Fragment, { children: [' ', _jsx("span", { className: "num shrink-0 text-foreground/80", children: value })] }))] }));
146
+ return (_jsxs("span", { className: cn('flex items-center gap-1.5', value === undefined && 'min-w-0', className), children: [ok !== undefined && (_jsx("span", { "aria-hidden": true, className: cn('size-2 shrink-0 rounded-full', ok === null ? 'bg-muted-foreground/40' : ok ? 'bg-gain' : 'bg-destructive') })), _jsx("span", { className: value === undefined ? 'min-w-0 truncate' : 'max-md:hidden', children: label }), value !== undefined && (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, className: "max-md:hidden", children: "\u00B7" }), _jsx("span", { className: "shrink-0 tabular-nums text-foreground/80", children: value })] }))] }));
147
147
  }
package/dist/status.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  /** Time zone picker: on the right of the status bar, and in Settings. */
2
- export declare function TimeZoneSelect({ className, }: {
2
+ export declare function TimeZoneSelect({ className, short, }: {
3
3
  className?: string;
4
+ /** Shows the zone's short name (PT), the way every time on the page names it. */
5
+ short?: boolean;
4
6
  /** Kept for callers; the list flips upward by itself where there is no room below. */
5
7
  side?: 'top' | 'bottom';
6
8
  }): import("react").JSX.Element;
package/dist/status.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { cn } from './lib/utils.js';
3
3
  import { LumenSelector } from './lumen-selector.js';
4
- import { TIME_ZONES, setPref, usePrefs } from './prefs.js';
4
+ import { TIME_ZONES, setPref, usePrefs, zoneAbbr } from './prefs.js';
5
5
  /** Time zone picker: on the right of the status bar, and in Settings. */
6
- export function TimeZoneSelect({ className, }) {
6
+ export function TimeZoneSelect({ className, short, }) {
7
7
  const { timeZone } = usePrefs();
8
- return (_jsx(LumenSelector, { label: "Time zone", size: "sm", className: className, options: TIME_ZONES.map((z) => ({ value: z.id, label: z.label })), value: timeZone, onChange: (v) => setPref('timeZone', v) }));
8
+ return (_jsx(LumenSelector, { label: "Time zone", size: "sm", className: className, options: TIME_ZONES.map((z) => ({ value: z.id, label: z.label })), value: timeZone, display: short ? zoneAbbr(timeZone) : undefined, onChange: (v) => setPref('timeZone', v) }));
9
9
  }
10
10
  export function StatusTimeZone({ className }) {
11
- return (_jsx("span", { className: cn('flex items-center', className), children: _jsx(TimeZoneSelect, { side: "top", className: "h-6 border-0 px-1.5 text-xs shadow-none" }) }));
11
+ return (_jsx("span", { className: cn('flex items-center', className), children: _jsx(TimeZoneSelect, { side: "top", short: true, className: "h-6 border-0 px-1.5 text-xs shadow-none" }) }));
12
12
  }
@@ -0,0 +1,19 @@
1
+ import { type ViewTile } from '@columenlabs/lumen-ui-types/views';
2
+ import { type FilterField } from './lumen-filter.js';
3
+ import { type ViewDomain } from './view-model.js';
4
+ /**
5
+ * The Summary's cards in Edit widget: a grid to pick and drag, the domain's own cards to add or
6
+ * remove, and the picked card of the view's own: its main number (label, roll-up, field, rows,
7
+ * colour by sign) and its secondary lines.
8
+ */
9
+ export declare function SummaryCardsEditor<T>({ domain, tiles, onChange, rows, fields, problems, one, }: {
10
+ domain: ViewDomain<T>;
11
+ tiles: ViewTile[];
12
+ onChange: (tiles: ViewTile[]) => void;
13
+ /** One card only (LumenCard): a built-in replaces it and there is no Add card. */
14
+ one?: boolean;
15
+ /** The Summary's rows, for the previews. */
16
+ rows: T[];
17
+ fields: FilterField<T>[];
18
+ problems: (string | null)[];
19
+ }): import("react").JSX.Element;
@@ -0,0 +1,139 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The cards part of the Summary's Edit widget (cards reorder by drag), in a file of its own so the
3
+ // drag library loads with the editor, not with every page that draws a Summary.
4
+ import { MAX_TILE_LINES, isCountAgg, isOwnTile, viewAggs, } from '@columenlabs/lumen-ui-types/views';
5
+ import { DndContext, KeyboardSensor, PointerSensor, TouchSensor, closestCenter, useSensor, useSensors, } from '@dnd-kit/core';
6
+ import { SortableContext, arrayMove, rectSortingStrategy, sortableKeyboardCoordinates, useSortable, } from '@dnd-kit/sortable';
7
+ import { CSS } from '@dnd-kit/utilities';
8
+ import { PlusIcon, Trash2Icon, XIcon } from 'lucide-react';
9
+ import { useEffect, useState } from 'react';
10
+ import { LumenConfirm } from './confirm.js';
11
+ import { IconButton } from './icon-button.js';
12
+ import { cn } from './lib/utils.js';
13
+ import { LumenTokenizedFilter, useLumenFilter } from './lumen-filter.js';
14
+ import { LumenSelector } from './lumen-selector.js';
15
+ import { Badge } from './ui/badge.js';
16
+ import { Button } from './ui/button.js';
17
+ import { Input } from './ui/input.js';
18
+ import { Label } from './ui/label.js';
19
+ import { Switch } from './ui/switch.js';
20
+ import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.js';
21
+ import { AGG_LABELS, MAX_TILES, fieldOptions } from './view-formulas.js';
22
+ import { takesFormulas } from './view-model.js';
23
+ import { FigureText, OwnTile, figuresOf, isNumber } from './summary-cards.js';
24
+ const dragOptions = { activationConstraint: { distance: 4 } };
25
+ function FieldPick({ value, fields, domain, label, onChange, }) {
26
+ return (_jsx(LumenSelector, { label: label, size: "sm", className: "w-full min-w-0", placeholder: "Pick a field", options: fieldOptions(fields, domain), value: value, onChange: onChange }));
27
+ }
28
+ function AggPick({ value, label, onChange, }) {
29
+ return (_jsx(LumenSelector, { label: label, size: "sm", className: "w-full min-w-0", options: viewAggs.map((a) => ({ value: a, label: AGG_LABELS[a] })), value: value, onChange: onChange }));
30
+ }
31
+ /** A card in the editor's grid: its label, its value now and where it comes from. */
32
+ function CardChip({ id, selected, onSelect, label, value, how, problem, domId, }) {
33
+ const s = useSortable({ id });
34
+ return (_jsxs(Button, { ref: s.setNodeRef, id: domId, variant: "outline", style: { transform: CSS.Transform.toString(s.transform), transition: s.transition }, className: cn('h-auto min-h-16 min-w-0 flex-col items-start justify-start gap-0.5 rounded-lg px-2.5 py-2 text-left font-normal whitespace-normal', selected && 'border-primary ring-3 ring-primary/15', problem && 'border-destructive', s.isDragging && 'z-20 shadow-md'), ...s.attributes, ...s.listeners, "aria-pressed": selected, onClick: onSelect, children: [_jsx("span", { className: "w-full truncate text-xs text-muted-foreground", children: label }), _jsx("span", { className: "num w-full truncate text-sm font-semibold", children: value }), _jsx("span", { className: "w-full truncate text-[0.7rem] text-muted-foreground", children: how })] }));
35
+ }
36
+ /** A card's own rows filter, kept in step with the card. */
37
+ function CardFilter({ rows, fields, tokens, onChange, }) {
38
+ const filter = useLumenFilter(rows, fields, { initial: tokens });
39
+ const next = JSON.stringify(filter.tokens);
40
+ // biome-ignore lint/correctness/useExhaustiveDependencies: reports the box's tokens when they change
41
+ useEffect(() => {
42
+ if (next !== JSON.stringify(tokens))
43
+ onChange(filter.tokens);
44
+ }, [next]);
45
+ return (_jsx(LumenTokenizedFilter, { filter: filter, label: "Card filter", placeholder: "All the Summary's rows" }));
46
+ }
47
+ const LINE_KINDS = [
48
+ { value: 'value', label: 'Number' },
49
+ { value: 'pct', label: '% of' },
50
+ { value: 'text', label: 'Text' },
51
+ ];
52
+ function LineRow({ line, nums, fields, domain, onChange, onRemove, }) {
53
+ const first = nums[0]?.key ?? '';
54
+ const as = (kind) => kind === 'text'
55
+ ? { kind, text: 'Note' }
56
+ : kind === 'pct'
57
+ ? { kind, field: line.kind !== 'text' ? line.field : first, agg: 'sum' }
58
+ : {
59
+ kind,
60
+ field: line.kind !== 'text' ? line.field : first,
61
+ agg: 'sum',
62
+ filter: [],
63
+ suffix: '',
64
+ };
65
+ return (_jsxs("div", { className: "flex flex-col gap-1.5 rounded-md border p-2", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(ToggleGroup, { type: "single", size: "sm", variant: "outline", spacing: 0, value: line.kind, onValueChange: (v) => v && onChange(as(v)), "aria-label": "Line kind", children: LINE_KINDS.map((k) => (_jsx(ToggleGroupItem, { value: k.value, className: "px-2 text-xs", children: k.label }, k.value))) }), _jsx(IconButton, { icon: XIcon, label: "Remove line", className: "ml-auto", onClick: onRemove })] }), line.kind === 'text' ? (_jsx(Input, { "aria-label": "Line text", value: line.text, maxLength: 60, onChange: (e) => onChange({ ...line, text: e.target.value }) })) : (_jsxs("div", { className: "grid grid-cols-[repeat(2,minmax(0,1fr))] gap-1.5", children: [_jsx(AggPick, { value: line.agg, label: "Line roll-up", onChange: (agg) => onChange({ ...line, agg }) }), _jsx(FieldPick, { value: line.field, fields: isCountAgg(line.agg) ? fields : nums, domain: domain, label: "Line field", onChange: (field) => onChange({ ...line, field }) }), line.kind === 'value' && (_jsx(Input, { "aria-label": "Words after the number", className: "col-span-2", placeholder: "Words after the number (contracts)", value: line.suffix, maxLength: 30, onChange: (e) => onChange({ ...line, suffix: e.target.value }) }))] }))] }));
66
+ }
67
+ /**
68
+ * The Summary's cards in Edit widget: a grid to pick and drag, the domain's own cards to add or
69
+ * remove, and the picked card of the view's own: its main number (label, roll-up, field, rows,
70
+ * colour by sign) and its secondary lines.
71
+ */
72
+ export function SummaryCardsEditor({ domain, tiles, onChange, rows, fields, problems, one = false, }) {
73
+ const sensors = useSensors(useSensor(PointerSensor, dragOptions), useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 6 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
74
+ const builtIns = domain.tiles ?? [];
75
+ const [current, setCurrent] = useState(tiles.find(isOwnTile)?.key ?? tiles[0]?.key ?? null);
76
+ const nums = domain.fields.filter(isNumber);
77
+ const pickable = domain.fields;
78
+ const fieldOf = (k) => domain.fields.find((f) => f.key === k);
79
+ const i = tiles.findIndex((t) => t.key === current);
80
+ const picked = tiles[i];
81
+ const own = picked && isOwnTile(picked) ? picked : undefined;
82
+ const set = (p) => onChange(tiles.map((t, j) => (j === i && isOwnTile(t) ? { ...t, ...p } : t)));
83
+ const add = () => {
84
+ let n = tiles.length + 1;
85
+ while (tiles.some((t) => t.key === `tile_${n}`) || builtIns.some((w) => w.key === `tile_${n}`))
86
+ n++;
87
+ const key = `tile_${n}`;
88
+ const field = (nums.find((f) => f.amount) ?? nums[0])?.key ?? '';
89
+ onChange([
90
+ ...tiles,
91
+ { key, label: `Card ${n}`, field, agg: 'sum', filter: [], sign: false, lines: [] },
92
+ ]);
93
+ setCurrent(key);
94
+ };
95
+ const ownCount = tiles.filter(isOwnTile).length;
96
+ const off = builtIns.filter((w) => !tiles.some((t) => t.key === w.key));
97
+ const preview = (t) => {
98
+ if (!isOwnTile(t))
99
+ return { value: 'Built in', how: 'Built in' };
100
+ const figs = figuresOf(domain, t, rows, fields);
101
+ return {
102
+ value: figs ? _jsx(FigureText, { f: figs.main }) : '—',
103
+ how: `${AGG_LABELS[t.agg]} of ${fieldOf(t.field)?.label ?? t.field}`,
104
+ };
105
+ };
106
+ return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsx(DndContext, { sensors: sensors, collisionDetection: closestCenter, onDragEnd: (e) => {
107
+ const keys = tiles.map((t) => t.key);
108
+ const from = keys.indexOf(String(e.active.id));
109
+ const to = e.over ? keys.indexOf(String(e.over.id)) : -1;
110
+ if (from >= 0 && to >= 0 && from !== to)
111
+ onChange(arrayMove(tiles, from, to));
112
+ }, children: _jsx(SortableContext, { items: tiles.map((t) => t.key), strategy: rectSortingStrategy, children: _jsxs("div", { className: "grid grid-cols-3 gap-1.5", "data-testid": "summary-cards", children: [tiles.map((t, j) => {
113
+ const p = preview(t);
114
+ const label = isOwnTile(t)
115
+ ? t.label
116
+ : (builtIns.find((w) => w.key === t.key)?.label ?? t.key);
117
+ return (_jsx(CardChip, { id: t.key, selected: current === t.key, onSelect: () => setCurrent(t.key), label: label, value: p.value, how: p.how, problem: !!problems[j], domId: `summary-card-${j}` }, t.key));
118
+ }), takesFormulas(domain) && !(one && tiles.length) && (_jsxs(Button, { id: "summary-card-add", variant: "outline", className: "h-auto min-h-16 border-dashed font-normal text-muted-foreground", disabled: ownCount >= MAX_TILES, onClick: add, children: [_jsx(PlusIcon, {}), " Add card"] }))] }) }) }), off.length > 0 && (_jsxs("div", { className: "flex flex-wrap items-center gap-1.5", children: [_jsx("span", { className: "text-xs text-muted-foreground", children: "Built in:" }), off.map((w) => (_jsxs(Button, { variant: "outline", size: "sm", onClick: () => {
119
+ onChange(one ? [{ key: w.key }] : [...tiles, { key: w.key }]);
120
+ setCurrent(w.key);
121
+ }, children: [_jsx(PlusIcon, {}), " ", w.label] }, w.key)))] })), picked && !own && (_jsxs("div", { className: "flex items-center gap-2 rounded-lg border p-3", children: [_jsx(Badge, { variant: "secondary", className: "font-normal", children: "Built in" }), _jsxs("span", { className: "flex-1 text-sm", children: [builtIns.find((w) => w.key === picked.key)?.label ?? picked.key, " works out its own lines."] }), _jsx(IconButton, { icon: Trash2Icon, label: "Remove card", onClick: () => {
122
+ onChange(tiles.filter((_, j) => j !== i));
123
+ setCurrent(null);
124
+ } })] })), own && (_jsxs("div", { className: "flex min-w-0 flex-col gap-3 rounded-lg border p-3", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "flex-1 text-xs font-medium tracking-wide text-muted-foreground uppercase", children: "Main number" }), _jsx(LumenConfirm, { title: `Remove card ${own.label}?`, description: "It leaves this Summary when you save.", action: "Remove", onConfirm: () => {
125
+ onChange(tiles.filter((_, j) => j !== i));
126
+ setCurrent(null);
127
+ }, children: _jsx(IconButton, { icon: Trash2Icon, label: "Remove card", variant: "destructive" }) })] }), _jsxs("div", { className: "grid grid-cols-[minmax(0,1fr)] gap-1.5", children: [_jsx(Label, { htmlFor: "card-label", children: "Label" }), _jsx(Input, { id: "card-label", value: own.label, maxLength: 40, onChange: (e) => set({ label: e.target.value }) })] }), _jsxs("div", { className: "grid grid-cols-[repeat(2,minmax(0,1fr))] gap-1.5", children: [_jsx(AggPick, { value: own.agg, label: "Roll-up", onChange: (agg) => set({ agg }) }), _jsx(FieldPick, { value: own.field, fields: isCountAgg(own.agg) ? pickable : nums, domain: domain, label: "Field", onChange: (field) => set({ field }) }), _jsx(Input, { "aria-label": "Words after the number", className: "col-span-2", placeholder: "Words after the number (places)", value: own.suffix ?? '', maxLength: 30, onChange: (e) => set({ suffix: e.target.value }) })] }), _jsxs("div", { className: "grid grid-cols-[minmax(0,1fr)] gap-1.5", children: [_jsx(Label, { children: "Only rows where" }), _jsx(CardFilter, { rows: rows, fields: fields, tokens: own.filter, onChange: (filter) => set({ filter }) }, own.key)] }), _jsxs(Label, { className: "flex items-center gap-2 font-normal", children: [_jsx(Switch, { checked: own.sign, onCheckedChange: (sign) => set({ sign }) }), "Green or red by sign"] }), _jsx("span", { className: "text-xs font-medium tracking-wide text-muted-foreground uppercase", children: "Secondary lines" }), own.lines.map((l, j) => (_jsx(LineRow
128
+ // biome-ignore lint/suspicious/noArrayIndexKey: lines have no key of their own
129
+ , { line: l, nums: nums, fields: pickable, domain: domain, onChange: (next) => set({ lines: own.lines.map((x, k) => (k === j ? next : x)) }), onRemove: () => set({ lines: own.lines.filter((_, k) => k !== j) }) }, j))), _jsxs(Button, { variant: "ghost", size: "sm", className: "self-start", disabled: own.lines.length >= MAX_TILE_LINES, onClick: () => set({
130
+ lines: [
131
+ ...own.lines,
132
+ {
133
+ kind: 'pct',
134
+ field: (nums.find((f) => f.amount) ?? nums[0])?.key ?? '',
135
+ agg: 'sum',
136
+ },
137
+ ],
138
+ }), children: [_jsx(PlusIcon, {}), " Add line"] }), _jsx("div", { className: "rounded-lg bg-muted/50 p-2", children: _jsx(OwnTile, { domain: domain, tile: own, rows: rows, fields: fields }) }), problems[i] && (_jsx("p", { role: "alert", className: "text-xs text-destructive", children: problems[i] }))] }))] }));
139
+ }
@@ -1,7 +1,35 @@
1
- import { type CardStyle, type MeterKind, type ViewAgg, type ViewTile } from '@columenlabs/lumen-ui-types/views';
1
+ import { type CardStyle, type MeterKind, type ViewAgg, type ViewFormat, type ViewOwnTile, type ViewTile } from '@columenlabs/lumen-ui-types/views';
2
2
  import { type ReactNode } from 'react';
3
3
  import { type FilterField } from './lumen-filter.js';
4
- import { type ViewDomain, type ViewField } from './view-model.js';
4
+ import { type ViewCellValue, type ViewDomain, type ViewField } from './view-model.js';
5
+ /** One figure of a card: a value in its field's format, or plain text. */
6
+ export type Figure = {
7
+ kind: 'value';
8
+ value: ViewCellValue;
9
+ format: ViewFormat;
10
+ amount: boolean;
11
+ suffix?: string;
12
+ } | {
13
+ kind: 'text';
14
+ text: string;
15
+ };
16
+ /** A card's main number and lines over `rows` (the Summary's rows); null when its field is gone. */
17
+ export declare function figuresOf<T>(domain: ViewDomain<T>, t: ViewOwnTile, rows: T[], fields: FilterField<T>[]): {
18
+ main: Figure & {
19
+ kind: 'value';
20
+ };
21
+ lines: Figure[];
22
+ } | null;
23
+ export declare function FigureText({ f }: {
24
+ f: Figure;
25
+ }): import("react").JSX.Element;
26
+ /** A card of the view's own as the page shows it. */
27
+ export declare function OwnTile<T>({ domain, tile: t, rows, fields, }: {
28
+ domain: ViewDomain<T>;
29
+ tile: ViewOwnTile;
30
+ rows: T[];
31
+ fields: FilterField<T>[];
32
+ }): import("react").JSX.Element | null;
5
33
  /**
6
34
  * A LumenCard's number and lines: its frame shows the label, so the body is
7
35
  * the main number and its lines; a domain's own card keeps the app's drawing without its frame.
@@ -36,6 +64,7 @@ export declare function SummaryTiles<T>({ domain, tiles, rows, fields, tile, loa
36
64
  /** While the view's rows load, each card holds its place. */
37
65
  loading?: boolean;
38
66
  }): import("react").JSX.Element | null;
67
+ export declare const isNumber: <T>(f: ViewField<T>) => boolean;
39
68
  /**
40
69
  * The roll-up a card over `f` starts with, so any field makes a sensible number: text counts its
41
70
  * different values, a percent or weight takes its highest (shares never add up), the rest sum.
@@ -44,19 +73,3 @@ export declare function SummaryTiles<T>({ domain, tiles, rows, fields, tile, loa
44
73
  export declare function cardAgg<T>(f: ViewField<T> | undefined, agg?: ViewAgg): ViewAgg;
45
74
  /** What is wrong with each of the cards: a key, a label or a field; null where nothing. */
46
75
  export declare function summaryCardIssues(tiles: ViewTile[], fields: string[], domainTiles: string[]): (string | null)[];
47
- /**
48
- * The Summary's cards in Edit widget: a grid to pick and drag, the domain's own cards to add or
49
- * remove, and the picked card of the view's own: its main number (label, roll-up, field, rows,
50
- * colour by sign) and its secondary lines.
51
- */
52
- export declare function SummaryCardsEditor<T>({ domain, tiles, onChange, rows, fields, problems, one, }: {
53
- domain: ViewDomain<T>;
54
- tiles: ViewTile[];
55
- onChange: (tiles: ViewTile[]) => void;
56
- /** One card only (LumenCard): a built-in replaces it and there is no Add card. */
57
- one?: boolean;
58
- /** The Summary's rows, for the previews. */
59
- rows: T[];
60
- fields: FilterField<T>[];
61
- problems: (string | null)[];
62
- }): import("react").JSX.Element;
@@ -1,38 +1,17 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { MAX_TILE_LINES, isCountAgg, isOwnTile, meterTone, viewAggs, } from '@columenlabs/lumen-ui-types/views';
3
- // The Summary widget's cards:
4
- // each card is one main number and any secondary lines. A domain's own cards (Value, Day P&L) are
5
- // drawn by the app; a card of the view's own rolls one field or formula column up over the rows
6
- // the Summary shows, narrowed by its own filter, and its lines are other roll-ups, a percent of a
7
- // roll-up over all the Summary's rows, or plain text. `SummaryTiles` draws them; `SummaryCardsEditor`
8
- // is the cards part of the Summary's Edit widget. Everything is worked out here from the rows the
9
- // view already loaded.
10
- import { DndContext, KeyboardSensor, PointerSensor, TouchSensor, closestCenter, useSensor, useSensors, } from '@dnd-kit/core';
11
- import { SortableContext, arrayMove, rectSortingStrategy, sortableKeyboardCoordinates, useSortable, } from '@dnd-kit/sortable';
12
- import { CSS } from '@dnd-kit/utilities';
13
- import { PlusIcon, Trash2Icon, XIcon } from 'lucide-react';
14
- import { useEffect, useMemo, useState } from 'react';
15
- import { LumenConfirm } from './confirm.js';
16
- import { IconButton } from './icon-button.js';
2
+ import { isCountAgg, isOwnTile, meterTone, } from '@columenlabs/lumen-ui-types/views';
3
+ import { useMemo } from 'react';
17
4
  import { KIT } from './kit-config.js';
18
5
  import { lazyPart } from './lazy-part.js';
19
6
  import { cn } from './lib/utils.js';
20
- import { LumenTokenizedFilter, filterByTokens, useLumenFilter, } from './lumen-filter.js';
21
- import { LumenSelector } from './lumen-selector.js';
7
+ import { filterByTokens } from './lumen-filter.js';
22
8
  import { ViewValueCell } from './lumen-view.js';
23
9
  import { Tile } from './tile.js';
24
- import { Badge } from './ui/badge.js';
25
- import { Button } from './ui/button.js';
26
- import { Input } from './ui/input.js';
27
- import { Label } from './ui/label.js';
28
10
  import { Progress } from './ui/progress.js';
29
11
  import { Skeleton } from './ui/skeleton.js';
30
- import { Switch } from './ui/switch.js';
31
- import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.js';
32
- import { AGG_LABELS, MAX_TILES, fieldOptions } from './view-formulas.js';
33
- import { computedOf, rollUp, takesFormulas, } from './view-model.js';
12
+ import { computedOf, rollUp, } from './view-model.js';
34
13
  /** A card's main number and lines over `rows` (the Summary's rows); null when its field is gone. */
35
- function figuresOf(domain, t, rows, fields) {
14
+ export function figuresOf(domain, t, rows, fields) {
36
15
  const computed = computedOf(domain, rows, null);
37
16
  const field = (k) => domain.fields.find((f) => f.key === k);
38
17
  const roll = (f, agg, rs) => rollUp(rs.map((r) => computed.value(f, r)), agg);
@@ -74,13 +53,13 @@ function figuresOf(domain, t, rows, fields) {
74
53
  });
75
54
  return { main, lines };
76
55
  }
77
- function FigureText({ f }) {
56
+ export function FigureText({ f }) {
78
57
  if (f.kind === 'text')
79
58
  return _jsx(_Fragment, { children: f.text });
80
59
  return (_jsxs(_Fragment, { children: [_jsx(ViewValueCell, { value: f.value, format: f.format, amount: f.amount }), f.suffix ? ` ${f.suffix}` : null] }));
81
60
  }
82
61
  /** A card of the view's own as the page shows it. */
83
- function OwnTile({ domain, tile: t, rows, fields, }) {
62
+ export function OwnTile({ domain, tile: t, rows, fields, }) {
84
63
  const figs = useMemo(() => figuresOf(domain, t, rows, fields), [domain, t, rows, fields]);
85
64
  if (!figs)
86
65
  return null;
@@ -135,10 +114,9 @@ export function SummaryTiles({ domain, tiles, rows, fields, tile, loading, }) {
135
114
  // Each tile sits in a `contents` holder, so the odd last one's span goes on what it holds.
136
115
  '[&>*:last-child:nth-child(odd)>*]:col-span-2 md:[&>*:last-child:nth-child(odd)>*]:col-span-1'), children: shown.map((t) => (_jsx("div", { className: "contents", children: isOwnTile(t) ? (_jsx(OwnTile, { domain: domain, tile: t, rows: rows, fields: fields })) : (tile?.(t.key, rows)) }, t.key))) }));
137
116
  }
138
- // ── Edit ───────────────────────────────────────────────────────────────────────────────────────
139
- const dragOptions = { activationConstraint: { distance: 4 } };
117
+ // ── Card rules (the editor lives in summary-cards-editor.tsx) ─────────────────────────────────
140
118
  // A number the page draws its own way (a meter, a unit) still reads its raw value, so it rolls up.
141
- const isNumber = (f) => f.format !== 'text';
119
+ export const isNumber = (f) => f.format !== 'text';
142
120
  /**
143
121
  * The roll-up a card over `f` starts with, so any field makes a sensible number: text counts its
144
122
  * different values, a percent or weight takes its highest (shares never add up), the rest sum.
@@ -169,118 +147,3 @@ export function summaryCardIssues(tiles, fields, domainTiles) {
169
147
  return null;
170
148
  });
171
149
  }
172
- function FieldPick({ value, fields, domain, label, onChange, }) {
173
- return (_jsx(LumenSelector, { label: label, size: "sm", className: "w-full min-w-0", placeholder: "Pick a field", options: fieldOptions(fields, domain), value: value, onChange: onChange }));
174
- }
175
- function AggPick({ value, label, onChange, }) {
176
- return (_jsx(LumenSelector, { label: label, size: "sm", className: "w-full min-w-0", options: viewAggs.map((a) => ({ value: a, label: AGG_LABELS[a] })), value: value, onChange: onChange }));
177
- }
178
- /** A card in the editor's grid: its label, its value now and where it comes from. */
179
- function CardChip({ id, selected, onSelect, label, value, how, problem, domId, }) {
180
- const s = useSortable({ id });
181
- return (_jsxs(Button, { ref: s.setNodeRef, id: domId, variant: "outline", style: { transform: CSS.Transform.toString(s.transform), transition: s.transition }, className: cn('h-auto min-h-16 min-w-0 flex-col items-start justify-start gap-0.5 rounded-lg px-2.5 py-2 text-left font-normal whitespace-normal', selected && 'border-primary ring-3 ring-primary/15', problem && 'border-destructive', s.isDragging && 'z-20 shadow-md'), ...s.attributes, ...s.listeners, "aria-pressed": selected, onClick: onSelect, children: [_jsx("span", { className: "w-full truncate text-xs text-muted-foreground", children: label }), _jsx("span", { className: "num w-full truncate text-sm font-semibold", children: value }), _jsx("span", { className: "w-full truncate text-[0.7rem] text-muted-foreground", children: how })] }));
182
- }
183
- /** A card's own rows filter, kept in step with the card. */
184
- function CardFilter({ rows, fields, tokens, onChange, }) {
185
- const filter = useLumenFilter(rows, fields, { initial: tokens });
186
- const next = JSON.stringify(filter.tokens);
187
- // biome-ignore lint/correctness/useExhaustiveDependencies: reports the box's tokens when they change
188
- useEffect(() => {
189
- if (next !== JSON.stringify(tokens))
190
- onChange(filter.tokens);
191
- }, [next]);
192
- return (_jsx(LumenTokenizedFilter, { filter: filter, label: "Card filter", placeholder: "All the Summary's rows" }));
193
- }
194
- const LINE_KINDS = [
195
- { value: 'value', label: 'Number' },
196
- { value: 'pct', label: '% of' },
197
- { value: 'text', label: 'Text' },
198
- ];
199
- function LineRow({ line, nums, fields, domain, onChange, onRemove, }) {
200
- const first = nums[0]?.key ?? '';
201
- const as = (kind) => kind === 'text'
202
- ? { kind, text: 'Note' }
203
- : kind === 'pct'
204
- ? { kind, field: line.kind !== 'text' ? line.field : first, agg: 'sum' }
205
- : {
206
- kind,
207
- field: line.kind !== 'text' ? line.field : first,
208
- agg: 'sum',
209
- filter: [],
210
- suffix: '',
211
- };
212
- return (_jsxs("div", { className: "flex flex-col gap-1.5 rounded-md border p-2", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(ToggleGroup, { type: "single", size: "sm", variant: "outline", spacing: 0, value: line.kind, onValueChange: (v) => v && onChange(as(v)), "aria-label": "Line kind", children: LINE_KINDS.map((k) => (_jsx(ToggleGroupItem, { value: k.value, className: "px-2 text-xs", children: k.label }, k.value))) }), _jsx(IconButton, { icon: XIcon, label: "Remove line", className: "ml-auto", onClick: onRemove })] }), line.kind === 'text' ? (_jsx(Input, { "aria-label": "Line text", value: line.text, maxLength: 60, onChange: (e) => onChange({ ...line, text: e.target.value }) })) : (_jsxs("div", { className: "grid grid-cols-[repeat(2,minmax(0,1fr))] gap-1.5", children: [_jsx(AggPick, { value: line.agg, label: "Line roll-up", onChange: (agg) => onChange({ ...line, agg }) }), _jsx(FieldPick, { value: line.field, fields: isCountAgg(line.agg) ? fields : nums, domain: domain, label: "Line field", onChange: (field) => onChange({ ...line, field }) }), line.kind === 'value' && (_jsx(Input, { "aria-label": "Words after the number", className: "col-span-2", placeholder: "Words after the number (contracts)", value: line.suffix, maxLength: 30, onChange: (e) => onChange({ ...line, suffix: e.target.value }) }))] }))] }));
213
- }
214
- /**
215
- * The Summary's cards in Edit widget: a grid to pick and drag, the domain's own cards to add or
216
- * remove, and the picked card of the view's own: its main number (label, roll-up, field, rows,
217
- * colour by sign) and its secondary lines.
218
- */
219
- export function SummaryCardsEditor({ domain, tiles, onChange, rows, fields, problems, one = false, }) {
220
- const sensors = useSensors(useSensor(PointerSensor, dragOptions), useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 6 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
221
- const builtIns = domain.tiles ?? [];
222
- const [current, setCurrent] = useState(tiles.find(isOwnTile)?.key ?? tiles[0]?.key ?? null);
223
- const nums = domain.fields.filter(isNumber);
224
- const pickable = domain.fields;
225
- const fieldOf = (k) => domain.fields.find((f) => f.key === k);
226
- const i = tiles.findIndex((t) => t.key === current);
227
- const picked = tiles[i];
228
- const own = picked && isOwnTile(picked) ? picked : undefined;
229
- const set = (p) => onChange(tiles.map((t, j) => (j === i && isOwnTile(t) ? { ...t, ...p } : t)));
230
- const add = () => {
231
- let n = tiles.length + 1;
232
- while (tiles.some((t) => t.key === `tile_${n}`) || builtIns.some((w) => w.key === `tile_${n}`))
233
- n++;
234
- const key = `tile_${n}`;
235
- const field = (nums.find((f) => f.amount) ?? nums[0])?.key ?? '';
236
- onChange([
237
- ...tiles,
238
- { key, label: `Card ${n}`, field, agg: 'sum', filter: [], sign: false, lines: [] },
239
- ]);
240
- setCurrent(key);
241
- };
242
- const ownCount = tiles.filter(isOwnTile).length;
243
- const off = builtIns.filter((w) => !tiles.some((t) => t.key === w.key));
244
- const preview = (t) => {
245
- if (!isOwnTile(t))
246
- return { value: 'Built in', how: 'Built in' };
247
- const figs = figuresOf(domain, t, rows, fields);
248
- return {
249
- value: figs ? _jsx(FigureText, { f: figs.main }) : '—',
250
- how: `${AGG_LABELS[t.agg]} of ${fieldOf(t.field)?.label ?? t.field}`,
251
- };
252
- };
253
- return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsx(DndContext, { sensors: sensors, collisionDetection: closestCenter, onDragEnd: (e) => {
254
- const keys = tiles.map((t) => t.key);
255
- const from = keys.indexOf(String(e.active.id));
256
- const to = e.over ? keys.indexOf(String(e.over.id)) : -1;
257
- if (from >= 0 && to >= 0 && from !== to)
258
- onChange(arrayMove(tiles, from, to));
259
- }, children: _jsx(SortableContext, { items: tiles.map((t) => t.key), strategy: rectSortingStrategy, children: _jsxs("div", { className: "grid grid-cols-3 gap-1.5", "data-testid": "summary-cards", children: [tiles.map((t, j) => {
260
- const p = preview(t);
261
- const label = isOwnTile(t)
262
- ? t.label
263
- : (builtIns.find((w) => w.key === t.key)?.label ?? t.key);
264
- return (_jsx(CardChip, { id: t.key, selected: current === t.key, onSelect: () => setCurrent(t.key), label: label, value: p.value, how: p.how, problem: !!problems[j], domId: `summary-card-${j}` }, t.key));
265
- }), takesFormulas(domain) && !(one && tiles.length) && (_jsxs(Button, { id: "summary-card-add", variant: "outline", className: "h-auto min-h-16 border-dashed font-normal text-muted-foreground", disabled: ownCount >= MAX_TILES, onClick: add, children: [_jsx(PlusIcon, {}), " Add card"] }))] }) }) }), off.length > 0 && (_jsxs("div", { className: "flex flex-wrap items-center gap-1.5", children: [_jsx("span", { className: "text-xs text-muted-foreground", children: "Built in:" }), off.map((w) => (_jsxs(Button, { variant: "outline", size: "sm", onClick: () => {
266
- onChange(one ? [{ key: w.key }] : [...tiles, { key: w.key }]);
267
- setCurrent(w.key);
268
- }, children: [_jsx(PlusIcon, {}), " ", w.label] }, w.key)))] })), picked && !own && (_jsxs("div", { className: "flex items-center gap-2 rounded-lg border p-3", children: [_jsx(Badge, { variant: "secondary", className: "font-normal", children: "Built in" }), _jsxs("span", { className: "flex-1 text-sm", children: [builtIns.find((w) => w.key === picked.key)?.label ?? picked.key, " works out its own lines."] }), _jsx(IconButton, { icon: Trash2Icon, label: "Remove card", onClick: () => {
269
- onChange(tiles.filter((_, j) => j !== i));
270
- setCurrent(null);
271
- } })] })), own && (_jsxs("div", { className: "flex min-w-0 flex-col gap-3 rounded-lg border p-3", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "flex-1 text-xs font-medium tracking-wide text-muted-foreground uppercase", children: "Main number" }), _jsx(LumenConfirm, { title: `Remove card ${own.label}?`, description: "It leaves this Summary when you save.", action: "Remove", onConfirm: () => {
272
- onChange(tiles.filter((_, j) => j !== i));
273
- setCurrent(null);
274
- }, children: _jsx(IconButton, { icon: Trash2Icon, label: "Remove card", variant: "destructive" }) })] }), _jsxs("div", { className: "grid grid-cols-[minmax(0,1fr)] gap-1.5", children: [_jsx(Label, { htmlFor: "card-label", children: "Label" }), _jsx(Input, { id: "card-label", value: own.label, maxLength: 40, onChange: (e) => set({ label: e.target.value }) })] }), _jsxs("div", { className: "grid grid-cols-[repeat(2,minmax(0,1fr))] gap-1.5", children: [_jsx(AggPick, { value: own.agg, label: "Roll-up", onChange: (agg) => set({ agg }) }), _jsx(FieldPick, { value: own.field, fields: isCountAgg(own.agg) ? pickable : nums, domain: domain, label: "Field", onChange: (field) => set({ field }) }), _jsx(Input, { "aria-label": "Words after the number", className: "col-span-2", placeholder: "Words after the number (places)", value: own.suffix ?? '', maxLength: 30, onChange: (e) => set({ suffix: e.target.value }) })] }), _jsxs("div", { className: "grid grid-cols-[minmax(0,1fr)] gap-1.5", children: [_jsx(Label, { children: "Only rows where" }), _jsx(CardFilter, { rows: rows, fields: fields, tokens: own.filter, onChange: (filter) => set({ filter }) }, own.key)] }), _jsxs(Label, { className: "flex items-center gap-2 font-normal", children: [_jsx(Switch, { checked: own.sign, onCheckedChange: (sign) => set({ sign }) }), "Green or red by sign"] }), _jsx("span", { className: "text-xs font-medium tracking-wide text-muted-foreground uppercase", children: "Secondary lines" }), own.lines.map((l, j) => (_jsx(LineRow
275
- // biome-ignore lint/suspicious/noArrayIndexKey: lines have no key of their own
276
- , { line: l, nums: nums, fields: pickable, domain: domain, onChange: (next) => set({ lines: own.lines.map((x, k) => (k === j ? next : x)) }), onRemove: () => set({ lines: own.lines.filter((_, k) => k !== j) }) }, j))), _jsxs(Button, { variant: "ghost", size: "sm", className: "self-start", disabled: own.lines.length >= MAX_TILE_LINES, onClick: () => set({
277
- lines: [
278
- ...own.lines,
279
- {
280
- kind: 'pct',
281
- field: (nums.find((f) => f.amount) ?? nums[0])?.key ?? '',
282
- agg: 'sum',
283
- },
284
- ],
285
- }), children: [_jsx(PlusIcon, {}), " Add line"] }), _jsx("div", { className: "rounded-lg bg-muted/50 p-2", children: _jsx(OwnTile, { domain: domain, tile: own, rows: rows, fields: fields }) }), problems[i] && (_jsx("p", { role: "alert", className: "text-xs text-destructive", children: problems[i] }))] }))] }));
286
- }
package/dist/theme.css CHANGED
@@ -414,6 +414,82 @@
414
414
  left: 100%;
415
415
  }
416
416
 
417
+ /* A LumenView table on a narrow phone (`data-cards`): each row is a card, so nothing runs off
418
+ the right edge. Line 1: the row's name (shortens) and one figure (`data-card="meta"`); line 2:
419
+ the other values, small and muted. Numbers, dates and their units keep their width; only text
420
+ shortens, in an ellipsis. Group, Total and More rows span the card's width. */
421
+ @media (max-width: 639px) {
422
+ table[data-cards],
423
+ table[data-cards] > tbody,
424
+ table[data-cards] > tfoot {
425
+ display: block;
426
+ width: 100%;
427
+ }
428
+ /* A wide table widens its card (the page scrolls sideways); a card table fits the view. */
429
+ table[data-cards] {
430
+ max-width: calc(var(--view-w, 100%) - 2px);
431
+ }
432
+ table[data-cards] > thead {
433
+ display: none;
434
+ }
435
+ table[data-cards] tr {
436
+ display: flex;
437
+ flex-wrap: wrap;
438
+ align-items: baseline;
439
+ column-gap: 0.75rem;
440
+ row-gap: 0.125rem;
441
+ padding: 0.5rem var(--cell-x);
442
+ }
443
+ table[data-cards] td {
444
+ padding: 0 !important;
445
+ width: auto !important;
446
+ min-width: 0;
447
+ }
448
+ table[data-cards] td[colspan] {
449
+ flex: 1 1 100%;
450
+ }
451
+ table[data-cards] td:not([data-card]):not([colspan]) {
452
+ display: none;
453
+ }
454
+ table[data-cards] td[data-card="select"] {
455
+ order: 0;
456
+ flex: none;
457
+ align-self: center;
458
+ }
459
+ table[data-cards] td[data-card="lead"] {
460
+ order: 1;
461
+ flex: 1 1 0;
462
+ overflow: hidden;
463
+ font-weight: 500;
464
+ }
465
+ table[data-cards] td[data-card="meta"] {
466
+ order: 2;
467
+ flex: none;
468
+ }
469
+ table[data-cards] tr:has(> td[data-card="lead"])::after {
470
+ content: "";
471
+ order: 3;
472
+ flex-basis: 100%;
473
+ }
474
+ table[data-cards] td:is([data-card="num"], [data-card="text"]) {
475
+ order: 4;
476
+ font-size: 0.8125rem;
477
+ color: var(--muted-foreground);
478
+ }
479
+ table[data-cards] td[data-card="num"] {
480
+ flex: none;
481
+ }
482
+ table[data-cards] td[data-card="text"] {
483
+ flex: 0 1 auto;
484
+ overflow: hidden;
485
+ text-overflow: ellipsis;
486
+ }
487
+ table[data-cards] [data-slot="unit-value-unit"] {
488
+ position: static;
489
+ margin-left: 0.25em;
490
+ }
491
+ }
492
+
417
493
  @layer utilities {
418
494
  .bg-gap-hatch {
419
495
  background: repeating-linear-gradient(135deg, var(--gap) 0 3px, var(--gap-soft) 3px 6px);
@@ -10,7 +10,7 @@ import { CONNECTED_COUNT, VIEW_CARD_KIND_NAMES, WIDGET_STYLES, cardsOf, connecte
10
10
  import { DndContext, KeyboardSensor, PointerSensor, TouchSensor, closestCenter, useSensor, useSensors, } from '@dnd-kit/core';
11
11
  import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable';
12
12
  import { CSS } from '@dnd-kit/utilities';
13
- import { ArrowDownIcon, ArrowLeftIcon, ArrowUpIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, Columns3Icon, CopyIcon, DatabaseIcon, EllipsisIcon, FilterIcon, GripVerticalIcon, LayoutDashboardIcon, Link2Icon, MoveHorizontalIcon, PencilIcon, PlusIcon, ReplaceIcon, RotateCcwIcon, SearchIcon, ShapesIcon, ShrinkIcon, SigmaIcon, SlidersHorizontalIcon, SmileIcon, SparklesIcon, StarIcon, TableIcon, ToggleRightIcon, Trash2Icon, Unlink2Icon, UnlinkIcon, XIcon, } from 'lucide-react';
13
+ import { ArrowDownIcon, ArrowUpIcon, CheckIcon, ChevronRightIcon, Columns3Icon, CopyIcon, DatabaseIcon, EllipsisIcon, FilterIcon, GripVerticalIcon, LayoutDashboardIcon, Link2Icon, MoveHorizontalIcon, PencilIcon, PlusIcon, ReplaceIcon, SearchIcon, ShapesIcon, ShrinkIcon, SigmaIcon, SlidersHorizontalIcon, SparklesIcon, StarIcon, ToggleRightIcon, Trash2Icon, Unlink2Icon, UnlinkIcon, XIcon, } from 'lucide-react';
14
14
  import { useEffect, useMemo, useRef, useState } from 'react';
15
15
  import { AddFormulaDialog } from './add-column.js';
16
16
  import { useAssistant, useAssistantName } from './assistant-context.js';
@@ -35,18 +35,15 @@ import { Button } from './ui/button.js';
35
35
  import { Checkbox } from './ui/checkbox.js';
36
36
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible.js';
37
37
  import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from './ui/dialog.js';
38
- import { Input } from './ui/input.js';
39
38
  import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from './ui/input-group.js';
40
39
  import { Label } from './ui/label.js';
41
- import { Popover, PopoverContent, PopoverTrigger } from './ui/popover.js';
42
- import { Separator } from './ui/separator.js';
43
40
  import { Switch } from './ui/switch.js';
44
41
  import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs.js';
45
42
  import { Textarea } from './ui/textarea.js';
46
43
  import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.js';
47
44
  import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip.js';
48
45
  import { AGG_LABELS, FORMAT_LABELS, FormatChip, FormulaForm, MAX_FORMULAS, fieldOptions, formatOptions, formulaIssueTarget, formulaIssues, formulaOptions, } from './view-formulas.js';
49
- import { VIEW_ICONS, shapeIcon, viewIcon } from './view-icons.js';
46
+ import { shapeIcon } from './view-icons.js';
50
47
  import { FORMULAS_GROUP, canFilter, chipsOf, formulaBlanks, formulasOf, groupOf, hiddenOf, subgroupLabel, subgroupOf, subgroupsOf, summaryCash, takesFormulas, tilesOf, totalAggOf, withViewFields, } from './view-model.js';
51
48
  import { WidgetControlsEditor } from './widget-controls.js';
52
49
  import { PAGE_SOURCE, useWidgetDataOptions } from './widget-data.js';
package/dist/whats-new.js CHANGED
@@ -8,6 +8,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
8
8
  // takeaway carries a chip of the app it is about (`apps` on the takeaway; none: everyone), and a
9
9
  // release where one app has `GROUP_AT` or more is split into one sub-section per app.
10
10
  import { useInfiniteQuery } from '@tanstack/react-query';
11
+ import { SparklesIcon } from 'lucide-react';
11
12
  import { useState } from 'react';
12
13
  import Markdown from 'react-markdown';
13
14
  import { DrawerSection } from './drawer-section.js';
@@ -57,7 +58,7 @@ export function WhatsNew({ api, apps, everyone = 'Global', }) {
57
58
  const releases = q.data.pages.flatMap((p) => p ?? []);
58
59
  const latest = releases[0];
59
60
  const day = (at) => new Date(at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone });
60
- return (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", size: "sm", className: "num h-6 px-1.5 text-muted-foreground text-xs", onClick: () => setOpen(true), title: "What's new", children: latest?.tag ?? "What's new" }), _jsxs(LumenDrawer, { open: open, onClose: () => setOpen(false), title: "What's new", description: "Key takeaways of each release, newest first", footerStart: releases.length ? `${releases.length} releases` : undefined, footer: _jsxs(_Fragment, { children: [q.hasNextPage && (_jsx(Button, { variant: "outline", size: "sm", disabled: q.isFetchingNextPage, onClick: () => q.fetchNextPage(), children: "Show more" })), _jsx(Button, { size: "sm", onClick: () => setOpen(false), children: "Close" })] }), children: [!releases.length && (_jsx("p", { className: "py-2 text-muted-foreground text-sm", children: "No releases yet. Each release's key takeaways show here once it ships." })), releases.map((r, i) => (_jsx(DrawerSection, { title: r.tag, hint: day(r.published_at), defaultOpen: i === 0, children: r.takeaways.length ? (_jsx(Notes, { takeaways: r.takeaways, apps: apps, everyone: everyone })) : (_jsx("p", { className: "py-1 pl-1 text-muted-foreground text-sm", children: "Nothing new for you in this one." })) }, r.tag)))] })] }));
61
+ return (_jsxs(_Fragment, { children: [_jsxs(Button, { variant: "ghost", size: "sm", className: "h-6 gap-1 px-1.5 text-muted-foreground text-xs", onClick: () => setOpen(true), title: latest ? `What's new · ${latest.tag}` : "What's new", "aria-label": "What's new", children: [_jsx(SparklesIcon, { className: "size-3.5" }), _jsx("span", { className: "max-md:hidden", children: "What's new" })] }), _jsxs(LumenDrawer, { open: open, onClose: () => setOpen(false), title: "What's new", description: "Key takeaways of each release, newest first", footerStart: releases.length ? `${releases.length} releases` : undefined, footer: _jsxs(_Fragment, { children: [q.hasNextPage && (_jsx(Button, { variant: "outline", size: "sm", disabled: q.isFetchingNextPage, onClick: () => q.fetchNextPage(), children: "Show more" })), _jsx(Button, { size: "sm", onClick: () => setOpen(false), children: "Close" })] }), children: [!releases.length && (_jsx("p", { className: "py-2 text-muted-foreground text-sm", children: "No releases yet. Each release's key takeaways show here once it ships." })), releases.map((r, i) => (_jsx(DrawerSection, { title: r.tag, hint: day(r.published_at), defaultOpen: i === 0, children: r.takeaways.length ? (_jsx(Notes, { takeaways: r.takeaways, apps: apps, everyone: everyone })) : (_jsx("p", { className: "py-1 pl-1 text-muted-foreground text-sm", children: "Nothing new for you in this one." })) }, r.tag)))] })] }));
61
62
  }
62
63
  /** One release's takeaways: a flat list, or one sub-section per app once one app has many. */
63
64
  function Notes({ takeaways, apps, everyone, }) {
@@ -1,6 +1,8 @@
1
1
  import { type ViewWidget } from '@columenlabs/lumen-ui-types/views';
2
2
  import { type ReactNode } from 'react';
3
+ import { dateFields } from './date-fields.js';
3
4
  import type { FilterField } from './lumen-filter.js';
5
+ export { dateFields };
4
6
  import type { ViewDomain, ViewField } from './view-model.js';
5
7
  /** A widget the editor draws its own parts for: anything but a table of the rows. */
6
8
  export type WidgetDraft = Exclude<ViewWidget, {
@@ -14,8 +16,6 @@ export declare function EditorRow({ label, children }: {
14
16
  }): import("react").JSX.Element;
15
17
  export declare const isTextField: <T>(f: ViewField<T>) => boolean;
16
18
  export declare const isNumberField: <T>(f: ViewField<T>) => boolean;
17
- /** A trend's date candidates: text fields, the date-like ones first. */
18
- export declare const dateFields: <T>(domain: ViewDomain<T>) => ViewField<T>[];
19
19
  /**
20
20
  * One place a widget takes fields: what it takes (`accepts`, `why` when a field does not fit),
21
21
  * how many (`max`), the keys in it now and the widget with other keys. A table's one slot is its
@@ -9,13 +9,16 @@ import { OUTLOOK_INDEXES, marketWindows, } from '@columenlabs/lumen-ui-types/mar
9
9
  import { ALLOCATION_MAX, isOwnTile, isRowCard, isTableCard, trendBuckets, widgetStyleId, } from '@columenlabs/lumen-ui-types/views';
10
10
  import { ArrowDownIcon, ArrowUpIcon } from 'lucide-react';
11
11
  import { useState } from 'react';
12
+ import { dateFields } from './date-fields.js';
12
13
  import { LumenSelector } from './lumen-selector.js';
13
- import { SummaryCardsEditor, cardAgg, summaryCardIssues } from './summary-cards.js';
14
+ import { cardAgg, summaryCardIssues } from './summary-cards.js';
15
+ import { SummaryCardsEditor } from './summary-cards-editor.js';
14
16
  import { Input } from './ui/input.js';
15
17
  import { Label } from './ui/label.js';
16
18
  import { Switch } from './ui/switch.js';
17
19
  import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.js';
18
20
  import { MAX_TILES, fieldOptions } from './view-formulas.js';
21
+ export { dateFields };
19
22
  /** A label beside its control, the same in every section of Edit widget. */
20
23
  export function EditorRow({ label, children }) {
21
24
  return (_jsxs("div", { className: "grid grid-cols-[5rem_minmax(0,1fr)] items-center gap-2", children: [_jsx("span", { className: "text-sm text-muted-foreground", children: label }), _jsx("div", { className: "min-w-0", children: children })] }));
@@ -23,16 +26,6 @@ export function EditorRow({ label, children }) {
23
26
  export const isTextField = (f) => f.format === 'text' && !f.render;
24
27
  // A number the page draws its own way (a meter, a unit) still reads its raw value, so it rolls up.
25
28
  export const isNumberField = (f) => f.format !== 'text';
26
- const DATE_LIKE = /(^|_)(date|day|at|time|month|week|year)($|_)/i;
27
- /** A trend's date candidates: text fields, the date-like ones first. */
28
- export const dateFields = (domain) => {
29
- // A rendered text field still reads its raw value (a report date drawn as Tue, Oct 20).
30
- const texts = domain.fields.filter((f) => f.format === 'text');
31
- return [
32
- ...texts.filter((f) => DATE_LIKE.test(f.key)),
33
- ...texts.filter((f) => !DATE_LIKE.test(f.key)),
34
- ];
35
- };
36
29
  /** The most columns a Top N shows. */
37
30
  const TOP_N_COLUMNS = 8;
38
31
  /** How many values a trend draws, a spark or tracker one. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.102",
3
+ "version": "0.1.104",
4
4
  "description": "Lumen: shared React components (frame, views, widgets, charts, drawers).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,7 +49,7 @@
49
49
  "@codemirror/lang-javascript": "6.2.5",
50
50
  "@codemirror/lang-json": "6.0.2",
51
51
  "@codemirror/lang-sql": "6.10.0",
52
- "@columenlabs/lumen-ui-types": "0.1.102",
52
+ "@columenlabs/lumen-ui-types": "0.1.104",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",