Lumen

A grid that lights up the bits of your data you couldn't see at a glance

Overview

https://www.robinn.io
Describe what to show, press Enter
Product
Category
Price (£)
Status
Review
Merino Wool Jacket
Outerwear
189
Delivered
Absolutely love this jacket — fit is perfect, feels pr…
Ember Travel Mug
Kitchen
49
Delivered
Keeps coffee hot for hours. Lid leaks slightly when ti…
Trail Runner Pro
Footwear
134
Returned
Sizing runs two full sizes small. Sent back immediately.
Oak Desk Organiser
Home Office
145
Delivered
Solid build, looks great. Slots fit pens exactly as de…
Studio Headphones
Electronics
220
Delivered
Sound is exceptional, but the clamping force gives me …
Cedar & Smoke Candle
Home
28
Delivered
Scent fills the whole room without being overpowering.
0/6analysed

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

TerminalShell
pnpm add lumen-grid
# or
npm install lumen-grid

One 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

app/demo.tsxTSX
'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

PieceWhat it does
GridThe React component you render. Owns the virtualizer, header, rows, NL filter, insight pill.
InferenceCacheA Map of rowId::field → { status, data }. Every AI result lives here.
VisionPlugin / RichTextPluginFire on cell mount (lazy). Write to the cache.
NLFilterCompiles user text into a FilterState[] via one LLM call, then evaluates client-side against the cache.
inferencePersistenceOptional. Writes done entries to sessionStorage / localStorage, keyed by content fingerprint.
  1. Virtualizer mounts rows in view.
  2. AI-eligible cells (image, richtext, long text) enqueue inference jobs.
  3. Results stream into the cache; cells and row tints update.
  4. NL filter (if used) reads from the same cache, with no extra API calls for filtering on AI fields.

Grid props

GridPropsTypeScript
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

Column<T>TypeScript
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).
  • text with a row value > 60 chars also runs RichTextPlugin.
  • Everything else renders without AI.

AI: provider & persistence

AIConfig

AIConfigTypeScript
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-latest
  • openai gpt-4o-mini

Browser CORS

  • Anthropic requires the anthropic-dangerous-direct-browser-access header. 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)

TSX
<Grid
inferencePersistence={{
scope: 'support-tickets', // unique per dataset / page
storage: 'localStorage', // default 'sessionStorage'
}}
/>
  • Each done entry 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 getRowId for stable keys; otherwise Lumen falls back to row-index and persistence breaks on reorder.

Debug logs

TSX
aiConfig={{
provider: 'anthropic',
apiKey: '…',
debug: true,
terminalLogRelayUrl: 'http://localhost:5173/__lumen-logs', // optional
}}

Logs prefix with [lumen]. Categories: vision:*, richtext:*, nlFilter:*, cache:*.

Themes

TSX
<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.

1
2
3
Natural-language filter
Describe what to show, press Enter
⏎ Enter
[lumen] nlFilter:compileone LLM call · parses intent only
FilterState[] = [
{ field: 'review', op: 'ai_sentiment_negative' },
{ field: 'category', op: 'eq', value: 'Kitchen' }
]
applyFilters(rows, clauses) — pure JS, reads from InferenceCache
1 model call per Enter press · 0 per row evaluated
0/ row

English → typed clauses in one call. From here it's pure JS over the InferenceCache.

Filter ops

OpApplies toNotes
eq / neqanyStrict equality.
contains / not_containstextCase-insensitive substring.
gt / gte / lt / ltenumber, dateCoerces strings when needed.
is_true / is_falseboolean-
ai_sentiment_positive / ai_sentiment_negativerichtextReads cache[row, field].sentiment.
ai_containsrichtextLLM-style match against summary + keyPhrases.
ai_tagimage, richtextCase-insensitive match across tags and dominantColors.

Example queries

User typesCompiled 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 from InferenceCache only.
  • 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

columnsTSX
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 and window; 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 aiConfig inline on every render; memoise it.
  • Duplicate column field still 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-grid but unused).
  • More plugins (date inference, address geocoding, classification).
  • Server-side cache rehydration helper.

Contributing

TerminalShell
git clone https://github.com/rbnnghs/lumen.git
cd lumen
pnpm install
pnpm typecheck
pnpm build

Issues and PRs welcome. License: see LICENSE in the repo.