@columenlabs/lumen-ui 0.1.171 → 0.1.173

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/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export { LumenLayout, type LumenLayoutCard, type LumenLayoutProps, type LumenLay
3
3
  export { LumenViewCard, type LumenViewCardProps } from './lumen-view-card.js';
4
4
  export { LumenSelector, LumenSelectorList, type LumenSelectorListHandle, type LumenSelectorProps, type LumenOption, type LumenOptionGroup, SelectorRowContent, } from './lumen-selector.js';
5
5
  export { ColorChip, ColorDot, colorIcon } from './color-dot.js';
6
+ export { TagChips, tagColor, tagIcon } from './tag-chips.js';
6
7
  export { EditCell, EditInputCell } from './view-edit-cell.js';
7
8
  export { type CardOnScreen, cardsOnScreen, kindName, onScreen, pointAt, pointAtCard, type PointedAt, type SectionOnScreen, sectionsOnScreen, spotlight, targetEl, textOf, } from './spotlight.js';
8
9
  export { LinkDot, LinkPicker, type LinkColor, linkVar } from './lumen-links.js';
@@ -45,7 +46,7 @@ export { RenameField, type RenameFieldProps } from './rename-field.js';
45
46
  export { LumenStackedList, type LumenStackedListProps, LumenStackedRow, type LumenStackedRowProps, type StackedColumn, } from './lumen-stacked-list.js';
46
47
  export { ResetButton, type ResetButtonProps } from './reset-button.js';
47
48
  export { type ChartBand, type ChartMarker, GrowthChart, KEY_DATE_KINDS, KeyDateChips, type KeyDateKind, type KeyDateMarker, PALETTE, type Scale, type Series, benchColour, keyDateMarkers, paletteAt, } from './growth-chart.js';
48
- export { FoldGroup, NavGroup, type NavItem, type SectionItem, type SectionSubItem, SidebarSection, } from './nav.js';
49
+ export { FoldGroup, LumenTabBar, NavGroup, type NavItem, type SectionItem, type SectionSubItem, SidebarSection, } from './nav.js';
49
50
  export { type OverviewPageProps, type OverviewStage, type OverviewTone, type OverviewWidget, OverviewList, OverviewPage, ToneDot, } from './overview.js';
50
51
  export { type Crumb, IconButton, type IconButtonProps, Page, PageBody, PageHeader, type PageHeaderProps, } from './page.js';
51
52
  export { DEFAULT_PREFS, deviceTimeZone, type Prefs, TIME_ZONES, type Theme, type TimeZoneId, getPrefs, resolvedTheme, setPref, timeZoneLabel, usePrefs, zoneAbbr, ASSISTANT_WIDTH, } from './prefs.js';
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ export { LumenLayout, domainKeys, followRows, } from './lumen-layout.js';
10
10
  export { LumenViewCard } from './lumen-view-card.js';
11
11
  export { LumenSelector, LumenSelectorList, SelectorRowContent, } from './lumen-selector.js';
12
12
  export { ColorChip, ColorDot, colorIcon } from './color-dot.js';
13
+ export { TagChips, tagColor, tagIcon } from './tag-chips.js';
13
14
  export { EditCell, EditInputCell } from './view-edit-cell.js';
14
15
  export { cardsOnScreen, kindName, onScreen, pointAt, pointAtCard, sectionsOnScreen, spotlight, targetEl, textOf, } from './spotlight.js';
15
16
  export { LinkDot, LinkPicker, linkVar } from './lumen-links.js';
@@ -52,7 +53,7 @@ export { RenameField } from './rename-field.js';
52
53
  export { LumenStackedList, LumenStackedRow, } from './lumen-stacked-list.js';
53
54
  export { ResetButton } from './reset-button.js';
54
55
  export { GrowthChart, KEY_DATE_KINDS, KeyDateChips, PALETTE, benchColour, keyDateMarkers, paletteAt, } from './growth-chart.js';
55
- export { FoldGroup, NavGroup, SidebarSection, } from './nav.js';
56
+ export { FoldGroup, LumenTabBar, NavGroup, SidebarSection, } from './nav.js';
56
57
  export { OverviewList, OverviewPage, ToneDot, } from './overview.js';
57
58
  export { IconButton, Page, PageBody, PageHeader, } from './page.js';
58
59
  export { DEFAULT_PREFS, deviceTimeZone, TIME_ZONES, getPrefs, resolvedTheme, setPref, timeZoneLabel, usePrefs, zoneAbbr, ASSISTANT_WIDTH, } from './prefs.js';
@@ -29,6 +29,7 @@ import { MAX_COMPARE, useSymbolChart } from './market/lumen-chart.js';
29
29
  import { LumenPageFooter, LumenPager } from './pager.js';
30
30
  import { maskAmount, useAmountsHidden } from './shell.js';
31
31
  import { useSymbolSheet } from './symbol-sheet-context.js';
32
+ import { TagChips, tagColor, tagIcon } from './tag-chips.js';
32
33
  import { Badge } from './ui/badge.js';
33
34
  import { Button } from './ui/button.js';
34
35
  import { Checkbox } from './ui/checkbox.js';
@@ -66,6 +67,17 @@ export function mergeRows(domain, rows, groupBy) {
66
67
  const MINUS = '−';
67
68
  /** Text cells longer than this many characters are cut to one line with a tooltip. */
68
69
  const LONG_TEXT = 48;
70
+ /** A tags field's tags for a row: its `values`, else its text split on commas. */
71
+ function tagsOf(f, row, v) {
72
+ if (f.values)
73
+ return f.values(row);
74
+ return typeof v === 'string'
75
+ ? v
76
+ .split(',')
77
+ .map((t) => t.trim())
78
+ .filter(Boolean)
79
+ : [];
80
+ }
69
81
  /** Rows of a view the assistant sees with each message. */
70
82
  const ASSISTANT_ROWS = 10;
71
83
  const formatter = (digits) => new Intl.NumberFormat('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits });
@@ -555,12 +567,16 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
555
567
  const valueCellOf = (f, row, chip, v) => {
556
568
  if (f.render)
557
569
  return f.render(row);
570
+ if (f.tags && !f.edit?.many)
571
+ return (_jsx(TagChips, { tags: tagsOf(f, row, v), colorOf: f.color, iconOf: f.iconOf, empty: f.empty }));
558
572
  if (!f.edit)
559
573
  return (_jsx(ViewValueCell, { value: v, format: f.format, chip: chip, tone: chip ? f.tone?.(row) : undefined, color: typeof v === 'string' ? f.color?.(v) : undefined, amount: f.amount, compact: f.compact, empty: f.empty }));
560
574
  const edit = f.edit;
561
575
  const many = edit.many;
562
576
  if (many)
563
- return (_jsx(EditManyCell, { label: f.label, values: f.values?.(row) ?? (typeof v === 'string' && v !== '' ? [v] : []), options: options.get(f.key)?.inUse ?? [], unused: options.get(f.key)?.unused, note: edit.note?.(row), colorOf: f.color, iconOf: f.iconOf, onSave: (values) => many(row, values) }));
577
+ return (_jsx(EditManyCell, { label: f.label, values: f.tags
578
+ ? tagsOf(f, row, v)
579
+ : (f.values?.(row) ?? (typeof v === 'string' && v !== '' ? [v] : [])), options: options.get(f.key)?.inUse ?? [], unused: options.get(f.key)?.unused, note: edit.note?.(row), colorOf: f.tags ? (t) => f.color?.(t) ?? tagColor(t) : f.color, iconOf: f.tags ? (t) => f.iconOf?.(t) ?? tagIcon(t) : f.iconOf, onSave: (values) => many(row, values) }));
564
580
  const save = edit.save;
565
581
  if (!save)
566
582
  return _jsx(ViewValueCell, { value: v, format: f.format, chip: chip });
@@ -895,8 +911,10 @@ function PivotTable({ domain, rows, pivot, }) {
895
911
  /** A group heading's mark: its value's icon in its colour, else its colour's dot, else the
896
912
  * field's icon. */
897
913
  function GroupMark({ field, value }) {
898
- const color = field.color?.(value);
899
- const Icon = field.iconOf?.(value);
914
+ // A tags field's one tag takes its own colour and icon, as its chips do.
915
+ const tag = field.tags && !value.includes(',');
916
+ const color = field.color?.(value) ?? (tag ? tagColor(value) : undefined);
917
+ const Icon = field.iconOf?.(value) ?? (tag ? tagIcon(value) : undefined);
900
918
  if (Icon)
901
919
  return (_jsx("span", { className: "inline-flex", style: color ? { color } : undefined, children: _jsx(Icon, { className: "size-3.5" }) }));
902
920
  if (color)
package/dist/nav.d.ts CHANGED
@@ -79,3 +79,10 @@ export declare function SidebarSection({ label, items, onAdd, addLabel, onRemove
79
79
  /** What its nested items stand for; plain pages by default. */
80
80
  assistantSubType?: AssistantPageType;
81
81
  }): import("react").JSX.Element;
82
+ /**
83
+ * The phone's bottom tab bar (`LumenShell tabs`): up to four of the app's pages, then More, which
84
+ * opens the sidebar with every page. Phone only; the desktop keeps the sidebar.
85
+ */
86
+ export declare function LumenTabBar({ items }: {
87
+ items: NavItem[];
88
+ }): import("react").JSX.Element | null;
package/dist/nav.js CHANGED
@@ -5,7 +5,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
5
5
  // A section header folds its items: it lights up on hover with a chevron after
6
6
  // the name, a click folds it, the fold is remembered in the browser, and `+` never folds.
7
7
  import { Link, useRouterState } from '@tanstack/react-router';
8
- import { ChevronRightIcon, PlusIcon, Trash2Icon } from 'lucide-react';
8
+ import { ChevronRightIcon, MenuIcon, PlusIcon, Trash2Icon } from 'lucide-react';
9
9
  import { useAssistantPages } from './assistant-screen.js';
10
10
  import { LumenConfirm } from './confirm.js';
11
11
  import { useFolded } from './fold.js';
@@ -112,3 +112,20 @@ export function SidebarSection({ label, items, onAdd, addLabel = `Add to ${label
112
112
  ? [0, 1].map((i) => (_jsx(SidebarMenuItem, { children: _jsx(SidebarMenuSkeleton, { showIcon: true }) }, i)))
113
113
  : items.map((item) => (_jsx(SectionRow, { item: item, activeId: activeId, onRemove: onRemove, removeLabel: removeLabel }, item.id))), !loading && !items.length && empty && (_jsx("li", { className: "px-2 py-1 text-xs text-muted-foreground group-data-[collapsible=icon]:hidden", children: empty }))] }), children && _jsx("div", { className: "group-data-[collapsible=icon]:hidden", children: children })] }) }));
114
114
  }
115
+ /**
116
+ * The phone's bottom tab bar (`LumenShell tabs`): up to four of the app's pages, then More, which
117
+ * opens the sidebar with every page. Phone only; the desktop keeps the sidebar.
118
+ */
119
+ export function LumenTabBar({ items }) {
120
+ const { isMobile, openMobile, setOpenMobile } = useSidebar();
121
+ const match = useMatch();
122
+ if (!isMobile || !items.length)
123
+ return null;
124
+ const tabs = items.slice(0, 4);
125
+ const scores = tabs.map((t) => match(t));
126
+ const best = Math.max(...scores);
127
+ return (_jsxs("nav", { "data-slot": "shell-tabbar", "aria-label": "Main", className: "sticky bottom-0 z-20 grid h-[calc(var(--shell-tabbar-height)+env(safe-area-inset-bottom))] shrink-0 grid-cols-5 border-t bg-background pb-[env(safe-area-inset-bottom)]", children: [tabs.map((t, i) => {
128
+ const on = best >= 0 && scores[i] === best;
129
+ return (_jsxs(Link, { to: t.to, "aria-current": on ? 'page' : undefined, className: cn('flex min-w-0 flex-col items-center justify-center gap-0.5 text-xs text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none', on && 'font-semibold text-primary'), children: [_jsx(t.icon, { className: "size-5" }), _jsx("span", { className: "max-w-full truncate px-1", children: t.title })] }, t.to));
130
+ }), _jsxs("button", { type: "button", "aria-expanded": openMobile, onClick: () => setOpenMobile(true), className: cn('flex min-w-0 flex-col items-center justify-center gap-0.5 text-xs text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none', best < 0 && 'font-semibold text-primary'), children: [_jsx(MenuIcon, { className: "size-5" }), _jsx("span", { children: "More" })] })] }));
131
+ }
package/dist/shell.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import { type LumenFinderConfig } from './lumen-finder.js';
3
+ import { type NavItem } from './nav.js';
3
4
  import { type SettingsGroupDef, type SettingsSectionDef } from './settings.js';
4
5
  import { type StatusBarModel } from './status-bar.js';
5
6
  interface ShellContext {
@@ -54,6 +55,8 @@ export interface LumenShellProps {
54
55
  brand: ReactNode;
55
56
  /** The app's sidebar content: `NavGroup`s and `SidebarSection`s. */
56
57
  nav: ReactNode;
58
+ /** The phone's bottom tab bar: up to four main pages, then More (the sidebar). None by default. */
59
+ tabs?: NavItem[];
57
60
  /** The app's status bar (DESIGN.md, Status bar): its sync and messages as data; pages add theirs
58
61
  * with `useStatus`. Running Tasks of this app show by themselves. */
59
62
  bar?: StatusBarModel;
@@ -101,7 +104,7 @@ export interface LumenShellProps {
101
104
  /** The routed page (usually `<Outlet />`). */
102
105
  children: ReactNode;
103
106
  }
104
- export declare function LumenShell({ app, appLabel, brand, nav, bar, 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;
107
+ export declare function LumenShell({ app, appLabel, brand, nav, tabs, bar, 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;
105
108
  /** A status bar entry: an optional health dot, a label and its value ("Mail · Up to date"). */
106
109
  export declare function StatusItem({ label, value, ok, className, }: {
107
110
  label: ReactNode;
package/dist/shell.js CHANGED
@@ -10,6 +10,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState, }
10
10
  import { lazyPart } from './lazy-part.js';
11
11
  import { cn } from './lib/utils.js';
12
12
  import { LumenFinder } from './lumen-finder.js';
13
+ import { LumenTabBar } from './nav.js';
13
14
  import { usePrefs } from './prefs.js';
14
15
  import { withRefreshToken } from './refresh.js';
15
16
  import { LumenSettings, ThemeToggle, allSettings, } from './settings.js';
@@ -96,7 +97,7 @@ const settingsFromUrl = (() => {
96
97
  window.history.replaceState(window.history.state, '', url);
97
98
  return at || 'profile';
98
99
  })();
99
- export function LumenShell({ app, appLabel, brand, nav, bar, status, health, statusEnd, releases, releaseApps, settings, shared, sharedLabel, settingsGroups, user = null, signOutUrl, profile, security, onRefresh, hideAmounts = false, onLogs, headerActions = null, panel, finder, wrap = (frame) => frame, children, }) {
100
+ export function LumenShell({ app, appLabel, brand, nav, tabs, bar, status, health, statusEnd, releases, releaseApps, settings, shared, sharedLabel, settingsGroups, user = null, signOutUrl, profile, security, onRefresh, hideAmounts = false, onLogs, headerActions = null, panel, finder, wrap = (frame) => frame, children, }) {
100
101
  const [statusSlot, setStatusSlot] = useState(null);
101
102
  const [dockSlot, setDockSlot] = useState(null);
102
103
  const [refreshing, setRefreshing] = useState(false);
@@ -142,7 +143,7 @@ export function LumenShell({ app, appLabel, brand, nav, bar, status, health, sta
142
143
  refreshing,
143
144
  hideAmounts,
144
145
  headerActions,
145
- }, children: _jsx(StatusBarProvider, { children: _jsx(DockSlot.Provider, { value: dockSlot, 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), _jsx(LumenStatusBar, { app: app, model: bar, legacy: status, slot: setStatusSlot, end: _jsxs(_Fragment, { children: [health, releases && _jsx(WhatsNew, { api: releases, apps: releaseApps }), statusEnd, _jsx(StatusTimeZone, {})] }) })] }), _jsx("div", { ref: setDockSlot, "data-slot": "shell-dock", className: "contents" }), 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" })] }) }) })) }) }) }) }));
146
+ }, children: _jsx(StatusBarProvider, { children: _jsx(DockSlot.Provider, { value: dockSlot, 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), _jsx(LumenStatusBar, { app: app, model: bar, legacy: status, slot: setStatusSlot, end: _jsxs(_Fragment, { children: [health, releases && _jsx(WhatsNew, { api: releases, apps: releaseApps }), statusEnd, _jsx(StatusTimeZone, {})] }) }), tabs && _jsx(LumenTabBar, { items: tabs })] }), _jsx("div", { ref: setDockSlot, "data-slot": "shell-dock", className: "contents" }), 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" })] }) }) })) }) }) }) }));
146
147
  }
147
148
  /** A status bar entry: an optional health dot, a label and its value ("Mail · Up to date"). */
148
149
  export function StatusItem({ label, value, ok, className, }) {
@@ -218,7 +218,7 @@ export function LumenStatusBar({ app, model, legacy, slot, end, }) {
218
218
  const entries = useEntries(model, app);
219
219
  const [open, setOpen] = useState(false);
220
220
  const urgent = [...entries].sort((a, b) => a.rank - b.rank)[0];
221
- return (_jsx("footer", { "data-slot": "shell-status", "aria-label": "Status", className: "sticky bottom-0 z-10 flex h-(--shell-status-height) shrink-0 items-center gap-3 overflow-hidden border-t bg-background px-(--shell-gutter) text-xs whitespace-nowrap text-muted-foreground", children: mobile ? (_jsxs(_Fragment, { children: [urgent && (_jsx("span", { "aria-live": "polite", className: "flex min-w-0 items-center", children: _jsx(EntryLine, { e: { ...urgent, bar: undefined, detail: undefined } }) })), _jsx("div", { ref: slot, hidden: true }), entries.length > 0 ? (_jsxs(Sheet, { open: open, onOpenChange: setOpen, children: [_jsxs(SheetTrigger, { className: cn(STATUS_TRIGGER, 'num ml-auto shrink-0 border text-foreground/80'), "aria-label": `Status, ${entries.length} items`, children: [_jsx(InfoIcon, { className: "size-3.5" }), entries.length] }), _jsxs(SheetContent, { side: "bottom", className: "max-h-[75svh] gap-0 rounded-t-2xl p-0 text-sm", onOpenAutoFocus: (e) => e.preventDefault(), children: [_jsxs(SheetHeader, { className: "border-b px-4 py-3", children: [_jsx(SheetTitle, { children: "Status" }), _jsx(SheetDescription, { className: "sr-only", children: "What is true right now" })] }), _jsx("ul", { className: "overflow-y-auto px-4", children: [...entries]
221
+ return (_jsx("footer", { "data-slot": "shell-status", "aria-label": "Status", className: "sticky bottom-(--shell-tabbar-offset) z-10 flex h-(--shell-status-height) shrink-0 items-center gap-3 overflow-hidden border-t bg-background px-(--shell-gutter) text-xs whitespace-nowrap text-muted-foreground", children: mobile ? (_jsxs(_Fragment, { children: [urgent && (_jsx("span", { "aria-live": "polite", className: "flex min-w-0 items-center", children: _jsx(EntryLine, { e: { ...urgent, bar: undefined, detail: undefined } }) })), _jsx("div", { ref: slot, hidden: true }), entries.length > 0 ? (_jsxs(Sheet, { open: open, onOpenChange: setOpen, children: [_jsxs(SheetTrigger, { className: cn(STATUS_TRIGGER, 'num ml-auto shrink-0 border text-foreground/80'), "aria-label": `Status, ${entries.length} items`, children: [_jsx(InfoIcon, { className: "size-3.5" }), entries.length] }), _jsxs(SheetContent, { side: "bottom", className: "max-h-[75svh] gap-0 rounded-t-2xl p-0 text-sm", onOpenAutoFocus: (e) => e.preventDefault(), children: [_jsxs(SheetHeader, { className: "border-b px-4 py-3", children: [_jsx(SheetTitle, { children: "Status" }), _jsx(SheetDescription, { className: "sr-only", children: "What is true right now" })] }), _jsx("ul", { className: "overflow-y-auto px-4", children: [...entries]
222
222
  .sort((a, b) => a.rank - b.rank)
223
223
  .map((e) => (_jsx(SheetRow, { e: e }, e.id))) }), _jsx("div", { className: "flex items-center justify-end gap-1 border-t px-4 py-2 text-xs text-muted-foreground", children: end })] })] })) : (_jsx("span", { className: "ml-auto flex shrink-0 items-center gap-1", children: end }))] })) : (_jsxs(_Fragment, { children: [_jsx("span", { "aria-live": "polite", className: "flex min-w-0 items-center gap-3", children: entries.map((e) => (_jsx(EntryLine, { e: e }, e.id))) }), legacy, _jsx("div", { ref: slot, className: "flex shrink-0 items-center gap-4" }), _jsx("span", { className: "ml-auto flex shrink-0 items-center gap-1", children: end })] })) }));
224
224
  }
@@ -0,0 +1,22 @@
1
+ import type { ComponentType } from 'react';
2
+ type Icon = ComponentType<{
3
+ className?: string;
4
+ }>;
5
+ /** A tag's colour, picked from its name: the same tag is the same colour everywhere. */
6
+ export declare function tagColor(tag: string): string;
7
+ /** A tag's icon from its words (`cybersecurity` a shield, `ai` sparkles), or none when no word fits. */
8
+ export declare function tagIcon(tag: string): Icon | undefined;
9
+ /**
10
+ * A row's tags as chips on one line: each in its colour with its icon. Past `max` the rest fold
11
+ * into a `+N` chip whose hover names them all, so a table row never wraps. `colorOf` and `iconOf`
12
+ * override the name-picked colour and icon (an app's own label colours).
13
+ */
14
+ export declare function TagChips({ tags, max, colorOf, iconOf, empty, }: {
15
+ tags: readonly string[];
16
+ max?: number;
17
+ colorOf?: (tag: string) => string | undefined;
18
+ iconOf?: (tag: string) => Icon | undefined;
19
+ /** What no tags says; left out: a dash. */
20
+ empty?: string;
21
+ }): import("react").JSX.Element;
22
+ export {};
@@ -0,0 +1,96 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Tags (a row's several free-form labels: `ai`, `cybersecurity`, `favorite`) as coloured chips,
3
+ // each with an icon where its words suggest one. The colour comes from the tag's name, so one tag
4
+ // reads the same colour on every row, page and app with nothing stored.
5
+ import { Baby, BookOpen, Bot, Briefcase, Bug, Building2, Car, Check, Clock, Cloud, Code, Cpu, Database, DollarSign, Dumbbell, Factory, Film, Flag, Flame, Gamepad2, Globe, GraduationCap, Heart, HeartPulse, Hourglass, House, Landmark, Leaf, Lock, MapPin, Megaphone, Music, Network, Palette, Plane, Rocket, Server, Shield, ShoppingCart, Smartphone, Sparkles, Star, TriangleAlert, Truck, Users, Utensils, Wrench, Zap, } from 'lucide-react';
6
+ import { ColorChip } from './color-dot.js';
7
+ import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip.js';
8
+ // Chart colours that read as a hue in light and dark (not the grey `--chart-8`).
9
+ const TAG_COLORS = [1, 2, 3, 4, 5, 6, 7, 9, 10].map((n) => `var(--chart-${n})`);
10
+ /** A tag's colour, picked from its name: the same tag is the same colour everywhere. */
11
+ export function tagColor(tag) {
12
+ const key = tag.trim().toLowerCase();
13
+ let h = 2166136261;
14
+ for (let i = 0; i < key.length; i++)
15
+ h = Math.imul(h ^ key.charCodeAt(i), 16777619);
16
+ return TAG_COLORS[(h >>> 0) % TAG_COLORS.length];
17
+ }
18
+ // Words to icons, most specific first: a tag takes the icon of the first entry any of its words
19
+ // matches (`data-security` is a shield, `ai-buildout` a server).
20
+ const TAG_ICONS = [
21
+ [/^(fav|favou?rites?|starred|top|pick)$/, Star],
22
+ [/^(cyber|cybersecurity|security|secure|infosec|appsec|privacy)$/, Shield],
23
+ [/^(auth|identity|iam|zero|zerotrust|crypto|encryption)$/, Lock],
24
+ [/^(buildout|datacenters?|dc|infra|infrastructure|servers?|hosting|compute)$/, Server],
25
+ [/^(chips?|semis?|semiconductors?|hardware|gpus?|silicon)$/, Cpu],
26
+ [/^(ai|ml|llm|genai|agents?|agentic)$/, Sparkles],
27
+ [/^(bots?|robots?|robotics|automation)$/, Bot],
28
+ [/^(data|database|db|analytics|warehouse|sql|etl)$/, Database],
29
+ [/^(cloud|saas|serverless)$/, Cloud],
30
+ [/^(network|networking|edge|cdn)$/, Network],
31
+ [/^(bigtech|faang|enterprise|corp|corporate|company)$/, Building2],
32
+ [/^(dev|devtools|developer|developers|code|coding|engineering|software|platform)$/, Code],
33
+ [/^(startup|startups|growth|launch)$/, Rocket],
34
+ [/^(fintech|finance|financial|bank|banking|money|pay|payments|budget)$/, DollarSign],
35
+ [/^(gov|government|public|federal|civic)$/, Landmark],
36
+ [/^(health|healthcare|medical|bio|biotech|pharma)$/, HeartPulse],
37
+ [/^(fitness|gym|workout|sports?)$/, Dumbbell],
38
+ [/^(energy|power|electric|ev|battery|utilities)$/, Zap],
39
+ [/^(climate|green|sustainability|eco|garden|plants?)$/, Leaf],
40
+ [/^(manufacturing|industrial|factory)$/, Factory],
41
+ [/^(mobile|phone|ios|android|apps?)$/, Smartphone],
42
+ [/^(remote|global|international|web|online)$/, Globe],
43
+ [/^(local|onsite|hybrid|office|nearby)$/, MapPin],
44
+ [/^(home|house|family)$/, House],
45
+ [/^(kids?|baby|children)$/, Baby],
46
+ [/^(team|people|social|community|hr)$/, Users],
47
+ [/^(jobs?|work|career|hiring)$/, Briefcase],
48
+ [/^(edu|education|learning|school|course|courses|training)$/, GraduationCap],
49
+ [/^(books?|reading|docs|research)$/, BookOpen],
50
+ [/^(games?|gaming)$/, Gamepad2],
51
+ [/^(music|audio)$/, Music],
52
+ [/^(film|video|movies?|media|streaming)$/, Film],
53
+ [/^(design|art|creative)$/, Palette],
54
+ [/^(marketing|ads|sales|pr)$/, Megaphone],
55
+ [/^(shop|shopping|retail|ecommerce|commerce)$/, ShoppingCart],
56
+ [/^(food|recipes?|dinner|lunch|breakfast|dessert|cooking)$/, Utensils],
57
+ [/^(travel|trip|flights?)$/, Plane],
58
+ [/^(cars?|auto|automotive)$/, Car],
59
+ [/^(logistics|shipping|delivery|supply)$/, Truck],
60
+ [/^(tools?|repair|maintenance|fix)$/, Wrench],
61
+ [/^(bugs?|issues?)$/, Bug],
62
+ [/^(urgent|hot|asap)$/, Flame],
63
+ [/^(risk|warning|alert|blocked)$/, TriangleAlert],
64
+ [/^(done|ok|approved|verified)$/, Check],
65
+ [/^(waiting|pending|later|someday)$/, Hourglass],
66
+ [/^(daily|weekly|monthly|recurring|time)$/, Clock],
67
+ [/^(goal|goals|milestone|priority)$/, Flag],
68
+ [/^(love|loved|liked)$/, Heart],
69
+ ];
70
+ const iconCache = new Map();
71
+ /** A tag's icon from its words (`cybersecurity` a shield, `ai` sparkles), or none when no word fits. */
72
+ export function tagIcon(tag) {
73
+ const key = tag.trim().toLowerCase();
74
+ let icon = iconCache.get(key);
75
+ if (icon === undefined) {
76
+ const words = key.split(/[^a-z0-9]+/).filter(Boolean);
77
+ icon = TAG_ICONS.find(([re]) => words.some((w) => re.test(w)))?.[1] ?? null;
78
+ iconCache.set(key, icon);
79
+ }
80
+ return icon ?? undefined;
81
+ }
82
+ /**
83
+ * A row's tags as chips on one line: each in its colour with its icon. Past `max` the rest fold
84
+ * into a `+N` chip whose hover names them all, so a table row never wraps. `colorOf` and `iconOf`
85
+ * override the name-picked colour and icon (an app's own label colours).
86
+ */
87
+ export function TagChips({ tags, max = 3, colorOf, iconOf, empty, }) {
88
+ if (!tags.length)
89
+ return _jsx("span", { className: "text-muted-foreground", children: empty ?? '—' });
90
+ const shown = tags.slice(0, max);
91
+ const rest = tags.length - shown.length;
92
+ const chips = (_jsxs("span", { className: "inline-flex min-w-0 items-center gap-1 overflow-hidden", children: [shown.map((t) => (_jsx(ColorChip, { color: colorOf?.(t) ?? tagColor(t), icon: iconOf?.(t) ?? tagIcon(t), children: t }, t))), rest > 0 && _jsxs(ColorChip, { children: ["+", rest] })] }));
93
+ if (!rest)
94
+ return chips;
95
+ return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: chips }), _jsx(TooltipContent, { className: "max-w-md whitespace-normal", children: tags.join(', ') })] }));
96
+ }
package/dist/theme.css CHANGED
@@ -89,6 +89,9 @@
89
89
  --shell-assistant-width: 26rem;
90
90
  --shell-header-height: 48px;
91
91
  --shell-status-height: 32px;
92
+ /* The phone tab bar (LumenShell tabs); 0 until one shows, so the status bar sits above it. */
93
+ --shell-tabbar-height: 56px;
94
+ --shell-tabbar-offset: 0px;
92
95
  /* A status bar message ends in an ellipsis here (StatusMessages), so it fits beside the bar's
93
96
  other items; the full text opens on hover or tap. */
94
97
  --status-message-max: 55vw;
@@ -851,3 +854,8 @@ body[data-spotlight="nav"] [data-spotlit] {
851
854
  filter: none;
852
855
  opacity: 1;
853
856
  }
857
+
858
+ /* With a phone tab bar under it, the status bar rides above the bar instead of the screen's edge. */
859
+ [data-slot="sidebar-inset"]:has(> [data-slot="shell-tabbar"]) {
860
+ --shell-tabbar-offset: calc(var(--shell-tabbar-height) + env(safe-area-inset-bottom));
861
+ }
@@ -25,6 +25,13 @@ export interface ViewField<T> {
25
25
  topic?: string;
26
26
  /** Every value a row has for filtering, when it has several (a symbol in Holdings and Watchlist). */
27
27
  values?: (row: T) => string[];
28
+ /**
29
+ * The values are tags (a row's several free-form labels): the cell draws each of `values` as a
30
+ * chip in its colour with its icon (`TagChips`), never a comma list. The colour and icon come
31
+ * from the tag's name unless `color` or `iconOf` gives them. Without `values` the text value is
32
+ * split on commas.
33
+ */
34
+ tags?: boolean;
28
35
  format: ViewFormat;
29
36
  /** How the value is made, shown under the field in the Edit dialog. */
30
37
  formula?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.171",
3
+ "version": "0.1.173",
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.171",
52
+ "@columenlabs/lumen-ui-types": "0.1.173",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",