@columenlabs/lumen-ui 0.1.44 → 0.1.46

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/README.md CHANGED
@@ -74,6 +74,13 @@ Every export, its props and an example are on the [docs](https://lumen.columenla
74
74
  - Pickers are `LumenSelector`, side panels `LumenDrawer`, tabs `LumenTabs`, destructive confirms `LumenConfirm`; reach for the raw `ui/*` part only when no Lumen part fits.
75
75
  - Every card body sits in `LumenBoundary`, so one crash never blanks a page.
76
76
  - Numbers use tabular figures and a true minus `−`; a gain or loss always shows a sign or arrow.
77
+ - Bars in a column line up: use `ShareMeter` (fixed track, fixed right-aligned number), never a bar sized by its label.
78
+ - Table rows are one line. A cell shows one value (with a small bar or tag at most); anything more (other metrics, the allowance, a breakdown) goes in a hover tooltip, never in stacked text or sub-rows.
79
+ - Chart colours are theme tokens (`var(--chart-1)`, `useChartTokens()`), never hex picked for one theme; `LumenEChart` resolves `var(--…)` in its option so sparklines read in light and dark.
80
+ - "No label" filters come built in: `LumenTokenizedFilter` pins a "No <field>" row on any text field where some rows have no value (an `empty` token); don't add your own toggle for it.
81
+ - Fewer tab rows: one row of tabs at most per card; narrower cuts (a product, a type) are fields in `LumenTokenizedFilter`, and a link to one opens the tab with that filter chip set.
82
+ - One selected sidebar row: give each nav item its own route, and let an open parent look neutral.
83
+ - What's new is on in every app: pass `releases="/api/releases"` to `LumenShell`, served by lumen-ship's `listReleases`.
77
84
  - A Save blocked by its form's checks is a `SaveButton` that lists each issue, never a bare `disabled`.
78
85
 
79
86
  ## Notices
@@ -7,8 +7,17 @@ export declare function ColorDot({ color, className }: {
7
7
  export declare function colorIcon(color: string): ComponentType<{
8
8
  className?: string;
9
9
  }>;
10
- /** A value as a chip in its colour (a label): a tint behind, the colour as text. */
11
- export declare function ColorChip({ color, children }: {
10
+ /** `icon` drawn in `color` (a label's icon in the label's colour), kept per pair so it keeps its identity. */
11
+ export declare function tintedIcon(icon: ComponentType<{
12
+ className?: string;
13
+ }>, color: string | undefined): ComponentType<{
14
+ className?: string;
15
+ }>;
16
+ /** A value as a chip in its colour (a label): a tint behind, the colour as text, its icon first. */
17
+ export declare function ColorChip({ color, icon: Icon, children, }: {
12
18
  color?: string;
19
+ icon?: ComponentType<{
20
+ className?: string;
21
+ }>;
13
22
  children: ReactNode;
14
23
  }): import("react").JSX.Element;
package/dist/color-dot.js CHANGED
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cn } from './lib/utils.js';
3
3
  export function ColorDot({ color, className }) {
4
4
  return (_jsx("span", { "aria-hidden": true, className: cn('flex size-4 shrink-0 items-center justify-center', className), children: _jsx("span", { className: "size-2.5 rounded-full", style: { background: color } }) }));
@@ -13,7 +13,25 @@ export function colorIcon(color) {
13
13
  }
14
14
  return icon;
15
15
  }
16
- /** A value as a chip in its colour (a label): a tint behind, the colour as text. */
17
- export function ColorChip({ color, children }) {
18
- return (_jsx("span", { className: "inline-flex h-5 items-center rounded-sm bg-secondary px-1.5 text-xs font-medium whitespace-nowrap text-secondary-foreground", style: color ? { background: `color-mix(in oklab, ${color} 18%, transparent)`, color } : undefined, children: children }));
16
+ const tinted = new WeakMap();
17
+ /** `icon` drawn in `color` (a label's icon in the label's colour), kept per pair so it keeps its identity. */
18
+ export function tintedIcon(icon, color) {
19
+ if (!color)
20
+ return icon;
21
+ let byColor = tinted.get(icon);
22
+ if (!byColor) {
23
+ byColor = new Map();
24
+ tinted.set(icon, byColor);
25
+ }
26
+ let out = byColor.get(color);
27
+ if (!out) {
28
+ const Icon = icon;
29
+ out = ({ className }) => (_jsx("span", { "aria-hidden": true, className: "flex shrink-0", style: { color }, children: _jsx(Icon, { className: cn('size-4', className) }) }));
30
+ byColor.set(color, out);
31
+ }
32
+ return out;
33
+ }
34
+ /** A value as a chip in its colour (a label): a tint behind, the colour as text, its icon first. */
35
+ export function ColorChip({ color, icon: Icon, children, }) {
36
+ return (_jsxs("span", { className: "inline-flex h-5 shrink-0 items-center gap-1 rounded-sm bg-secondary px-1.5 text-xs font-medium whitespace-nowrap text-secondary-foreground", style: color ? { background: `color-mix(in oklab, ${color} 18%, transparent)`, color } : undefined, children: [Icon && _jsx(Icon, { className: "size-3 shrink-0" }), children] }));
19
37
  }
package/dist/index.d.ts CHANGED
@@ -21,6 +21,7 @@ export { LumenDateInput, type LumenDateInputProps, stepDate, } from './lumen-dat
21
21
  export { LumenChoice, type LumenChoiceOption } from './lumen-choice.js';
22
22
  export { LumenSecret } from './lumen-secret.js';
23
23
  export { RankedBars, StackedBars, StackedColumns } from './lumen-widget-charts.js';
24
+ export { ShareMeter } from './share-meter.js';
24
25
  export { COVERAGE_LEVELS, CoverageCell, type CoverageJobState, type CoverageLevel, CoverageLegend, coverageClass, coverageLabel, coverageLevel, } from './coverage-cells.js';
25
26
  export { type CoverageBlock, type CoverageData, type CoverageFact, type CoveragePick, type CoverageRow, type CoverageSummary, LumenCoverageMap, type LumenCoverageMapProps, blockName, blockOf, } from './coverage-map.js';
26
27
  export { type CalendarFact, LumenCoverageCalendarMap, type LumenCoverageCalendarMapProps, } from './coverage-calendar-map.js';
package/dist/index.js CHANGED
@@ -28,6 +28,7 @@ export { LumenDateInput, stepDate, } from './lumen-date-input.js';
28
28
  export { LumenChoice } from './lumen-choice.js';
29
29
  export { LumenSecret } from './lumen-secret.js';
30
30
  export { RankedBars, StackedBars, StackedColumns } from './lumen-widget-charts.js';
31
+ export { ShareMeter } from './share-meter.js';
31
32
  export { COVERAGE_LEVELS, CoverageCell, CoverageLegend, coverageClass, coverageLabel, coverageLevel, } from './coverage-cells.js';
32
33
  export { LumenCoverageMap, blockName, blockOf, } from './coverage-map.js';
33
34
  export { LumenCoverageCalendarMap, } from './coverage-calendar-map.js';
@@ -71,6 +71,24 @@ export function baseOption(t) {
71
71
  },
72
72
  };
73
73
  }
74
+ /** `option` with every `var(--name)` string (also inside `color-mix()`) replaced by its value. */
75
+ function resolveVars(option) {
76
+ const css = getComputedStyle(document.documentElement);
77
+ const walk = (v) => {
78
+ if (typeof v === 'string')
79
+ return v.includes('var(--')
80
+ ? v.replace(/var\((--[\w-]+)(?:,\s*([^)]+))?\)/g, (_, name, fb) => {
81
+ return css.getPropertyValue(name).trim() || fb?.trim() || '';
82
+ })
83
+ : v;
84
+ if (Array.isArray(v))
85
+ return v.map(walk);
86
+ if (v && typeof v === 'object' && Object.getPrototypeOf(v) === Object.prototype)
87
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
88
+ return v;
89
+ };
90
+ return walk(option);
91
+ }
74
92
  /** An axis in the theme's colours (merge into xAxis or yAxis). */
75
93
  export function axisStyle(t) {
76
94
  return {
@@ -106,10 +124,14 @@ export function LumenEChart({ option, height = 260, on, className, label, }) {
106
124
  chart.current = null;
107
125
  };
108
126
  }, []);
127
+ // Canvas can't read CSS variables: `var(--chart-1)` in an option would draw black. Each one is
128
+ // swapped for its value, again whenever the theme flips.
129
+ const tokens = useChartTokens();
130
+ // biome-ignore lint/correctness/useExhaustiveDependencies: tokens change when the theme flips.
109
131
  useEffect(() => {
110
132
  if (ready)
111
- chart.current?.setOption(option, { notMerge: true });
112
- }, [ready, option]);
133
+ chart.current?.setOption(resolveVars(option), { notMerge: true });
134
+ }, [ready, option, tokens]);
113
135
  const names = Object.keys(on ?? {}).join();
