@columenlabs/lumen-ui 0.1.31 → 0.1.33

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.
@@ -1,12 +1,15 @@
1
1
  import { type ReactNode } from 'react';
2
2
  /** A backfill's state on a block it touches; it wins over coverage while the job runs. */
3
- export type CoverageJobState = 'queued' | 'running' | 'failed' | 'canceled';
3
+ /** `indexing`: the block's copy is done and a follow-up step (an index, a rollup) is still running on it. */
4
+ export type CoverageJobState = 'queued' | 'running' | 'indexing' | 'failed' | 'canceled';
4
5
  /** One cell's state. Done is not its own state: a finished block simply turns `full`. */
5
6
  export type CoverageLevel = 'full' | 'high' | 'low' | 'gap' | 'current' | 'none' | CoverageJobState;
7
+ /** `swatch`: the legend's look when it differs from a cell's (a part-filled cell fills to its share). */
6
8
  export declare const COVERAGE_LEVELS: {
7
9
  level: CoverageLevel;
8
10
  label: string;
9
11
  className: string;
12
+ swatch?: string;
10
13
  }[];
11
14
  export declare const coverageLabel: (l: CoverageLevel) => string;
12
15
  export declare const coverageClass: (l: CoverageLevel) => string;
@@ -18,18 +21,22 @@ export declare function coverageLevel(have: number, expected: number, { current,
18
21
  /** The job state that shows when a block holds several. */
19
22
  export declare const firstJobState: (a: CoverageJobState | null, b: CoverageJobState | undefined) => CoverageJobState | null;
20
23
  /** The states on screen, in the legend's order, with a hint at the right. */
21
- export declare function CoverageLegend({ levels, hint }: {
24
+ export declare function CoverageLegend({ levels, hint, labels, }: {
22
25
  levels: Set<CoverageLevel>;
23
26
  hint?: ReactNode;
27
+ /** An app's own words for some states (`running`: `Copying`). */
28
+ labels?: Partial<Record<CoverageLevel, string>>;
24
29
  }): import("react").JSX.Element;
25
30
  /**
26
31
  * One cell: a plain button. Its tooltip is the map's one shared `CoverageTip` (a Radix tooltip per
27
32
  * cell made a 100-row map 13,000 tooltips, seconds per render). `label` is the
28
33
  * accessible name and the tooltip's first line; `tip` marks the cell for the shared tooltip.
29
34
  */
30
- export declare const CoverageCell: import("react").MemoExoticComponent<({ level, label, tip, picked, disabled, pickable, onClick, className, }: {
35
+ export declare const CoverageCell: import("react").MemoExoticComponent<({ level, label, tip, picked, disabled, pickable, onClick, className, fill, }: {
31
36
  level: CoverageLevel;
32
37
  label: string;
38
+ /** The share in hand (0–1): a part-done block fills to it from the left, whatever its state. */
39
+ fill?: number;
33
40
  /** What `CoverageTip` hands back to its `content` for this cell. */
34
41
  tip: string;
35
42
  picked?: boolean;
@@ -4,10 +4,21 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  import { memo, useRef, useState } from 'react';
5
5
  import { cn } from './lib/utils.js';
6
6
  import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip.js';
7
+ /** `swatch`: the legend's look when it differs from a cell's (a part-filled cell fills to its share). */
7
8
  export const COVERAGE_LEVELS = [
8
9
  { level: 'full', label: 'Complete', className: 'bg-cov-full' },
9
- { level: 'high', label: '95–99%', className: 'bg-cov-high' },
10
- { level: 'low', label: 'Under 95%', className: 'bg-cov-low' },
10
+ {
11
+ level: 'high',
12
+ label: '95–99%',
13
+ className: 'ring-1 ring-inset ring-cov-full',
14
+ swatch: 'bg-[linear-gradient(90deg,var(--cov-full)_0_80%,transparent_80%)] ring-1 ring-inset ring-cov-full',
15
+ },
16
+ {
17
+ level: 'low',
18
+ label: 'Under 95%',
19
+ className: 'ring-1 ring-inset ring-cov-full',
20
+ swatch: 'bg-[linear-gradient(90deg,var(--cov-full)_0_40%,transparent_40%)] ring-1 ring-inset ring-cov-full',
21
+ },
11
22
  { level: 'gap', label: 'Missing', className: 'bg-gap-hatch' },
12
23
  {
13
24
  level: 'current',
@@ -21,13 +32,18 @@ export const COVERAGE_LEVELS = [
21
32
  className: 'outline-1 outline-dashed -outline-offset-1 outline-primary',
22
33
  },
23
34
  { level: 'running', label: 'Running', className: 'bg-cov-running' },
24
- { level: 'failed', label: 'Failed', className: 'bg-destructive' },
35
+ { level: 'indexing', label: 'Indexing', className: 'bg-cov-indexing' },
36
+ {
37
+ level: 'failed',
38
+ label: 'Failed',
39
+ className: 'bg-destructive/15 outline-2 -outline-offset-2 outline-destructive',
40
+ },
25
41
  { level: 'canceled', label: 'Canceled', className: 'bg-cov-canceled' },
26
42
  ];
27
43
  const BY_LEVEL = Object.fromEntries(COVERAGE_LEVELS.map((l) => [l.level, l]));
28
44
  export const coverageLabel = (l) => BY_LEVEL[l].label;
29
45
  export const coverageClass = (l) => BY_LEVEL[l].className;
30
- const JOB_ORDER = ['running', 'queued', 'failed', 'canceled'];
46
+ const JOB_ORDER = ['running', 'indexing', 'queued', 'failed', 'canceled'];
31
47
  /** The state of a block from its sums: a job state first, then coverage. */
32
48
  export function coverageLevel(have, expected, { current = false, state } = {}) {
33
49
  if (state)
@@ -43,22 +59,22 @@ export function coverageLevel(have, expected, { current = false, state } = {}) {
43
59
  /** The job state that shows when a block holds several. */
44
60
  export const firstJobState = (a, b) => !b ? a : !a || JOB_ORDER.indexOf(b) < JOB_ORDER.indexOf(a) ? b : a;
45
61
  /** The states on screen, in the legend's order, with a hint at the right. */
46
- export function CoverageLegend({ levels, hint }) {
47
- return (_jsxs("div", { className: "flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-muted-foreground", children: [COVERAGE_LEVELS.filter((l) => levels.has(l.level)).map((l) => (_jsxs("span", { className: "flex items-center gap-1.5", children: [_jsx("span", { "aria-hidden": true, className: cn('h-3 w-4 rounded-[3px]', l.className) }), l.label] }, l.level))), hint && _jsx("span", { className: "ml-auto hidden md:inline", children: hint })] }));
62
+ export function CoverageLegend({ levels, hint, labels, }) {
63
+ return (_jsxs("div", { className: "flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-muted-foreground", children: [COVERAGE_LEVELS.filter((l) => levels.has(l.level)).map((l) => (_jsxs("span", { className: "flex items-center gap-1.5", children: [_jsx("span", { "aria-hidden": true, className: cn('h-3 w-4 rounded-[3px]', l.swatch ?? l.className) }), labels?.[l.level] ?? l.label] }, l.level))), hint && _jsx("span", { className: "ml-auto hidden md:inline", children: hint })] }));
48
64
  }
49
65
  /**
50
66
  * One cell: a plain button. Its tooltip is the map's one shared `CoverageTip` (a Radix tooltip per
51
67
  * cell made a 100-row map 13,000 tooltips, seconds per render). `label` is the
52
68
  * accessible name and the tooltip's first line; `tip` marks the cell for the shared tooltip.
53
69
  */
54
- export const CoverageCell = memo(function CoverageCell({ level, label, tip, picked = false, disabled = false, pickable = false, onClick, className, }) {
55
- return (_jsx("button", { type: "button", "data-cell": tip, "aria-label": `${label}: ${coverageLabel(level)}`, "aria-pressed": pickable && !disabled ? picked : undefined, disabled: disabled, onClick: onClick, className: cn('min-w-0 rounded-[3px] p-0 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default', coverageClass(level),
70
+ export const CoverageCell = memo(function CoverageCell({ level, label, tip, picked = false, disabled = false, pickable = false, onClick, className, fill, }) {
71
+ return (_jsx("button", { type: "button", "data-cell": tip, "aria-label": `${label}: ${coverageLabel(level)}`, "aria-pressed": pickable && !disabled ? picked : undefined, disabled: disabled, onClick: onClick, className: cn('min-w-0 rounded-[3px] p-0 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default', coverageClass(level), 'relative overflow-hidden',
56
72
  // outline-none would also hide the picked outline, so a picked cell must not carry it.
57
73
  picked
58
74
  ? 'outline-2 outline-offset-1 outline-foreground'
59
- : level === 'queued'
75
+ : level === 'queued' || level === 'failed'
60
76
  ? ''
61
- : 'outline-none', pickable && !disabled && 'cursor-pointer', className) }));
77
+ : 'outline-none', pickable && !disabled && 'cursor-pointer', className), children: fill !== undefined && fill > 0 && fill < 1 && (_jsx("span", { "aria-hidden": true, className: "pointer-events-none absolute inset-y-0 left-0 bg-cov-full", style: { width: `${Math.round(fill * 100)}%` } })) }));
62
78
  });
63
79
  /**
64
80
  * The one tooltip of a coverage map: it follows the cell under the pointer or keyboard focus inside
@@ -81,5 +97,5 @@ export function CoverageTip({ content, children, className, }) {
81
97
  : { tip, x: r.left - o.left, y: r.top - o.top, w: r.width, h: r.height });
82
98
  };
83
99
  const body = at ? content(at.tip) : null;
84
- return (_jsxs("div", { ref: box, className: cn('relative', className), onPointerOver: (e) => e.pointerType !== 'touch' && show(e.target), onPointerLeave: () => setAt(null), onFocus: (e) => show(e.target), onBlur: () => setAt(null), children: [children, at && body && (_jsxs(Tooltip, { open: true, children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx("span", { "aria-hidden": true, className: "pointer-events-none absolute", style: { left: at.x, top: at.y, width: at.w, height: at.h } }) }), _jsxs(TooltipContent, { className: "num max-w-72", children: [_jsx("div", { className: "font-medium", children: body.label }), body.detail] })] }, at.tip))] }));
100
+ return (_jsxs("div", { ref: box, className: cn('relative', className), onPointerOver: (e) => e.pointerType !== 'touch' && show(e.target), onPointerLeave: () => setAt(null), onFocus: (e) => show(e.target), onBlur: () => setAt(null), children: [children, at && body && (_jsxs(Tooltip, { open: true, children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx("span", { "aria-hidden": true, className: "pointer-events-none absolute", style: { left: at.x, top: at.y, width: at.w, height: at.h } }) }), _jsx(TooltipContent, { className: "num max-w-72", children: _jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx("div", { className: "font-medium", children: body.label }), body.detail] }) })] }, at.tip))] }));
85
101
  }
@@ -1,7 +1,7 @@
1
1
  import type { QueryKey, UseQueryOptions } from '@tanstack/react-query';
2
2
  import { type LucideIcon } from 'lucide-react';
3
3
  import { type ReactNode } from 'react';
4
- import { type CoverageJobState } from './coverage-cells.js';
4
+ import { type CoverageJobState, type CoverageLevel } from './coverage-cells.js';
5
5
  /** One count at the finest grain a page has: a day (`2026-10-12`) or a month (`2026-10`). */
6
6
  export interface CoverageFact {
7
7
  row: string;
@@ -72,11 +72,22 @@ export interface LumenCoverageMapProps<TRaw = unknown, TKey extends QueryKey = Q
72
72
  unit?: string;
73
73
  /** Right-hand columns; a phone keeps Complete only. */
74
74
  summary?: CoverageSummary[];
75
- /** `backfill`: click, shift-click a range, then Queue backfill. `drill`: a click opens the block. */
75
+ /** `backfill`: click, shift-click a range, then the selection bar's buttons. `drill`: a click opens the block. */
76
76
  select?: 'backfill' | 'drill';
77
77
  /** Months a backfill can take (the source's own limits). */
78
78
  selectable?: (row: string, month: string) => boolean;
79
79
  onQueue?: (pick: CoveragePick) => Promise<unknown> | undefined;
80
+ /** The selection bar's queue button (`Queue backfill`). */
81
+ queueLabel?: string;
82
+ /** What the months to queue come to, shown after their count (`1,240 messages · about 2 h`). */
83
+ pickNote?: (pick: CoveragePick) => string;
84
+ /** Months already queued that a pick can take back out, with the bar's Remove from queue. */
85
+ removable?: (row: string, month: string) => boolean;
86
+ onRemove?: (pick: CoveragePick) => Promise<unknown> | undefined;
87
+ /** An app's own words for some states, in the tooltip and legend (`{ running: 'Copying' }`). */
88
+ stateLabels?: Partial<Record<CoverageLevel, string>>;
89
+ /** Picks these months as if clicked (pass a new object each time); nothing is queued until the bar's button. */
90
+ preselect?: CoveragePick;
80
91
  /** The drilled block (`2026-09`) and the click that changes it. */
81
92
  drilled?: string | null;
82
93
  onDrill?: (row: string, period: string) => void;
@@ -110,4 +121,4 @@ export interface LumenCoverageMapProps<TRaw = unknown, TKey extends QueryKey = Q
110
121
  export declare function blockOf(period: string, block: CoverageBlock): string;
111
122
  /** `2026-09` → `Sep 2026`, `2026-Q3` → `2026 Q3`, days stay as they are. */
112
123
  export declare function blockName(key: string): string;
113
- export declare function LumenCoverageMap<TRaw, TKey extends QueryKey>({ source, title, icon, rows: rowSpec, layout, block: initialBlock, zoom, window, unit, summary, select, selectable, onQueue, drilled, onDrill, groupBy, hint, pageSize: initialPageSize, pager, tools, filter, filterCount, empty, lazy, }: LumenCoverageMapProps<TRaw, TKey>): import("react").JSX.Element;
124
+ export declare function LumenCoverageMap<TRaw, TKey extends QueryKey>({ source, title, icon, rows: rowSpec, layout, block: initialBlock, zoom, window, unit, summary, select, selectable, onQueue, queueLabel, pickNote, removable, onRemove, preselect, stateLabels, drilled, onDrill, groupBy, hint, pageSize: initialPageSize, pager, tools, filter, filterCount, empty, lazy, }: LumenCoverageMapProps<TRaw, TKey>): import("react").JSX.Element;
@@ -1,16 +1,16 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Link } from '@tanstack/react-router';
3
- import { Grid3x3Icon, ListPlusIcon, XIcon } from 'lucide-react';
4
- import { isValidElement, memo, useLayoutEffect, useMemo, useRef, useState, } from 'react';
3
+ import { Grid3x3Icon, ListPlusIcon, ListXIcon } from 'lucide-react';
4
+ import { isValidElement, memo, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
5
5
  import { CoverageCell, CoverageLegend, CoverageTip, coverageLabel, coverageLevel, firstJobState, } from './coverage-cells.js';
6
6
  import { FoldChevron, useFolds, usePageKey } from './fold.js';
7
7
  import { useIsMobile } from './hooks/use-mobile.js';
8
- import { IconButton } from './icon-button.js';
9
8
  import { cn } from './lib/utils.js';
10
9
  import { LumenStates, useLumenSource } from './lumen-source.js';
11
10
  import { LumenWidget } from './lumen-widget.js';
12
11
  import { LumenPager } from './pager.js';
13
12
  import { Badge } from './ui/badge.js';
13
+ import { Button } from './ui/button.js';
14
14
  import { Skeleton } from './ui/skeleton.js';
15
15
  import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.js';
16
16
  const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
@@ -189,7 +189,7 @@ const MapRow = memo(function MapRow({ r, cols, marks, picks, total, look, }) {
189
189
  const m = marks[i];
190
190
  if (!m)
191
191
  return _jsx("span", {}, k);
192
- return (_jsx(CoverageCell, { level: m.level, label: `${text} · ${blockName(k)}`, tip: `${r.row}|${k}`, picked: picks.includes(k) || look.drilled === k, pickable: look.select === 'backfill' || look.select === 'drill', disabled: look.select === 'backfill' ? !m.canPick : look.select !== 'drill', className: "h-[18px]" }, k));
192
+ return (_jsx(CoverageCell, { level: m.level, fill: m.fill, label: `${text} · ${blockName(k)}`, tip: `${r.row}|${k}`, picked: picks.includes(k) || look.drilled === k, pickable: look.select === 'backfill' || look.select === 'drill', disabled: look.select === 'backfill' ? !m.canPick : look.select !== 'drill', className: "h-[18px]" }, k));
193
193
  }), look.sums.map((s) => (_jsx("span", { className: cn('num pl-3 text-right text-xs whitespace-nowrap', s !== 'complete' && 'text-muted-foreground'), children: s === 'complete'
194
194
  ? fmtPct(total.have, total.expected)
195
195
  : s === 'first'
@@ -200,7 +200,7 @@ const MapRow = memo(function MapRow({ r, cols, marks, picks, total, look, }) {
200
200
  ? fmtCounts(total.have, total.expected)
201
201
  : fmtInt(total.have) }, s)))] }));
202
202
  }, (a, b) => a.sig === b.sig && sameNode(a.r.badge, b.r.badge));
203
- export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowSpec, layout = 'grid', block: initialBlock = 'month', zoom, window = 'all', unit = 'sessions', summary = ['complete', 'last'], select, selectable = () => true, onQueue, drilled = null, onDrill, groupBy = false, hint, pageSize: initialPageSize, pager, tools, filter, filterCount, empty, lazy = false, }) {
203
+ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowSpec, layout = 'grid', block: initialBlock = 'month', zoom, window = 'all', unit = 'sessions', summary = ['complete', 'last'], select, selectable = () => true, onQueue, queueLabel = 'Queue backfill', pickNote, removable = () => false, onRemove, preselect, stateLabels, drilled = null, onDrill, groupBy = false, hint, pageSize: initialPageSize, pager, tools, filter, filterCount, empty, lazy = false, }) {
204
204
  const { data, load: raw, ref } = useLumenSource(source, { lazy });
205
205
  // A coverage map refreshes in place: any refresh, asked for or a poll, only spins its ↻; no bar or dimming over the cells.
206
206
  const load = { ...raw, fetching: raw.fetching || raw.background };
@@ -210,6 +210,11 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
210
210
  const [picked, setPicked] = useState(new Set());
211
211
  const [queueing, setQueueing] = useState(false);
212
212
  const anchor = useRef(null);
213
+ // A preselect picks its months as if clicked; it replaces the current pick.
214
+ useEffect(() => {
215
+ if (preselect)
216
+ setPicked(new Set(preselect.flatMap((p) => p.months.map((m) => `${p.row}|${m}`))));
217
+ }, [preselect]);
213
218
  const scroller = useRef(null);
214
219
  const [pageSize, setPageSize] = useState(initialPageSize ?? 0);
215
220
  const [pageIndex, setPageIndex] = useState(0);
@@ -248,14 +253,15 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
248
253
  }
249
254
  return next;
250
255
  });
251
- const pickedMonths = () => {
256
+ const pickedMonths = (keep = selectable) => {
252
257
  const byRow = new Map();
253
258
  for (const id of picked) {
254
- const [row, key] = id.split('|');
255
- const c = folded?.cells.get(row)?.get(key);
259
+ const cut = id.lastIndexOf('|');
260
+ const row = id.slice(0, cut);
261
+ const c = folded?.cells.get(row)?.get(id.slice(cut + 1));
256
262
  const set = byRow.get(row) ?? new Set();
257
263
  for (const m of c?.months ?? [])
258
- if (selectable(row, m))
264
+ if (keep(row, m))
259
265
  set.add(m);
260
266
  byRow.set(row, set);
261
267
  }
@@ -263,12 +269,13 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
263
269
  .filter(([, ms]) => ms.size)
264
270
  .map(([row, ms]) => ({ row, months: [...ms].sort() }));
265
271
  };
266
- const queue = async () => {
267
- if (!onQueue)
272
+ const queue = async (remove = false) => {
273
+ const act = remove ? onRemove : onQueue;
274
+ if (!act)
268
275
  return;
269
276
  setQueueing(true);
270
277
  try {
271
- await onQueue(pickedMonths());
278
+ await act(pickedMonths(remove ? removable : selectable));
272
279
  setPicked(new Set());
273
280
  }
274
281
  finally {
@@ -288,9 +295,14 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
288
295
  const text = data?.rows.find((r) => r.row === row)?.label ?? row;
289
296
  return {
290
297
  label: `${text} · ${blockName(k)}`,
291
- detail: (_jsxs(_Fragment, { children: [_jsxs("div", { children: [unitLabel(c.have, c.expected), " \u00B7 ", coverageLabel(c.level)] }), c.notes.map((n) => (_jsx("div", { children: n }, n))), layout === 'months' && select === 'drill' && _jsx("div", { children: "Click to see its days" })] })),
298
+ detail: (_jsxs(_Fragment, { children: [_jsxs("div", { children: [unitLabel(c.have, c.expected), " \u00B7 ", stateLabels?.[c.level] ?? coverageLabel(c.level)] }), c.notes.map((n) => (_jsx("div", { children: n }, n))), layout === 'months' && select === 'drill' && _jsx("div", { children: "Click to see its days" })] })),
292
299
  };
293
300
  };
301
+ /** A block a pick can take: a month in it can be queued, or taken out of the queue. */
302
+ const canPick = (row, key) => !!folded?.cells
303
+ .get(row)
304
+ ?.get(key)
305
+ ?.months.some((m) => selectable(row, m) || (onRemove !== undefined && removable(row, m)));
294
306
  // One click handler for every cell: drill, or pick (shift-click picks a range in the row).
295
307
  const onCellClick = (e) => {
296
308
  const el = e.target instanceof Element ? e.target.closest('[data-cell]') : null;
@@ -306,10 +318,13 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
306
318
  }
307
319
  if (select !== 'backfill')
308
320
  return;
309
- const i = cols.indexOf(k);
321
+ // The months layout ranges over its months in time order, across years.
322
+ const order = layout === 'months' ? [...(folded?.cells.get(row)?.keys() ?? [])].sort() : cols;
323
+ const i = order.indexOf(k);
310
324
  if (e.shiftKey && anchor.current?.row === row) {
311
325
  const [lo, hi] = [anchor.current.index, i].sort((x, y) => x - y);
312
- toggle(row, cols.slice(lo, hi + 1), 'add');
326
+ const span = order.slice(lo, hi + 1);
327
+ toggle(row, span.filter((key) => canPick(row, key)), 'add');
313
328
  }
314
329
  else
315
330
  toggle(row, [k], 'toggle');
@@ -338,7 +353,7 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
338
353
  levels.add(c.level);
339
354
  have += c.have;
340
355
  expected += c.expected;
341
- return (_jsx(CoverageCell, { level: c.level, label: `${row} · ${blockName(key)}`, tip: `${row}|${key}`, picked: drilled === key, pickable: select === 'drill', disabled: select !== 'drill', className: mobile ? 'h-4' : 'h-5' }, key));
356
+ return (_jsx(CoverageCell, { level: c.level, fill: c.expected > 0 ? c.have / c.expected : undefined, label: `${row} · ${blockName(key)}`, tip: `${row}|${key}`, picked: picked.has(`${row}|${key}`) || drilled === key, pickable: select === 'backfill' || select === 'drill', disabled: select === 'backfill' ? !canPick(row, key) : select !== 'drill', className: mobile ? 'h-4' : 'h-5' }, key));
342
357
  }),
343
358
  ...sums.map((s) => (_jsx("span", { className: cn('num pl-3 text-right text-xs', s !== 'complete' && 'text-muted-foreground'), children: s === 'complete'
344
359
  ? fmtPct(have, expected)
@@ -392,8 +407,11 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
392
407
  if (!c)
393
408
  return null;
394
409
  levels.add(c.level);
395
- const canPick = select === 'backfill' && c.months.some((m) => selectable(r.row, m));
396
- return { level: c.level, canPick };
410
+ return {
411
+ level: c.level,
412
+ canPick: select === 'backfill' && canPick(r.row, k),
413
+ fill: c.expected > 0 ? c.have / c.expected : undefined,
414
+ };
397
415
  });
398
416
  const picks = pickedBy.get(r.row) ?? [];
399
417
  out.push(_jsx(MapRow, { r: r, cols: cols, marks: marks, picks: picks, total: folded.totals.get(r.row) ?? { have: 0, expected: 0 }, look: look, sig: [
@@ -411,7 +429,9 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
411
429
  cols[0],
412
430
  cols.at(-1),
413
431
  picks.join(),
414
- marks.map((m) => (m ? `${m.level}${m.canPick ? '+' : ''}` : '')).join(),
432
+ marks
433
+ .map((m) => (m ? `${m.level}${m.canPick ? '+' : ''}${m.fill ?? ''}` : ''))
434
+ .join(),
415
435
  JSON.stringify(folded.totals.get(r.row) ?? null),
416
436
  ].join('\u0001') }, r.row));
417
437
  return out;
@@ -419,7 +439,13 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
419
439
  }
420
440
  }
421
441
  const pick = picked.size ? pickedMonths() : [];
422
- const pickedCount = pick.reduce((n, p) => n + p.months.length, 0);
442
+ const queueCount = pick.reduce((n, p) => n + p.months.length, 0);
443
+ const unpick = picked.size && onRemove ? pickedMonths(removable) : [];
444
+ const removeCount = unpick.reduce((n, p) => n + p.months.length, 0);
445
+ const pickedCount = queueCount + removeCount;
446
+ const pickedRows = [...new Set([...pick, ...unpick].map((p) => p.row))];
447
+ const rowName = (row) => data?.rows.find((r) => r.row === row)?.label ?? row;
448
+ const months = (n) => `${n} month${n > 1 ? 's' : ''}`;
423
449
  const head = (_jsxs(_Fragment, { children: [tools, zoom && layout === 'grid' && (_jsx(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, value: block, onValueChange: (v) => {
424
450
  if (!v)
425
451
  return;
@@ -431,9 +457,9 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
431
457
  setPageSize(n);
432
458
  setPageIndex(0);
433
459
  }, noun: plural(rowSpec.title) })))] }));
434
- return (_jsx("div", { ref: ref, className: "min-w-0", children: _jsx(LumenWidget, { id: title, kind: null, icon: icon, title: title, tools: head, filter: filter, filterCount: filterCount, load: load, rows: data?.rows.length, pad: false, children: _jsx("div", { className: "flex flex-col gap-3 px-3 pt-3 pb-3", children: _jsxs(LumenStates, { load: cells, skeleton: _jsx(GridSkeleton, {}), empty: data && !data.rows.length && !data.facts.length ? empty : undefined, children: [_jsx("div", { ref: scroller, className: "overflow-x-auto overflow-y-hidden pb-1", children: _jsx(CoverageTip, { content: tipFor, className: "w-max min-w-full", children: body }) }), pickedCount > 0 && (_jsxs("div", { className: "flex flex-wrap items-center gap-2 rounded-lg bg-muted px-3 py-2 text-sm", children: [_jsxs("span", { className: "min-w-0 truncate", children: [_jsx("span", { className: "num font-semibold", children: pick.map((p) => p.row).join(', ') }), ` · ${pickedCount} month${pickedCount > 1 ? 's' : ''}`] }), _jsx("span", { className: "flex-1" }), _jsx(IconButton, { icon: XIcon, label: "Clear selection", onClick: () => setPicked(new Set()) }), _jsx(IconButton, { icon: ListPlusIcon, label: `Queue backfill · ${pickedCount} month${pickedCount > 1 ? 's' : ''}`, variant: "default", disabled: queueing || !onQueue, onClick: () => void queue() })] })), _jsx("div", { className: "border-t pt-3", children: _jsx(CoverageLegend, { levels: levels, hint: hint ??
460
+ return (_jsx("div", { ref: ref, className: "min-w-0", children: _jsx(LumenWidget, { id: title, kind: null, icon: icon, title: title, tools: head, filter: filter, filterCount: filterCount, load: load, rows: data?.rows.length, pad: false, children: _jsx("div", { className: "flex flex-col gap-3 px-3 pt-3 pb-3", children: _jsxs(LumenStates, { load: cells, skeleton: _jsx(GridSkeleton, {}), empty: data && !data.rows.length && !data.facts.length ? empty : undefined, children: [_jsx("div", { ref: scroller, className: "overflow-x-auto overflow-y-hidden pb-1", children: _jsx(CoverageTip, { content: tipFor, className: "w-max min-w-full", children: body }) }), pickedCount > 0 && (_jsxs("div", { className: "flex flex-wrap items-center gap-2 rounded-lg bg-muted px-3 py-2 text-sm", children: [_jsxs("span", { className: "min-w-0", children: [layout === 'grid' && (_jsx("span", { className: "num font-semibold", children: `${pickedRows.map(rowName).join(', ')} · ` })), `${months(pickedCount)} picked`, pickNote && queueCount > 0 ? ` · ${pickNote(pick)}` : ''] }), _jsx("span", { className: "flex-1" }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => setPicked(new Set()), children: "Clear" }), removeCount > 0 && (_jsxs(Button, { variant: "outline", size: "sm", disabled: queueing, onClick: () => void queue(true), children: [_jsx(ListXIcon, {}), " Remove from queue \u00B7 ", months(removeCount)] })), queueCount > 0 && (_jsxs(Button, { size: "sm", disabled: queueing || !onQueue, onClick: () => void queue(), children: [_jsx(ListPlusIcon, {}), " ", queueLabel, " \u00B7 ", months(queueCount)] }))] })), _jsx("div", { className: "border-t pt-3", children: _jsx(CoverageLegend, { levels: levels, labels: stateLabels, hint: hint ??
435
461
  (select === 'backfill'
436
- ? 'Click a cell, shift-click for a range, to queue a backfill'
462
+ ? 'Click a block, shift-click for a range'
437
463
  : select === 'drill'
438
464
  ? 'Click a block to see its days'
439
465
  : undefined) }) })] }) }) }) }));
package/dist/theme.css CHANGED
@@ -397,6 +397,9 @@
397
397
  background-size: 11.3px 11.3px;
398
398
  animation: lumen-cov-run 1s linear infinite;
399
399
  }
400
+ .bg-cov-indexing {
401
+ background: repeating-linear-gradient(135deg, var(--cov-high) 0 4px, var(--cov-low) 4px 8px);
402
+ }
400
403
  .bg-cov-canceled {
401
404
  background: linear-gradient(
402
405
  135deg,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
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.31",
44
+ "@columenlabs/lumen-ui-types": "0.1.33",
45
45
  "@dnd-kit/core": "6.3.1",
46
46
  "@dnd-kit/sortable": "10.0.0",
47
47
  "@dnd-kit/utilities": "3.2.2",