Lumen
A grid that lights up the bits of your data you couldn't see at a glance
Overview
The shape Lumen ships in: NL filter, sentiment-tinted review column, and a live insight pill. One LLM call per Enter; the rest is cache.
Install
pnpm add lumen-grid# ornpm install lumen-gridOne npm dependency. lumen-grid ships the virtualizer, inference cache, filter engine, LLM adapters, Grid, NLFilter, and the insight pill. Peer deps: react ≥ 18, react-dom ≥ 18.
Minimal example
'use client'
import { Grid } from 'lumen-grid'
const data = [ { id: 1, photo: 'https://…/a.jpg', notes: 'Loved it, great support team.' }, { id: 2, photo: 'https://…/b.jpg', notes: 'Defective after a week, returning.' },]
const columns = [ { field: 'photo', header: 'Image', width: 96 }, { field: 'notes', header: 'Review', width: 360 },]
export function Demo() { return ( <Grid data={data} columns={columns} getRowId={(r) => r.id} aiConfig={{ provider: 'anthropic', apiKey: process.env.NEXT_PUBLIC_ANTHROPIC_KEY!, }} height={560} /> )}The photo column auto-detects as image; notes promotes to richtext once a row exceeds 80 characters. Both start analysing as soon as they mount.
30-second mental model
| Piece | What it does |
|---|---|
Grid | The React component you render. Owns the virtualizer, header, rows, NL filter, insight pill. |
InferenceCache | A Map of rowId::field → { status, data }. Every AI result lives here. |
VisionPlugin / RichTextPlugin | Fire on cell mount (lazy). Write to the cache. |
NLFilter | Compiles user text into a FilterState[] via one LLM call, then evaluates client-side against the cache. |
inferencePersistence | Optional. Writes done entries to sessionStorage / localStorage, keyed by content fingerprint. |
- Virtualizer mounts rows in view.
- AI-eligible cells (
image,richtext, longtext) enqueue inference jobs. - Results stream into the cache; cells and row tints update.
- NL filter (if used) reads from the same cache, with no extra API calls for filtering on AI fields.
Grid props
interface GridProps<T> { // ── Data ──────────────────────────────────────────── data: T[] columns: Column<T>[] getRowId?: (row: T) => string | number onRowClick?: (row: T) => void
// ── AI ────────────────────────────────────────────── aiConfig?: AIConfig inferencePersistence?: { scope: string storage?: 'sessionStorage' | 'localStorage' // default 'sessionStorage' } enableNLFilter?: boolean // defaults to true when aiConfig is set enableInsightBar?: boolean // defaults to true when aiConfig is set enableVisionPopover?: boolean // default: false
// ── Layout ────────────────────────────────────────── rowHeight?: number // default: theme.cell.height (42) height?: number // default: 500
// ── Theme ─────────────────────────────────────────── appearance?: 'light' | 'dark' | 'system' // default 'light' theme?: Partial<Theme>
// ── Slots & styling ───────────────────────────────── className?: string style?: CSSProperties scrollAreaClassName?: string scrollAreaStyle?: CSSProperties toolbar?: ReactNode}Columns
interface Column<T> { field: keyof T & string header: string type?: 'text' | 'number' | 'date' | 'boolean' | 'image' | 'richtext' | 'url' | 'unknown' width?: number // px, default 150 minWidth?: number // px, default 80 sortable?: boolean // default true cell?: (value: unknown, row: T) => ReactNode}Type inference
If you omit type, Lumen samples the first 20 rows.
- Looks like an image URL, or field name is
photo/avatar/thumbnail→image. - All values pass
Number(...)→number. - All values parse as dates →
date. - Boolean-shaped values →
boolean. - Any value > 80 chars →
richtext. - All values are http(s) URLs but not images →
url. - Default:
text.
AI eligibility
image→VisionPlugin(description, tags, dominant colors).richtext→RichTextPlugin(summary, sentiment, key phrases).textwith a row value > 60 chars also runsRichTextPlugin.- Everything else renders without AI.
AI: provider & persistence
AIConfig
interface AIConfig { provider: 'anthropic' | 'openai' apiKey: string model?: string // optional override debug?: boolean // [lumen] console logs terminalLogRelayUrl?: string // POST logs to a dev server URL}Defaults
anthropic→claude-3-5-sonnet-latestopenai→gpt-4o-mini
Browser CORS
- Anthropic requires the
anthropic-dangerous-direct-browser-accessheader. The adapter handles it. - OpenAI is typically CORS-blocked from the browser. Use a thin proxy in production or move calls server-side.
Persistence (skips paid calls on reload)
<Grid … inferencePersistence={{ scope: 'support-tickets', // unique per dataset / page storage: 'localStorage', // default 'sessionStorage' }}/>- Each
doneentry stores{ fingerprint, result }. - Fingerprint = stable hash of the raw cell value (length-prefixed for strings, JSON-stringified for objects).
- On reload, only entries whose fingerprint still matches the current row data are restored.
- Pass
getRowIdfor stable keys; otherwise Lumen falls back to row-index and persistence breaks on reorder.
Debug logs
aiConfig={{ provider: 'anthropic', apiKey: '…', debug: true, terminalLogRelayUrl: 'http://localhost:5173/__lumen-logs', // optional}}Logs prefix with [lumen]. Categories: vision:*, richtext:*, nlFilter:*, cache:*.
Themes
<Grid appearance="light" /> // default<Grid appearance="dark" /><Grid appearance="system" /> // follows prefers-color-scheme
<Grid theme={{ colors: { accent: '#ff5722', rowHover: '#fffaf5' }, cell: { height: 48 }, fontFamily: '"IBM Plex Sans", system-ui', borderRadius: '10px', }}/>theme is deep-merged onto the active base palette.
Host CSS isolation
The grid root carries a lumen-grid class. Lumen injects one <style> tag that forces descendant text elements to inherit colour and size from their immediate parent. This beats host body span rules without using !important, so inline overrides still win.
Natural-language filter
The bar above the grid. Users type, press Enter, and the LLM compiles their query into typed clauses.
English → typed clauses in one call. From here it's pure JS over the InferenceCache.
Filter ops
| Op | Applies to | Notes |
|---|---|---|
eq / neq | any | Strict equality. |
contains / not_contains | text | Case-insensitive substring. |
gt / gte / lt / lte | number, date | Coerces strings when needed. |
is_true / is_false | boolean | - |
ai_sentiment_positive / ai_sentiment_negative | richtext | Reads cache[row, field].sentiment. |
ai_contains | richtext | LLM-style match against summary + keyPhrases. |
ai_tag | image, richtext | Case-insensitive match across tags and dominantColors. |
Example queries
| User types | Compiled FilterState[] |
|---|---|
| "unhappy reviews" | [{ field: 'review', op: 'ai_sentiment_negative' }] |
| "orders over £500 delivered" | [{ field: 'total', op: 'gt', value: 500 }, { field: 'status', op: 'eq', value: 'Delivered' }] |
| "red products" | [{ field: 'photo', op: 'ai_tag', value: 'red' }] |
Cost model
- One LLM call per Enter press (the compile).
- Zero calls per row evaluated: the
ai_*ops read fromInferenceCacheonly. - If a cell hasn't finished analysing yet, it drops out of
ai_*filters until it completes, then reappears automatically.
Insight pill
A floating badge in the bottom-right that shows n analysed cells. Click to expand the summary (cell count + sentiment breakdown). Auto-hides when there is nothing to report. Pulses while analysis is in flight.
Toggle off with enableInsightBar={false}.
Custom cells
const columns: Column<Row>[] = [ { field: 'status', header: 'Status', width: 120, cell: (value, row) => ( <Pill tone={value === 'Delivered' ? 'success' : 'warn'}> {String(value)} </Pill> ), },]A cell function takes over rendering. The AI pipeline is bypassed for that column; useful for status pills, action buttons, links, money formatters.
Pitfalls
- Forgetting
'use client'in App Router. Lumen uses hooks andwindow; the error usually shows up as "hooks can only be called inside a component." - Forgetting
getRowId. Works fine until rows reorder. - CORS on OpenAI: calls from the browser are blocked by default. Use a proxy.
- API key in the client bundle; anything in
NEXT_PUBLIC_*is shipped to users. Acceptable for demos, never for production. - Re-creating
aiConfiginline on every render; memoise it. - Duplicate column
fieldstill works (with an index suffix) but check the console warning.
Versioning & roadmap
Lumen is v0.1.x, pre-1.0. The public API may change between minor versions; pin your install.
Before 1.0:
- Benchmark suite (10k / 100k row passes).
- Keyboard navigation + a11y pass.
- Streaming results into partial UI (currently in
lumen-gridbut unused). - More plugins (date inference, address geocoding, classification).
- Server-side cache rehydration helper.
Contributing
git clone https://github.com/rbnnghs/lumen.gitcd lumenpnpm installpnpm typecheckpnpm buildIssues and PRs welcome. License: see LICENSE in the repo.