114
136
  useEffect(() => {
115
137
  const c = chart.current;
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { CONTAINS_KEY, meetsNumber } from '@columenlabs/lumen-ui-types/views';
2
+ import { CONTAINS_KEY, EMPTY_VALUE, meetsNumber, } from '@columenlabs/lumen-ui-types/views';
3
3
  // The one filter box every view uses, redone after p2e's:
4
4
  // one chip per field in the sans face, `Account IRA, Roth ×`, included or excluded as a whole
5
5
  // (`Symbol not AAPL`). A chip's tap opens its field: Include / Exclude, then its values ticked,
@@ -8,7 +8,9 @@ import { CONTAINS_KEY, meetsNumber } from '@columenlabs/lumen-ui-types/views';
8
8
  // fields: all must match (AND). Free text matches the row's text and any field value; picking
9
9
  // "Text contains" keeps it as a chip. It filters the rows already loaded, on the client. The box
10
10
  // is one row that never grows and always keeps room to type; crowded chips fold to `Sector: 3`,
11
- // then behind +N. Phones get a bottom sheet.
11
+ // then behind +N. Phones get a bottom sheet. A text field where some rows have no value pins
12
+ // "No <field>" above its values: Include keeps the rows without one, Exclude the rows with any
13
+ // (an `empty` token); typing "no" or "empty" offers it too.
12
14
  import { Command as CommandPrimitive } from 'cmdk';
13
15
  import { ChevronLeftIcon, ChevronRightIcon, SaveIcon, SearchIcon, TextSearchIcon, XIcon, } from 'lucide-react';
14
16
  import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
@@ -24,14 +26,21 @@ import { Popover, PopoverAnchor, PopoverContent } from './ui/popover.js';
24
26
  import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet.js';
25
27
  import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.js';
26
28
  import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip.js';
27
- const OP_SIGN = { gt: '>', lt: '<', eq: '=', between: '' };
29
+ const OP_SIGN = { gt: '>', lt: '<', eq: '=', between: '', empty: '' };
30
+ /** The rows with no value on a field (`not`: with any). */
31
+ const isEmptyToken = (t) => t.op === 'empty';
32
+ const emptyToken = (key, not) => not ? { key, value: EMPTY_VALUE, op: 'empty', not } : { key, value: EMPTY_VALUE, op: 'empty' };
33
+ /** "No label": the empty row's words for a field. */
34
+ const noneOf = (label) => `No ${label.toLowerCase()}`;
28
35
  export const tokenText = (t) => `${t.not ? '-' : ''}${t.key}:${t.op ? OP_SIGN[t.op] : ''}${t.value}`;
29
36
  /** A number condition as people read it: `> 100`, `0 – 500`. */
30
37
  const conditionText = (t) => t.op === 'between' ? t.value.replace('..', ' – ') : `${OP_SIGN[t.op ?? 'eq']} ${t.value}`;
31
38
  /** `field:>100`, `field:<0`, `field:=5`, `field:0..500` typed out for a number field. */
32
39
  const NUMBER_TYPED = /^(\w+):(>|<|=)?(-?\d+(?:\.\d+)?)(?:\.\.(-?\d+(?:\.\d+)?))?$/;
33
40
  /** Same field and value, included or not: a value sits in one chip only. */
34
- const same = (a, b) => a.key === b.key && a.value.toLowerCase() === b.value.toLowerCase();
41
+ const same = (a, b) => a.key === b.key &&
42
+ isEmptyToken(a) === isEmptyToken(b) &&
43
+ a.value.toLowerCase() === b.value.toLowerCase();
35
44
  const token = (key, value, not) => not ? { key, value, not } : { key, value };
36
45
  /** The `contains` chip's field: free text over the row's own text. */
37
46
  const CONTAINS = {
@@ -55,9 +64,14 @@ function passes(values, nums, tokens, fields, except) {
55
64
  continue;
56
65
  }
57
66
  const have = values[f.key] ?? [];
58
- const inc = of.filter((t) => !t.not).map((t) => t.value.toLowerCase());
59
- const exc = of.filter((t) => t.not).map((t) => t.value.toLowerCase());
60
- if (inc.length && !have.some((v) => inc.includes(v)))
67
+ // `empty`: no value (included, alongside any values: none or one of these) or any value (excluded).
68
+ const none = of.find((t) => isEmptyToken(t) && !t.not);
69
+ if (of.some((t) => isEmptyToken(t) && t.not) && !have.length)
70
+ return false;
71
+ const plain = of.filter((t) => !isEmptyToken(t));
72
+ const inc = plain.filter((t) => !t.not).map((t) => t.value.toLowerCase());
73
+ const exc = plain.filter((t) => t.not).map((t) => t.value.toLowerCase());
74
+ if ((inc.length || none) && !(none && !have.length) && !have.some((v) => inc.includes(v)))
61
75
  return false;
62
76
  if (exc.length && have.some((v) => exc.includes(v)))
63
77
  return false;
@@ -92,7 +106,7 @@ text) {
92
106
  /** A field is included or excluded as a whole: where a field holds both, as
93
107
  * filters saved before that do, its included values stay. */
94
108
  function onePerField(tokens) {
95
- const inc = new Set(tokens.filter((t) => !t.not && !t.op).map((t) => t.key));
109
+ const inc = new Set(tokens.filter((t) => !t.not && (!t.op || isEmptyToken(t))).map((t) => t.key));
96
110
  return tokens.some((t) => t.not && inc.has(t.key))
97
111
  ? tokens.filter((t) => !(t.not && inc.has(t.key)))
98
112
  : tokens;
@@ -179,7 +193,12 @@ function chipWords(chip, field, form) {
179
193
  if (condition)
180
194
  return { name, verb: '', shown: conditionText(condition), all: '' };
181
195
  const quote = chip.key === CONTAINS_KEY;
182
- const all = chip.tokens.map((t) => (quote ? `“${t.value}”` : t.value)).join(', ');
196
+ // An excluded `empty` reads "Label any"; an included one "none" among the values.
197
+ if (chip.not && chip.tokens.length === 1 && chip.tokens[0] && isEmptyToken(chip.tokens[0]))
198
+ return { name, verb: '', shown: 'any', all: 'any' };
199
+ const all = chip.tokens
200
+ .map((t) => (isEmptyToken(t) ? 'none' : quote ? `“${t.value}”` : t.value))
201
+ .join(', ');
183
202
  const counted = form === 'count' && chip.tokens.length > 1;
184
203
  const verb = chip.not ? (counted ? 'not in:' : 'not') : counted ? ':' : '';
185
204
  return { name, verb, shown: counted ? String(chip.tokens.length) : all, all };
@@ -294,9 +313,20 @@ export function LumenTokenizedFilter({ filter, placeholder = 'Filter…', label
294
313
  const only = (key, value) => {
295
314
  setTokens([...tokens.filter((x) => x.key !== key), token(key, value, notFor(key))]);
296
315
  };
316
+ const hasEmpty = (key) => tokens.some((t) => t.key === key && isEmptyToken(t));
317
+ const toggleEmpty = (key, not = notFor(key)) => {
318
+ if (hasEmpty(key))
319
+ setTokens(tokens.filter((t) => !(t.key === key && isEmptyToken(t))));
320
+ else
321
+ setTokens([...tokens, emptyToken(key, not)]);
322
+ if (!field)
323
+ setText('');
324
+ else
325
+ setSearch('');
326
+ };
297
327
  const setNot = (key, not) => {
298
328
  setPendingNot(not);
299
- setTokens(tokens.map((t) => (t.key === key ? token(key, t.value, not) : t)));
329
+ setTokens(tokens.map((t) => t.key !== key ? t : isEmptyToken(t) ? emptyToken(key, not) : token(key, t.value, not)));
300
330
  };
301
331
  // A number field holds one condition: a new one replaces it.
302
332
  const setCondition = (key, op, value) => setTokens([...tokens.filter((x) => x.key !== key), { key, value, op }]);
@@ -335,21 +365,28 @@ export function LumenTokenizedFilter({ filter, placeholder = 'Filter…', label
335
365
  const dirty = !!onSave && (!!changed || !sameTokens(tokens, saved ?? []));
336
366
  // Each field's values with counts. A field counts the rows the other fields' tokens keep, so a
337
367
  // chip can take more values of its own field.
338
- const counts = useMemo(() => {
339
- const out = new Map();
368
+ const { counts, empties } = useMemo(() => {
369
+ const counts = new Map();
370
+ // Rows with no value on each field, for its "No <field>" row.
371
+ const empties = new Map();
340
372
  for (const f of fields) {
341
373
  if (f.number)
342
374
  continue;
343
375
  const m = new Map();
376
+ let none = 0;
344
377
  for (const { row, values, nums } of indexed) {
345
378
  if (!passes(values, nums, tokens, fields, f.key))
346
379
  continue;
347
- for (const v of new Set(f.values(row)))
380
+ const vs = new Set(f.values(row));
381
+ if (!vs.size)
382
+ none++;
383
+ for (const v of vs)
348
384
  m.set(v, (m.get(v) ?? 0) + 1);
349
385
  }
350
- out.set(f.key, m);
386
+ counts.set(f.key, m);
387
+ empties.set(f.key, none);
351
388
  }
352
- return out;
389
+ return { counts, empties };
353
390
  }, [fields, indexed, tokens]);
354
391
  const picked = (key, value) => tokens.some((t) => same(t, { key, value }));
355
392
  /** A field's values matching `n`: picked ones first, then the most common. In the open field
@@ -358,7 +395,7 @@ export function LumenTokenizedFilter({ filter, placeholder = 'Filter…', label
358
395
  const first = (v) => (key === field ? pinned.has(v) : picked(key, v));
359
396
  const m = new Map(counts.get(key) ?? []);
360
397
  for (const t of tokens)
361
- if (t.key === key && !m.has(t.value))
398
+ if (t.key === key && !isEmptyToken(t) && !m.has(t.value))
362
399
  m.set(t.value, 0);
363
400
  return [...m]
364
401
  .filter(([v]) => !n || v.toLowerCase().includes(n))
@@ -458,6 +495,13 @@ export function LumenTokenizedFilter({ filter, placeholder = 'Filter…', label
458
495
  only(key, value);
459
496
  }, children: "Only" }), _jsx(CommandShortcut, { className: cn('ml-0 w-8 text-right', shortcut), children: count })] }, `${key}:${value}`));
460
497
  };
498
+ // A field's "No <field>" row, pinned above its values: the rows with no value (Exclude: any).
499
+ const emptyRow = (f, include) => {
500
+ const n = empties.get(f.key) ?? 0;
501
+ if (!n && !hasEmpty(f.key))
502
+ return null;
503
+ return (_jsxs(CommandItem, { value: `${f.key}:${EMPTY_VALUE}`, onSelect: () => toggleEmpty(f.key, include ? false : undefined), className: tall, children: [_jsx(Checkbox, { checked: hasEmpty(f.key), tabIndex: -1, "aria-hidden": true, className: "pointer-events-none" }), _jsx("span", { className: "min-w-0 truncate text-muted-foreground italic", children: noneOf(f.label) }), _jsx(CommandShortcut, { className: cn('ml-auto w-8 text-right', shortcut), children: n })] }, `${f.key}:${EMPTY_VALUE}`));
504
+ };
461
505
  const summary = (c) => {
462
506
  const w = chipWords(c, fieldOf(c.key), 'full');
463
507
  return (_jsxs("span", { className: "ml-auto min-w-0 truncate pl-3 text-right", children: [c.not && _jsx("span", { className: "text-destructive", children: "not " }), _jsx("span", { className: "tabular-nums", children: w.shown })] }));
@@ -470,7 +514,8 @@ export function LumenTokenizedFilter({ filter, placeholder = 'Filter…', label
470
514
  focus();
471
515
  }, children: _jsx(ChevronLeftIcon, {}) }), _jsx(activeField.icon, { className: "size-4 text-muted-foreground" }), _jsx("span", { className: "font-medium", children: activeField.label }), !activeField.number && (_jsxs(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, className: "ml-auto", value: notFor(activeField.key) ? 'exclude' : 'include', onValueChange: (v) => v && setNot(activeField.key, v === 'exclude'), "aria-label": `${activeField.label}: include or exclude`, children: [_jsx(ToggleGroupItem, { value: "include", className: "h-7 px-2 text-xs font-normal", children: "Include" }), _jsx(ToggleGroupItem, { value: "exclude", className: "h-7 px-2 text-xs font-normal", children: "Exclude" })] }))] }), activeField.number && (_jsx(NumberCondition, { label: activeField.label, current: tokens.find((t) => t.key === activeField.key), tall: mobile, count: (op, value) => numberCount(activeField.key, op, value), onSet: (op, value) => setCondition(activeField.key, op, value) }, activeField.key))] }), active === CONTAINS_KEY ? (_jsxs(CommandGroup, { className: "px-1 py-0.5", children: [containsWord && !picked(CONTAINS_KEY, containsWord) && (_jsxs(CommandItem, { value: `${CONTAINS_KEY}:${containsWord}`, onSelect: () => toggle(CONTAINS_KEY, containsWord), className: tall, children: [_jsx(TextSearchIcon, { className: "text-muted-foreground" }), _jsxs("span", { className: "min-w-0 truncate", children: ["\u201C", containsWord, "\u201D"] }), _jsx(CommandShortcut, { className: shortcut, children: wordOf(containsWord, notFor(CONTAINS_KEY)) })] })), tokens
472
516
  .filter((t) => t.key === CONTAINS_KEY)
473
- .map((t) => valueRow(CONTAINS_KEY, t.value, wordOf(t.value, !!t.not)))] })) : (!activeField.number && (_jsx(CommandGroup, { className: "px-1 py-0.5", children: valuesOf(activeField.key, needle, 200).map(([v, n]) => valueRow(activeField.key, v, n)) }))), _jsx(CommandEmpty, { children: "No matching values." })] })) : q ? (_jsxs(_Fragment, { children: [_jsx(CommandEmpty, { children: "No matching values." }), word && (_jsx(CommandGroup, { className: "px-1 py-0.5", children: _jsxs(CommandItem, { value: `${CONTAINS_KEY}:${word}`, onSelect: () => {
517
+ .map((t) => valueRow(CONTAINS_KEY, t.value, wordOf(t.value, !!t.not)))] })) : (!activeField.number && (_jsxs(CommandGroup, { className: "px-1 py-0.5", children: [(!needle || noneOf(activeField.label).toLowerCase().includes(needle)) &&
518
+ emptyRow(activeField), valuesOf(activeField.key, needle, 200).map(([v, n]) => valueRow(activeField.key, v, n))] }))), _jsx(CommandEmpty, { children: "No matching values." })] })) : q ? (_jsxs(_Fragment, { children: [_jsx(CommandEmpty, { children: "No matching values." }), word && (_jsx(CommandGroup, { className: "px-1 py-0.5", children: _jsxs(CommandItem, { value: `${CONTAINS_KEY}:${word}`, onSelect: () => {
474
519
  setTokens([
475
520
  ...tokens.filter((t) => !same(t, { key: CONTAINS_KEY, value: word })),
476
521
  token(CONTAINS_KEY, word, chipOf(CONTAINS_KEY)?.not ?? negate),
@@ -478,7 +523,14 @@ export function LumenTokenizedFilter({ filter, placeholder = 'Filter…', label
478
523
  setText('');
479
524
  }, className: tall, children: [_jsx(TextSearchIcon, { className: "text-muted-foreground" }), _jsxs("span", { className: "min-w-0 truncate", children: [_jsxs("span", { className: "text-muted-foreground", children: ["Text ", negate ? 'not contains' : 'contains', ' '] }), "\u201C", word, "\u201D"] }), _jsx(CommandShortcut, { className: shortcut, children: wordOf(word, negate) })] }) })), fields.some((f) => f.label.toLowerCase().startsWith(q) || f.key.startsWith(q)) && (_jsx(CommandGroup, { heading: "Fields", className: "px-1 py-0.5", children: fields
480
525
  .filter((f) => f.label.toLowerCase().startsWith(q) || f.key.startsWith(q))
481
- .map((f) => (_jsxs(CommandItem, { value: `field:${f.key}`, onSelect: () => openField(f.key), className: tall, children: [_jsx(f.icon, { className: "text-muted-foreground" }), f.label, _jsx(ChevronRightIcon, { className: "ml-auto text-muted-foreground" })] }, f.key))) })), fields
526
+ .map((f) => (_jsxs(CommandItem, { value: `field:${f.key}`, onSelect: () => openField(f.key), className: tall, children: [_jsx(f.icon, { className: "text-muted-foreground" }), f.label, _jsx(ChevronRightIcon, { className: "ml-auto text-muted-foreground" })] }, f.key))) })), (q.startsWith('no') || (q.length > 1 && 'empty'.startsWith(q))) &&
527
+ fields.some((f) => !f.number &&
528
+ (empties.get(f.key) ?? 0) > 0 &&
529
+ (q.startsWith('no') ? noneOf(f.label).toLowerCase().startsWith(q) : true)) && (_jsx(CommandGroup, { heading: "Quick filters", className: "px-1 py-0.5", children: fields
530
+ .filter((f) => !f.number &&
531
+ (empties.get(f.key) ?? 0) > 0 &&
532
+ (q.startsWith('no') ? noneOf(f.label).toLowerCase().startsWith(q) : true))
533
+ .map((f) => emptyRow(f, true)) })), fields
482
534
  .filter((f) => !f.number)
483
535
  .map((f) => {
484
536
  const vs = valuesOf(f.key, q, PER_FIELD);
@@ -156,7 +156,9 @@ export function followedBy(tokens, fields, keys) {
156
156
  /** What a card follows, as its title says it: `IRA, Roth`. */
157
157
  export function followsText(tokens) {
158
158
  return tokens
159
- .map((t) => `${t.not ? 'not ' : ''}${t.op ? `${t.key} ${t.value.replace('..', '–')}` : (t.text ?? t.value)}`)
159
+ .map((t) => t.op === 'empty'
160
+ ? `${t.key} ${t.not ? 'any' : 'none'}`
161
+ : `${t.not ? 'not ' : ''}${t.op ? `${t.key} ${t.value.replace('..', '–')}` : (t.text ?? t.value)}`)
160
162
  .join(', ');
161
163
  }
162
164
  /** The square itself: filled in its colour, or an outline for none. */
@@ -11,6 +11,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
11
11
  // - Triggers: `field` (a form select), `chip` (a compact header pill), `icon` (an icon button).
12
12
  // `LumenSelectorList` is the fourth, `none`: the list anchored to a text box (the assistant @ and /).
13
13
  // - Phone: a bottom sheet with the search on top and, for multiple, Done.
14
+ // - Desktop, multiple: a footer with the count, Clear all and Done (picks save as they're made).
14
15
  // - An option with a `target` (a card or a sidebar page) lights it on the page while hovered.
15
16
  import { Combobox as Base } from '@base-ui/react';
16
17
  import { CheckIcon, ChevronDownIcon, PlusIcon, SearchIcon, XIcon } from 'lucide-react';
@@ -255,7 +256,7 @@ export function LumenSelector(props) {
255
256
  // No ring on the first button: the person taps what they want.
256
257
  onOpenAutoFocus: (e) => e.preventDefault(), className: "group/selector flex max-h-[85dvh] flex-col gap-2 rounded-t-xl pt-2", children: [_jsx("span", { "aria-hidden": true, className: "mx-auto h-1 w-9 rounded-full bg-border" }), _jsxs("div", { className: "flex items-center justify-between px-4", children: [_jsx(SheetTitle, { id: sheetTitle, children: label ?? placeholder }), multiple && (_jsx(Button, { size: "sm", onClick: () => setOpen(false), children: "Done" }))] }), _jsx(SheetDescription, { className: "sr-only", children: multiple ? 'Pick any, then Done' : 'Pick one' }), _jsxs(Base.Root, { ...rootProps, inline: true, open: true, onOpenChange: () => undefined, multiple: multiple, value: multiple ? [...values] : (values[0] ?? null), onValueChange: (v) => pick(v), children: [multiple && values.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-1.5 px-4", children: values.map((v) => (_jsx("span", { className: "rounded-sm bg-muted px-2 py-1 text-[13px]", children: labelOf(v) }, v))) })), searchable && searchBox, list] }), multiple && (_jsxs("div", { className: "flex items-center justify-between border-t px-4 pt-3 pb-5", children: [_jsxs("span", { className: "text-xs text-muted-foreground", children: [values.length, " picked"] }), clearAll] }))] }) })] }));
257
258
  }
258
- const popup = (_jsx(Base.Portal, { container: host ?? undefined, children: _jsx(Base.Positioner, { side: "bottom", sideOffset: 4, align: align, anchor: inChips ? chipsRef : undefined, className: "pointer-events-auto isolate z-[60]", children: _jsxs(Base.Popup, { "data-slot": "lumen-selector", className: "group/selector max-w-(--available-width) min-w-[max(var(--anchor-width),14rem)] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", children: [!inChips && searchBox, list, clearAll && (_jsxs("div", { className: "flex items-center justify-between border-t px-2 py-1.5", children: [_jsxs("span", { className: "text-xs text-muted-foreground", children: [values.length, " picked", max !== undefined ? ` of ${max}` : ''] }), clearAll] }))] }) }) }));
259
+ const popup = (_jsx(Base.Portal, { container: host ?? undefined, children: _jsx(Base.Positioner, { side: "bottom", sideOffset: 4, align: align, anchor: inChips ? chipsRef : undefined, className: "pointer-events-auto isolate z-[60]", children: _jsxs(Base.Popup, { "data-slot": "lumen-selector", className: "group/selector max-w-(--available-width) min-w-[max(var(--anchor-width),14rem)] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", children: [!inChips && searchBox, list, multiple && (_jsxs("div", { className: "flex items-center gap-3 border-t px-2 py-1.5", children: [_jsxs("span", { className: "text-xs text-muted-foreground", children: [values.length, " picked", max !== undefined ? ` of ${max}` : ''] }), clearAll, _jsx(Button, { size: "xs", className: "ml-auto", onClick: () => setOpen(false), children: "Done" })] }))] }) }) }));
259
260
  return (_jsxs(Base.Root, { ...rootProps, multiple: multiple, value: multiple ? [...values] : (values[0] ?? null), onValueChange: (v) => pick(v), children: [inChips ? (_jsxs(Base.Chips, { ref: chipsRef, className: cn('flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent px-1 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30', className), children: [chips, _jsx(Base.Input, { id: id, "aria-label": label, disabled: disabled, readOnly: !searchable, placeholder: values.length ? '' : placeholder, className: "min-w-16 flex-1 bg-transparent px-1 outline-none placeholder:text-muted-foreground" })] })) : trigger === 'icon' ? (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx(Base.Trigger, { render: _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": label, className: className }), children: LeadIcon && _jsx(LeadIcon, {}) }) }), _jsx(TooltipContent, { children: label })] })) : (_jsx(Base.Trigger, { id: id, "data-size": size, "aria-label": label, className: cn(FIELD, trigger === 'chip' && 'rounded-full', className), children: fieldInside })), _jsx("span", { ref: markRef, hidden: true }), popup] }));
260
261
  }
261
262
  /**
@@ -88,6 +88,8 @@ export interface ServerGroups {
88
88
  more?: () => void;
89
89
  /** A group header's click: open or close it. */
90
90
  onToggle: (key: string) => void;
91
+ /** Expand all (true) or Collapse all (false): open or close every group listed. */
92
+ onToggleAll?: (open: boolean) => void;
91
93
  }
92
94
  export interface LumenViewProps<T> {
93
95
  /** The view's domain, its formula columns included (`withViewFields`). */
@@ -445,7 +445,7 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
445
445
  const edit = f.edit;
446
446
  const many = edit.many;
447
447
  if (many)
448
- 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, onSave: (values) => many(row, values) }));
448
+ 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) }));
449
449
  const save = edit.save;
450
450
  if (!save)
451
451
  return _jsx(ViewValueCell, { value: v, format: f.format, chip: chip });
@@ -531,7 +531,9 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
531
531
  const startsOpen = (i) => rows.length <= OPEN_ALL || i === 0;
532
532
  const isOpen = (key, i) => serverGroups ? !!served.get(key)?.open : startsOpen(i) !== flipped.has(key);
533
533
  const anyClosed = !!groups?.some(([k], i) => !isOpen(k, i));
534
- const setAll = (open) => setFlipped(new Set((groups ?? []).filter((_, i) => startsOpen(i) !== open).map(([k]) => k)));
534
+ const setAll = (open) => serverGroups
535
+ ? serverGroups.onToggleAll?.(open)
536
+ : setFlipped(new Set((groups ?? []).filter((_, i) => startsOpen(i) !== open).map(([k]) => k)));
535
537
  const keyOf = domain.rowKey;
536
538
  const toggle = (keys, on) => {
537
539
  if (!selection)
@@ -626,7 +628,11 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
626
628
  const g = served.get(key);
627
629
  const out = rowsOf(list, key);
628
630
  if (!g?.more && !g?.loading)
629
- return out;
631
+ return out.length
632
+ ? out
633
+ : [
634
+ _jsx(TableRow, { children: _jsx(TableCell, { colSpan: span, className: "py-2 text-center text-xs text-muted-foreground", children: "No rows match." }) }, `${key}:none`),
635
+ ];
630
636
  return [
631
637
  ...out,
632
638
  _jsx(TableRow, { "data-more": true, children: _jsx(TableCell, { colSpan: span, className: "py-1", children: g.loading ? (_jsx("span", { className: "px-2 text-xs text-muted-foreground", children: "Loading\u2026" })) : (_jsxs(Button, { variant: "ghost", size: "xs", onClick: g.more, children: [_jsx(ChevronsDownIcon, {}), "Show more", g.n !== null && (_jsxs("span", { className: "num font-normal text-muted-foreground", children: ["of ", (g.n - list.length).toLocaleString()] }))] })) }) }, `${key}:more`),
@@ -641,7 +647,7 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
641
647
  ? 'ascending'
642
648
  : h.column.getIsSorted() === 'desc'
643
649
  ? 'descending'
644
- : undefined, children: [i === 0 && groups && !serverGroups && (_jsx(IconButton, { icon: anyClosed ? ChevronsUpDownIcon : ChevronsDownUpIcon, size: "icon-xs", className: "-ml-2 mr-1 align-middle", label: anyClosed ? 'Expand all groups' : 'Collapse all groups', onClick: () => setAll(anyClosed) })), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "xs", className: cn('font-medium', !(i === 0 && groups) && '-mx-2'), style: { color: colorOf(domain, shown[i]) ?? undefined }, onClick: h.column.getCanSort() ? h.column.getToggleSortingHandler() : undefined, children: [shown[i] ? _jsx(FreshnessMark, { domain: domain, field: shown[i] }) : null, flexRender(h.column.columnDef.header, h.getContext()), _jsx(SortIcon, { dir: h.column.getIsSorted() })] }) }), shown[i] && (domain.groups?.length || freshnessOf(domain, shown[i])) ? (_jsxs(TooltipContent, { children: [domain.groups?.length ? _jsx("div", { children: pathOf(domain, shown[i]) }) : null, freshnessLine(domain, shown[i]) ? (_jsx("div", { children: freshnessLine(domain, shown[i]) })) : null] })) : null] })] }, h.id))), add && (_jsx(TableHead, { className: cn('w-8 pl-1', PINNED_HEAD), children: _jsx(AddColumnMenu, { add: add, fieldOpen: fieldOpen, onFieldOpen: setFieldOpen }) }))] }, hg.id))) }), _jsxs(TableBody, { children: [!rows.length && (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: span, className: "py-6 text-center text-muted-foreground", children: "No rows match." }) })), groups && groupBy
650
+ : undefined, children: [i === 0 && groups && (!serverGroups || serverGroups.onToggleAll) && (_jsx(IconButton, { icon: anyClosed ? ChevronsUpDownIcon : ChevronsDownUpIcon, size: "icon-xs", className: "-ml-2 mr-1 align-middle", label: anyClosed ? 'Expand all groups' : 'Collapse all groups', onClick: () => setAll(anyClosed) })), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "xs", className: cn('font-medium', !(i === 0 && groups) && '-mx-2'), style: { color: colorOf(domain, shown[i]) ?? undefined }, onClick: h.column.getCanSort() ? h.column.getToggleSortingHandler() : undefined, children: [shown[i] ? _jsx(FreshnessMark, { domain: domain, field: shown[i] }) : null, flexRender(h.column.columnDef.header, h.getContext()), _jsx(SortIcon, { dir: h.column.getIsSorted() })] }) }), shown[i] && (domain.groups?.length || freshnessOf(domain, shown[i])) ? (_jsxs(TooltipContent, { children: [domain.groups?.length ? _jsx("div", { children: pathOf(domain, shown[i]) }) : null, freshnessLine(domain, shown[i]) ? (_jsx("div", { children: freshnessLine(domain, shown[i]) })) : null] })) : null] })] }, h.id))), add && (_jsx(TableHead, { className: cn('w-8 pl-1', PINNED_HEAD), children: _jsx(AddColumnMenu, { add: add, fieldOpen: fieldOpen, onFieldOpen: setFieldOpen }) }))] }, hg.id))) }), _jsxs(TableBody, { children: [!rows.length && !serverGroups?.list?.length && (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: span, className: "py-6 text-center text-muted-foreground", children: "No rows match." }) })), groups && groupBy
645
651
  ? groups.map(([key, members], gi) => {
646
652
  const open = isOpen(key, gi);
647
653
  return (_jsxs(Fragment, { children: [_jsxs(TableRow, { className: ROLLUP, "data-group": key, children: [selection && (_jsx(TableCell, { className: cn('w-8 pr-0', PINNED_GROUP), children: _jsx(Checkbox, { checked: members.every((m) => selection.keys.has(keyOf(m.original)))
@@ -42,7 +42,8 @@ export function StackedColumns({ rows, x, parts, label, height = 220, tick = Str
42
42
  const total = (r) => parts.reduce((n, p) => n + (Number(r[p.key]) || 0), 0);
43
43
  // The highest part with a value carries the column's rounded cap.
44
44
  const top = (r) => parts.findLast((p) => Number(r[p.key]) > 0)?.key;
45
- return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(ChartContainer, { config: config, role: "img", "aria-label": label, className: "aspect-auto w-full", style: { height }, children: _jsxs(BarChart, { data: rows, margin: { left: 0, right: 0, top: 8, bottom: 0 }, children: [_jsx(CartesianGrid, { vertical: false, strokeDasharray: "3 3" }), _jsx(XAxis, { dataKey: x, tickLine: false, axisLine: false, tickMargin: 8, minTickGap: 12, tickFormatter: (v) => tick(String(v)) }), _jsx(YAxis, { tickLine: false, axisLine: false, width: 36, allowDecimals: false, tickFormatter: (v) => fmt(Number(v)) }), _jsx(ChartTooltip, { cursor: { fill: 'var(--muted)', opacity: 0.5 }, content: _jsx(ChartTooltipContent, { labelFormatter: (_, payload) => {
45
+ const ticks = niceTicks(Math.max(0, ...rows.map(total)));
46
+ return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(ChartContainer, { config: config, role: "img", "aria-label": label, className: "aspect-auto w-full", style: { height }, children: _jsxs(BarChart, { data: rows, margin: { left: 0, right: 0, top: 8, bottom: 0 }, children: [_jsx(CartesianGrid, { vertical: false, strokeDasharray: "3 3" }), _jsx(XAxis, { dataKey: x, tickLine: false, axisLine: false, tickMargin: 8, minTickGap: 12, tickFormatter: (v) => tick(String(v)) }), _jsx(YAxis, { tickLine: false, axisLine: false, domain: [0, ticks.at(-1) ?? 'auto'], ticks: ticks, width: axisWidth(ticks.map(fmt)), tickFormatter: (v) => fmt(Number(v)) }), _jsx(ChartTooltip, { cursor: { fill: 'var(--muted)', opacity: 0.5 }, content: _jsx(ChartTooltipContent, { labelFormatter: (_, payload) => {
46
47
  const r = payload?.[0]?.payload;
47
48
  return r ? (_jsxs("span", { className: "flex w-full gap-3", children: [_jsx("span", { children: tick(String(r[x])) }), _jsx("span", { className: "num ml-auto", children: fmt(total(r)) })] })) : null;
48
49
  }, formatter: (v, name) => (_jsxs("span", { className: "flex w-full items-center gap-2", children: [_jsx("span", { className: "size-2 shrink-0 rounded-sm", style: { background: config[String(name)]?.color } }), _jsx("span", { children: config[String(name)]?.label }), _jsx("span", { className: "num ml-auto pl-3", children: fmt(Number(v)) })] })) }) }), parts.map((p) => (_jsx(Bar, { dataKey: p.key, stackId: "a", fill: p.fill, isAnimationActive: false, shape: (b) => (_jsx(Rectangle, { ...b, radius: top(b.payload) === p.key ? CAP : 0 })) }, p.key)))] }) }), legend && (_jsx("ul", { className: "flex flex-wrap justify-center gap-x-3 gap-y-1 text-xs text-muted-foreground", children: parts.map((p) => (_jsxs("li", { className: "flex items-center gap-1.5", children: [_jsx("span", { className: "size-2 rounded-sm", style: { background: p.fill } }), p.name] }, p.key))) }))] }));
@@ -84,3 +85,18 @@ export function AllocationBar({ items, total, config, tooltip, share, }) {
84
85
  return (_jsxs("text", { x: Number(p.x) + 8, y: Number(p.y) + Number(p.height) / 2, dy: "0.35em", fontSize: 11, fontWeight: 600, style: { fill: 'var(--background)' }, children: [name, _jsx("tspan", { fontWeight: 400, dx: 6, className: "num", children: share(s.v) })] }));
85
86
  } }) }, s.key)))] }) }));
86
87
  }
88
+ /**
89
+ * Round axis steps (1, 2, 2.5 or 5 times a power of ten) from 0 to just above `max`, about four
90
+ * of them, so a value axis reads 0, 100k, 200k, 300k rather than 95k, 190k, 285k.
91
+ */
92
+ function niceTicks(max, count = 4) {
93
+ if (!(max > 0))
94
+ return [0, 1];
95
+ const raw = max / count;
96
+ const pow = 10 ** Math.floor(Math.log10(raw));
97
+ const step = ([1, 2, 2.5, 5, 10].find((m) => m * pow >= raw) ?? 10) * pow;
98
+ const n = Math.ceil(max / step - 1e-9);
99
+ return Array.from({ length: n + 1 }, (_, i) => Math.round(i * step * 1e6) / 1e6);
100
+ }
101
+ /** Room for the widest tick label (about 7px a character at text-xs), so none is clipped. */
102
+ const axisWidth = (labels) => Math.max(24, Math.max(0, ...labels.map((l) => l.length)) * 7 + 8);
@@ -174,7 +174,7 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
174
174
  key: string;
175
175
  value: string;
176
176
  not?: boolean | undefined;
177
- op?: "between" | "eq" | "gt" | "lt" | undefined;
177
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
178
178
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
179
179
  }[];
180
180
  source?: string | undefined;
@@ -237,7 +237,7 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
237
237
  key: string;
238
238
  value: string;
239
239
  not?: boolean | undefined;
240
- op?: "between" | "eq" | "gt" | "lt" | undefined;
240
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
241
241
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
242
242
  }[];
243
243
  source?: string | undefined;
@@ -272,7 +272,7 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
272
272
  key: string;
273
273
  value: string;
274
274
  not?: boolean | undefined;
275
- op?: "between" | "eq" | "gt" | "lt" | undefined;
275
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
276
276
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
277
277
  }[];
278
278
  source?: string | undefined;
@@ -297,7 +297,7 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
297
297
  key: string;
298
298
  value: string;
299
299
  not?: boolean | undefined;
300
- op?: "between" | "eq" | "gt" | "lt" | undefined;
300
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
301
301
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
302
302
  }[];
303
303
  sign: boolean;
@@ -310,7 +310,7 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
310
310
  key: string;
311
311
  value: string;
312
312
  not?: boolean | undefined;
313
- op?: "between" | "eq" | "gt" | "lt" | undefined;
313
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
314
314
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
315
315
  }[];
316
316
  suffix: string;
@@ -348,7 +348,7 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
348
348
  key: string;
349
349
  value: string;
350
350
  not?: boolean | undefined;
351
- op?: "between" | "eq" | "gt" | "lt" | undefined;
351
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
352
352
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
353
353
  }[];
354
354
  source?: string | undefined;
@@ -372,7 +372,7 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
372
372
  key: string;
373
373
  value: string;
374
374
  not?: boolean | undefined;
375
- op?: "between" | "eq" | "gt" | "lt" | undefined;
375
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
376
376
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
377
377
  }[];
378
378
  sign: boolean;
@@ -385,7 +385,7 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
385
385
  key: string;
386
386
  value: string;
387
387
  not?: boolean | undefined;
388
- op?: "between" | "eq" | "gt" | "lt" | undefined;
388
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
389
389
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
390
390
  }[];
391
391
  suffix: string;
@@ -418,7 +418,7 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
418
418
  key: string;
419
419
  value: string;
420
420
  not?: boolean | undefined;
421
- op?: "between" | "eq" | "gt" | "lt" | undefined;
421
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
422
422
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
423
423
  }[];
424
424
  source?: string | undefined;
@@ -455,7 +455,7 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
455
455
  key: string;
456
456
  value: string;
457
457
  not?: boolean | undefined;
458
- op?: "between" | "eq" | "gt" | "lt" | undefined;
458
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
459
459
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
460
460
  }[];
461
461
  source?: string | undefined;
@@ -490,7 +490,7 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
490
490
  key: string;
491
491
  value: string;
492
492
  not?: boolean | undefined;
493
- op?: "between" | "eq" | "gt" | "lt" | undefined;
493
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
494
494
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
495
495
  }[];
496
496
  source?: string | undefined;
@@ -525,7 +525,7 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
525
525
  key: string;
526
526
  value: string;
527
527
  not?: boolean | undefined;
528
- op?: "between" | "eq" | "gt" | "lt" | undefined;
528
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
529
529
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
530
530
  }[];
531
531
  source?: string | undefined;
@@ -563,7 +563,7 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
563
563
  key: string;
564
564
  value: string;
565
565
  not?: boolean | undefined;
566
- op?: "between" | "eq" | "gt" | "lt" | undefined;
566
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
567
567
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
568
568
  }[];
569
569
  source?: string | undefined;
@@ -598,7 +598,7 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
598
598
  key: string;
599
599
  value: string;
600
600
  not?: boolean | undefined;
601
- op?: "between" | "eq" | "gt" | "lt" | undefined;
601
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
602
602
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
603
603
  }[];
604
604
  source?: string | undefined;
@@ -631,7 +631,7 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
631
631
  key: string;
632
632
  value: string;
633
633
  not?: boolean | undefined;
634
- op?: "between" | "eq" | "gt" | "lt" | undefined;
634
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
635
635
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
636
636
  }[];
637
637
  source?: string | undefined;
@@ -664,7 +664,7 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
664
664
  key: string;
665
665
  value: string;
666
666
  not?: boolean | undefined;
667
- op?: "between" | "eq" | "gt" | "lt" | undefined;
667
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
668
668
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
669
669
  }[];
670
670
  source?: string | undefined;
@@ -701,7 +701,7 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
701
701
  key: string;
702
702
  value: string;
703
703
  not?: boolean | undefined;
704
- op?: "between" | "eq" | "gt" | "lt" | undefined;
704
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
705
705
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
706
706
  }[];
707
707
  source?: string | undefined;
package/dist/nav.d.ts CHANGED
@@ -50,8 +50,12 @@ export interface SectionSubItem {
50
50
  id: string;
51
51
  title: string;
52
52
  to: string;
53
- /** A colour dot before the title, matching the item's line on a chart. */
53
+ /** Its icon, drawn in `color` (a label's icon in the label's colour). */
54
+ icon?: NavIcon;
55
+ /** The icon's colour; without an icon, a colour dot before the title (a chart line's colour). */
54
56
  color?: string;
57
+ /** A count on the right, lined up with the section items' badges. */
58
+ badge?: ReactNode;
55
59
  }
56
60
  /**
57
61
  * A section of things a person keeps (views, trades, a watchlist): `+` adds one, the
package/dist/nav.js CHANGED
@@ -12,9 +12,33 @@ import { useFolded } from './fold.js';
12
12
  import { cn } from './lib/utils.js';
13
13
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible.js';
14
14
  import { SidebarGroup, SidebarGroupAction, SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuAction, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSkeleton, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, useSidebar, } from './ui/sidebar.js';
15
+ /**
16
+ * How well a link matches where the person is: -1 when it doesn't, else higher for a closer
17
+ * match. A link's query (`/resources?label=a`) must be in the URL too, and each matched key
18
+ * outranks a bare path, so `?label=a` beats `/resources` on `/resources?label=a`.
19
+ */
20
+ function useMatch() {
21
+ const { path, search } = useRouterState({
22
+ select: (s) => ({ path: s.location.pathname, search: s.location.searchStr }),
23
+ });
24
+ const here = new URLSearchParams(search);
25
+ return ({ to, exact }) => {
26
+ const [base = '', query] = to.split('?');
27
+ const want = new URLSearchParams(query);
28
+ for (const [k, v] of want)
29
+ if (here.get(k) !== v)
30
+ return -1;
31
+ const keys = [...want.keys()].length;
32
+ if (path === base)
33
+ return 2 + keys * 10;
34
+ if (!exact && path.startsWith(`${base.replace(/\/$/, '')}/`))
35
+ return 1 + keys * 10;
36
+ return -1;
37
+ };
38
+ }
15
39
  function useActive() {
16
- const path = useRouterState({ select: (s) => s.location.pathname });
17
- return ({ to, exact }) => path === to || (!exact && path.startsWith(`${to.replace(/\/$/, '')}/`));
40
+ const match = useMatch();
41
+ return (i) => match(i) >= 0;
18
42
  }
19
43
  /**
20
44
  * A sidebar group whose header folds its items: bold caps, no icon. At rest
@@ -36,13 +60,13 @@ export function NavGroup({ label, items, pageType, }) {
36
60
  }
37
61
  const SECTION_BADGE = 'num group-data-[collapsible=icon]:hidden';
38
62
  /** One section item: a link, its badge, and the chevron and nested items when it has any. */
39
- function SectionRow({ item, active, onRemove, removeLabel, }) {
63
+ function SectionRow({ item, activeId, onRemove, removeLabel, }) {
40
64
  const subs = item.items ?? [];
41
- const subActive = subs.some((s) => active({ to: s.to, exact: true }));
42
- const isActive = active({ to: item.to, exact: true }) && !subActive;
65
+ const subActive = activeId?.startsWith(`${item.id}/`) ?? false;
66
+ const isActive = activeId === item.id;
43
67
  // The open item shows no trash icon.
44
68
  const remove = item.fixed || isActive ? undefined : onRemove;
45
- const row = (_jsxs(_Fragment, { children: [_jsx(SidebarMenuButton, { asChild: true, tooltip: item.title, isActive: isActive, children: _jsxs(Link, { to: item.to, children: [_jsx(item.icon, {}), _jsx("span", { children: item.title })] }) }), item.badge !== undefined && item.badge !== null && (_jsx(SidebarMenuBadge, { className: cn(SECTION_BADGE, subs.length > 0 && 'right-7',
69
+ const row = (_jsxs(_Fragment, { children: [_jsx(SidebarMenuButton, { asChild: true, tooltip: item.title, isActive: isActive, children: _jsxs(Link, { to: item.to, children: [_jsx(item.icon, {}), _jsx("span", { children: item.title })] }) }), item.badge !== undefined && item.badge !== null && (_jsx(SidebarMenuBadge, { className: cn(SECTION_BADGE,
46
70
  // The remove icon shows on hover from md up, and always below it: make room there.
47
71
  remove &&
48
72
  'max-md:right-7 md:group-focus-within/menu-item:opacity-0 md:group-hover/menu-item:opacity-0'), children: item.badge })), remove && (_jsx(LumenConfirm, { title: `${removeLabel(item)}?`, action: "Remove", onConfirm: () => remove(item), children: _jsx(SidebarMenuAction, { showOnHover: true, "aria-label": removeLabel(item), title: removeLabel(item),
@@ -50,7 +74,10 @@ function SectionRow({ item, active, onRemove, removeLabel, }) {
50
74
  className: "text-muted-foreground peer-hover/menu-button:text-muted-foreground hover:bg-transparent hover:text-destructive", children: _jsx(Trash2Icon, {}) }) }))] }));
51
75
  if (!subs.length)
52
76
  return _jsx(SidebarMenuItem, { children: row });
53
- return (_jsx(Collapsible, { asChild: true, defaultOpen: item.defaultOpen || subActive || active(item), className: "group/collapsible", children: _jsxs(SidebarMenuItem, { children: [row, _jsx(CollapsibleTrigger, { asChild: true, children: _jsx(SidebarMenuAction, { "aria-label": `Show or hide ${item.title}`, className: "data-[state=open]:rotate-90", children: _jsx(ChevronRightIcon, {}) }) }), _jsx(CollapsibleContent, { children: _jsx(SidebarMenuSub, { children: subs.map((sub) => (_jsx(SidebarMenuSubItem, { children: _jsx(SidebarMenuSubButton, { asChild: true, isActive: active({ to: sub.to, exact: true }), children: _jsxs(Link, { to: sub.to, children: [sub.color && (_jsx("span", { "aria-hidden": true, className: "size-2 shrink-0 rounded-full", style: { background: sub.color } })), _jsx("span", { children: sub.title })] }) }) }, sub.id))) }) })] }) }));
77
+ return (_jsx(Collapsible, { asChild: true, defaultOpen: item.defaultOpen || subActive || isActive, className: "group/collapsible", children: _jsxs(SidebarMenuItem, { children: [row, _jsx(CollapsibleTrigger, { asChild: true, children: _jsx(SidebarMenuAction, { "aria-label": `Show or hide ${item.title}`, className: "right-auto left-1.5 bg-sidebar opacity-0 group-hover/menu-item:opacity-100 focus-visible:opacity-100 peer-data-[active=true]/menu-button:bg-sidebar-accent [&>svg]:transition-transform data-[state=open]:[&>svg]:rotate-90", children: _jsx(ChevronRightIcon, {}) }) }), _jsx(CollapsibleContent, { children: _jsx(SidebarMenuSub, { className: "mr-0 pr-0", children: subs.map((sub) => {
78
+ const hasBadge = sub.badge !== undefined && sub.badge !== null;
79
+ return (_jsxs(SidebarMenuSubItem, { children: [_jsx(SidebarMenuSubButton, { asChild: true, isActive: activeId === `${item.id}/${sub.id}`, className: cn(hasBadge && 'pr-8'), children: _jsxs(Link, { to: sub.to, title: sub.title, children: [sub.icon ? (_jsx("span", { "aria-hidden": true, className: "flex shrink-0 [&>svg]:size-4", style: { color: sub.color }, children: _jsx(sub.icon, {}) })) : (sub.color && (_jsx("span", { "aria-hidden": true, className: "mx-1 size-2 shrink-0 rounded-full", style: { background: sub.color } }))), _jsx("span", { children: sub.title })] }) }), hasBadge && (_jsx("span", { className: cn('pointer-events-none absolute top-1 right-1 flex h-5 min-w-5 items-center justify-center px-1 text-xs text-muted-foreground', typeof sub.badge === 'number' && 'num'), children: sub.badge }))] }, sub.id));
80
+ }) }) })] }) }));
54
81
  }
55
82
  /**
56
83
  * A section of things a person keeps (views, trades, a watchlist): `+` adds one, the
@@ -66,8 +93,22 @@ export function SidebarSection({ label, items, onAdd, addLabel = `Add to ${label
66
93
  type: assistantSubType ?? 'page',
67
94
  })),
68
95
  ]), label, pageType);
69
- const active = useActive();
96
+ const match = useMatch();
97
+ // One selected row per section: the closest match wins, the first on a tie, so two items that
98
+ // open the same page never both look selected. An open parent is never selected for being open.
99
+ let activeId = null;
100
+ let best = -1;
101
+ for (const i of items) {
102
+ const own = match({ to: i.to, exact: true });
103
+ if (own > best)
104
+ [best, activeId] = [own, i.id];
105
+ for (const sub of i.items ?? []) {
106
+ const m = match({ to: sub.to, exact: true });
107
+ if (m > best)
108
+ [best, activeId] = [m, `${i.id}/${sub.id}`];
109
+ }
110
+ }
70
111
  return (_jsx(FoldGroup, { label: label, action: onAdd && (_jsx(SidebarGroupAction, { title: addLabel, "aria-label": addLabel, onClick: onAdd, children: _jsx(PlusIcon, {}) })), children: _jsxs(SidebarGroupContent, { children: [_jsxs(SidebarMenu, { children: [loading
71
112
  ? [0, 1].map((i) => (_jsx(SidebarMenuItem, { children: _jsx(SidebarMenuSkeleton, { showIcon: true }) }, i)))
72
- : items.map((item) => (_jsx(SectionRow, { item: item, active: active, 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] }) }));
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] }) }));
73
114
  }
@@ -0,0 +1,12 @@
1
+ import type { ReactNode } from 'react';
2
+ export declare function ShareMeter({ value, label, color, wide, className, }: {
3
+ /** 0 to 1 fills the bar; past 1 it stays full. */
4
+ value: number;
5
+ /** The number beside the bar, e.g. `113%`; left out, the rounded percent. */
6
+ label?: ReactNode;
7
+ /** The bar's colour, a theme token (`var(--loss)` when over). */
8
+ color?: string;
9
+ /** Fills its cell (a list row) instead of the fixed table width. */
10
+ wide?: boolean;
11
+ className?: string;
12
+ }): import("react").JSX.Element;
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cn } from './lib/utils.js';
3
+ import { Progress } from './ui/progress.js';
4
+ export function ShareMeter({ value, label, color = 'var(--primary)', wide, className, }) {
5
+ return (_jsxs("span", { className: cn('inline-flex items-center gap-2', wide && 'w-full', className), children: [_jsx(Progress, { value: Math.max(0, Math.min(value, 1)) * 100, className: cn('h-1.5 shrink-0 [&>[data-slot=progress-indicator]]:bg-(--meter-color)', wide ? 'w-full shrink' : 'w-14'), style: { '--meter-color': color } }), _jsx("span", { className: "num w-11 shrink-0 text-right text-xs", children: label ?? `${Math.round(value * 100)}%` })] }));
6
+ }
package/dist/tile.js CHANGED
@@ -6,7 +6,7 @@ function TileBody({ label, value, caption, lines, valueClassName, }) {
6
6
  return (_jsxs(_Fragment, { children: [_jsx("span", { className: "w-full truncate text-xs text-muted-foreground", children: label }), _jsx("span", { className: cn('num truncate text-xl leading-7 font-semibold', valueClassName), children: value }), caption ? (_jsx("span", { className: "num line-clamp-2 text-xs text-muted-foreground", children: caption })) : null, lines?.map((l, i) => (_jsx("span", { className: "num line-clamp-2 text-xs text-muted-foreground", children: l }, i)))] }));
7
7
  }
8
8
  export function Tile({ label, value, caption, lines, className, valueClassName, }) {
9
- return (_jsx(Card, { size: "sm", className: cn('min-w-0 gap-0.5 px-3', className), children: _jsx(TileBody, { label: label, value: value, caption: caption, lines: lines, valueClassName: valueClassName }) }));
9
+ return (_jsx(Card, { size: "sm", className: cn('min-w-0 flex-1 basis-2/5 gap-0.5 px-3 lg:basis-auto', TILE_WIDTH, className), children: _jsx(TileBody, { label: label, value: value, caption: caption, lines: lines, valueClassName: valueClassName }) }));
10
10
  }
11
11
  /** Width of every header tile on desktop, toggle tiles and plain Tiles alike; shrinks to fit the row. */
12
12
  export const TILE_WIDTH = 'lg:w-(--tile-width) lg:flex-initial';
@@ -1,4 +1,4 @@
1
- import { type ReactNode } from 'react';
1
+ import { type ComponentType, type ReactNode } from 'react';
2
2
  export declare function EditCell({ label, value, options, unused, chip, note, colorOf, fixed, edited, onSave, }: {
3
3
  /** The field's name, e.g. `Basket`. */
4
4
  label: string;
@@ -23,13 +23,17 @@ export declare function EditCell({ label, value, options, unused, chip, note, co
23
23
  * a tap opens a picker that ticks any number and takes a typed name, and closing it saves them all
24
24
  * at once. Clicks stay off the row, which opens its details.
25
25
  */
26
- export declare function EditManyCell({ label, values, options, unused, note, colorOf, onSave, }: {
26
+ export declare function EditManyCell({ label, values, options, unused, note, colorOf, iconOf, onSave, }: {
27
27
  label: string;
28
28
  values: string[];
29
29
  options: string[];
30
30
  unused?: string[];
31
31
  note?: string;
32
32
  colorOf?: (value: string) => string | undefined;
33
+ /** A value's icon (a label's), drawn in its colour on its chip and in the picker. */
34
+ iconOf?: (value: string) => ComponentType<{
35
+ className?: string;
36
+ }> | undefined;
33
37
  onSave: (values: string[]) => Promise<unknown>;
34
38
  }): import("react").JSX.Element;
35
39
  /**
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // opens its details.
5
5
  import { PencilLineIcon, PlusIcon, XIcon } from 'lucide-react';
6
6
  import { useEffect, useRef, useState } from 'react';
7
- import { ColorChip, colorIcon } from './color-dot.js';
7
+ import { ColorChip, colorIcon, tintedIcon } from './color-dot.js';
8
8
  import { cn } from './lib/utils.js';
9
9
  import { LumenSelector } from './lumen-selector.js';
10
10
  import { Input } from './ui/input.js';
@@ -69,7 +69,7 @@ export function EditCell({ label, value, options, unused = [], chip, note, color
69
69
  * a tap opens a picker that ticks any number and takes a typed name, and closing it saves them all
70
70
  * at once. Clicks stay off the row, which opens its details.
71
71
  */
72
- export function EditManyCell({ label, values, options, unused = [], note, colorOf, onSave, }) {
72
+ export function EditManyCell({ label, values, options, unused = [], note, colorOf, iconOf, onSave, }) {
73
73
  const [open, setOpen] = useState(false);
74
74
  const [draft, setDraft] = useState(values);
75
75
  const [error, setError] = useState(null);
@@ -99,7 +99,12 @@ export function EditManyCell({ label, values, options, unused = [], note, colorO
99
99
  };
100
100
  const row = (o) => {
101
101
  const c = colorOf?.(o);
102
- return { value: o, label: o, ...(c ? { icon: colorIcon(c) } : {}) };
102
+ const i = iconOf?.(o);
103
+ return {
104
+ value: o,
105
+ label: o,
106
+ ...(i ? { icon: tintedIcon(i, c) } : c ? { icon: colorIcon(c) } : {}),
107
+ };
103
108
  };
104
109
  const all = [...options, ...unused];
105
110
  const picked = draft.filter((v) => !all.includes(v));
@@ -107,7 +112,7 @@ export function EditManyCell({ label, values, options, unused = [], note, colorO
107
112
  { label: 'In use', options: [...options, ...picked].map(row) },
108
113
  { label: 'Unused', options: unused.map(row) },
109
114
  ];
110
- return (_jsxs("span", { className: "inline-flex min-w-0 items-center gap-1", onClick: stop, onKeyDown: stop, children: [values.length > 0 && (_jsx("button", { type: "button", "aria-label": `${label}: ${values.join(', ')}`, className: "flex min-w-0 flex-wrap items-center gap-1 rounded text-left", onClick: start, children: values.map((v) => (_jsx(ColorChip, { color: colorOf?.(v), children: v }, v))) })), _jsx(LumenSelector, { multiple: true, trigger: "icon", icon: values.length ? PencilLineIcon : PlusIcon, size: "sm", label: values.length ? `Change ${label.toLowerCase()}` : `Add ${label.toLowerCase()}`, className: cn('size-6 text-muted-foreground', values.length
115
+ return (_jsxs("span", { className: "inline-flex min-w-0 items-center gap-1", onClick: stop, onKeyDown: stop, children: [values.length > 0 && (_jsx("button", { type: "button", "aria-label": `${label}: ${values.join(', ')}`, className: "flex min-w-0 items-center gap-1 overflow-hidden rounded text-left", onClick: start, children: values.map((v) => (_jsx(ColorChip, { color: colorOf?.(v), icon: iconOf?.(v), children: v }, v))) })), _jsx(LumenSelector, { multiple: true, trigger: "icon", icon: values.length ? PencilLineIcon : PlusIcon, size: "sm", label: values.length ? `Change ${label.toLowerCase()}` : `Add ${label.toLowerCase()}`, className: cn('size-6 text-muted-foreground', values.length
111
116
  ? 'opacity-0 group-hover/row:opacity-100 focus-visible:opacity-100 max-md:opacity-100'
112
117
  : '-ml-1'), groups: groups, value: draft, disabled: saving, empty: `Type a ${label.toLowerCase()} name.`, create: (q) => q.trim() || null, createLabel: (q) => `Add “${q.trim()}”`, open: open, onOpenChange: (o) => {
113
118
  if (o)
@@ -1,7 +1,7 @@
1
1
  import type { JoinKey } from '@columenlabs/lumen-ui-types/domains';
2
2
  import { type CustomViewConfig, type ViewAgg, type ViewChip, type ViewFormat, type ViewTotalAgg } from '@columenlabs/lumen-ui-types/views';
3
3
  import type { LucideIcon } from 'lucide-react';
4
- import type { ReactNode } from 'react';
4
+ import type { ComponentType, ReactNode } from 'react';
5
5
  import type { ChartBand, ChartMarker, Series } from './growth-chart.js';
6
6
  export type ViewCellValue = number | string | null;
7
7
  /** One field of a data domain: how to read it, show it, filter, group and roll it up. */
@@ -107,6 +107,10 @@ export interface ViewField<T> {
107
107
  * cell, its chip and the edit picker. A CSS colour; a theme token reads in light and dark.
108
108
  */
109
109
  color?: (value: string) => string | undefined;
110
+ /** A text value's icon (a label's), drawn in its `color` on its chip and in the edit picker. */
111
+ iconOf?: (value: string) => ComponentType<{
112
+ className?: string;
113
+ }> | undefined;
110
114
  /** The cell's background, a theme colour mix (a P/L heat grid). */
111
115
  tint?: (row: T) => string | undefined;
112
116
  /** Left out on phones, where the table has no room for it (a returns table's longer windows). */
@@ -323,7 +327,7 @@ export declare const tilesOf: (c: CustomViewConfig) => ({
323
327
  key: string;
324
328
  value: string;
325
329
  not?: boolean | undefined;
326
- op?: "between" | "eq" | "gt" | "lt" | undefined;
330
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
327
331
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
328
332
  }[];
329
333
  sign: boolean;
@@ -336,7 +340,7 @@ export declare const tilesOf: (c: CustomViewConfig) => ({
336
340
  key: string;
337
341
  value: string;
338
342
  not?: boolean | undefined;
339
- op?: "between" | "eq" | "gt" | "lt" | undefined;
343
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
340
344
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
341
345
  }[];
342
346
  suffix: string;
package/dist/whats-new.js CHANGED
@@ -3,7 +3,8 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
3
3
  // every app): the version in the status bar opens the release notes, newest
4
4
  // first, each release's Key takeaways only. An app turns it on with LumenShell's `releases`, its
5
5
  // releases API (`GET ?limit=&before=` answering `{releases}`), which leaves out what the person can't
6
- // open; a host that answers 404 (self-host an app) keeps the version hidden. With `apps`, each
6
+ // open; a host that answers 404 (self-host an app) keeps the button hidden. Until the first release
7
+ // the button reads "What's new" and opens an empty state, so a new app shows it from day one. With `apps`, each
7
8
  // takeaway carries a chip of the app it is about (`apps` on the takeaway; none: everyone), and a
8
9
  // release where one app has `GROUP_AT` or more is split into one sub-section per app.
9
10
  import { useInfiniteQuery } from '@tanstack/react-query';
@@ -50,12 +51,13 @@ export function WhatsNew({ api, apps, everyone = 'Global', }) {
50
51
  staleTime: 30 * 60_000,
51
52
  retry: false,
52
53
  });
53
- const releases = q.data?.pages.flatMap((p) => p ?? []) ?? [];
54
- const latest = releases[0];
55
- if (!latest)
54
+ // Hidden while loading, on an error, and when the host has no releases API (404).
55
+ if (!q.data || q.data.pages[0] === null)
56
56
  return null;
57
+ const releases = q.data.pages.flatMap((p) => p ?? []);
58
+ const latest = releases[0];
57
59
  const day = (at) => new Date(at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone });
58
- 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 }), _jsx(LumenDrawer, { open: open, onClose: () => setOpen(false), title: "What's new", description: "Key takeaways of each release, newest first", footerStart: `${releases.length} releases`, 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.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))) })] }));
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)))] })] }));
59
61
  }
60
62
  /** One release's takeaways: a flat list, or one sub-section per app once one app has many. */
61
63
  function Notes({ takeaways, apps, everyone, }) {
@@ -72,7 +72,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
72
72
  key: string;
73
73
  value: string;
74
74
  not?: boolean | undefined;
75
- op?: "between" | "eq" | "gt" | "lt" | undefined;
75
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
76
76
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
77
77
  }[];
78
78
  source?: string | undefined;
@@ -135,7 +135,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
135
135
  key: string;
136
136
  value: string;
137
137
  not?: boolean | undefined;
138
- op?: "between" | "eq" | "gt" | "lt" | undefined;
138
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
139
139
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
140
140
  }[];
141
141
  source?: string | undefined;
@@ -170,7 +170,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
170
170
  key: string;
171
171
  value: string;
172
172
  not?: boolean | undefined;
173
- op?: "between" | "eq" | "gt" | "lt" | undefined;
173
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
174
174
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
175
175
  }[];
176
176
  source?: string | undefined;
@@ -195,7 +195,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
195
195
  key: string;
196
196
  value: string;
197
197
  not?: boolean | undefined;
198
- op?: "between" | "eq" | "gt" | "lt" | undefined;
198
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
199
199
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
200
200
  }[];
201
201
  sign: boolean;
@@ -208,7 +208,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
208
208
  key: string;
209
209
  value: string;
210
210
  not?: boolean | undefined;
211
- op?: "between" | "eq" | "gt" | "lt" | undefined;
211
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
212
212
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
213
213
  }[];
214
214
  suffix: string;
@@ -246,7 +246,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
246
246
  key: string;
247
247
  value: string;
248
248
  not?: boolean | undefined;
249
- op?: "between" | "eq" | "gt" | "lt" | undefined;
249
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
250
250
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
251
251
  }[];
252
252
  source?: string | undefined;
@@ -270,7 +270,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
270
270
  key: string;
271
271
  value: string;
272
272
  not?: boolean | undefined;
273
- op?: "between" | "eq" | "gt" | "lt" | undefined;
273
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
274
274
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
275
275
  }[];
276
276
  sign: boolean;
@@ -283,7 +283,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
283
283
  key: string;
284
284
  value: string;
285
285
  not?: boolean | undefined;
286
- op?: "between" | "eq" | "gt" | "lt" | undefined;
286
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
287
287
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
288
288
  }[];
289
289
  suffix: string;
@@ -316,7 +316,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
316
316
  key: string;
317
317
  value: string;
318
318
  not?: boolean | undefined;
319
- op?: "between" | "eq" | "gt" | "lt" | undefined;
319
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
320
320
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
321
321
  }[];
322
322
  source?: string | undefined;
@@ -353,7 +353,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
353
353
  key: string;
354
354
  value: string;
355
355
  not?: boolean | undefined;
356
- op?: "between" | "eq" | "gt" | "lt" | undefined;
356
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
357
357
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
358
358
  }[];
359
359
  source?: string | undefined;
@@ -388,7 +388,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
388
388
  key: string;
389
389
  value: string;
390
390
  not?: boolean | undefined;
391
- op?: "between" | "eq" | "gt" | "lt" | undefined;
391
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
392
392
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
393
393
  }[];
394
394
  source?: string | undefined;
@@ -423,7 +423,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
423
423
  key: string;
424
424
  value: string;
425
425
  not?: boolean | undefined;
426
- op?: "between" | "eq" | "gt" | "lt" | undefined;
426
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
427
427
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
428
428
  }[];
429
429
  source?: string | undefined;
@@ -461,7 +461,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
461
461
  key: string;
462
462
  value: string;
463
463
  not?: boolean | undefined;
464
- op?: "between" | "eq" | "gt" | "lt" | undefined;
464
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
465
465
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
466
466
  }[];
467
467
  source?: string | undefined;
@@ -493,7 +493,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
493
493
  key: string;
494
494
  value: string;
495
495
  not?: boolean | undefined;
496
- op?: "between" | "eq" | "gt" | "lt" | undefined;
496
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
497
497
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
498
498
  }[];
499
499
  source?: string | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.44",
3
+ "version": "0.1.46",
4
4
  "description": "Lumen: shared React components (frame, views, widgets, charts, drawers).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -41,7 +41,7 @@
41
41
  "@codemirror/lang-javascript": "6.2.5",
42
42
  "@codemirror/lang-json": "6.0.2",
43
43
  "@codemirror/lang-sql": "6.10.0",
44
- "@columenlabs/lumen-ui-types": "0.1.44",
44
+ "@columenlabs/lumen-ui-types": "0.1.46",
45
45
  "@dnd-kit/core": "6.3.1",
46
46
  "@dnd-kit/sortable": "10.0.0",
47
47
  "@dnd-kit/utilities": "3.2.2",