AG-Grid Patterns for TMNL
Critical: AG-Grid v34 Module Registration
Without this, grid renders blank. No exceptions.
import { ModuleRegistry, AllCommunityModule } from 'ag-grid-community' ModuleRegistry.registerModules([AllCommunityModule])
No CSS imports needed when using the theme prop.
Canonical Sources
Core Library (v2)
-
Compound component: src/lib/data-grid/components/UnifiedDataGrid.tsx
-
Context system: src/lib/data-grid/components/DataGridContext.tsx
-
Theme composer: src/lib/data-grid/composer/theme-composer.ts
-
Flash system: src/lib/data-grid/flash/index.ts
-
Cell renderers: src/lib/data-grid/renderers/
-
Variants: src/lib/data-grid/variants/
-
Barrel export: src/lib/data-grid/index.ts
tldraw Integration
-
V2 shape (modern): src/components/tldraw/shapes/data-grid-shape-v2.tsx
-
V1 shape (legacy): src/components/tldraw/shapes/data-grid-shape.tsx
-
Hybrid drag: src/components/tldraw/shapes/data-grid-shape.tsx:366-594
Architecture Documentation
- Deep dive: assets/documents/AG_GRID_THEMING_ARCHITECTURE.md
Pattern 1: Tmnl.DataGrid — COMPOUND COMPONENT API
When: Building any data grid in TMNL.
The modern API uses compound components for declarative composition.
import { Tmnl } from '@/lib/data-grid' import { tmnlDenseDark } from '@/lib/data-grid/variants/tmnl-dense-dark'
<Tmnl.DataGrid id="emitters" variant={tmnlDenseDark} rowData={data} columnDefs={columnDefs}
<Tmnl.DataGrid.Header> <Tmnl.DataGrid.Title title="EMITTERS" badge={data.length} /> <Tmnl.DataGrid.SettingsButton onClick={openSettings} /> </Tmnl.DataGrid.Header>
<Tmnl.DataGrid.Body />
<Tmnl.DataGrid.StatusBar> <span className="text-cyan-500">V2</span> <span>{data.length} rows</span> </Tmnl.DataGrid.StatusBar>
<Tmnl.DataGrid.CornerDecorations /> </Tmnl.DataGrid>
Child Components
Component Purpose Props
Header
Container for title/controls children
Title
Grid title with optional badge title, badge?
SettingsButton
Settings action button onClick
Body
The actual AG-Grid (none - uses context)
StatusBar
Footer status area children
CornerDecorations
Visual corner accents variant?
Per-Grid Runtime Isolation
Each <Tmnl.DataGrid> creates its own Effect runtime with isolated services:
// Inside Tmnl.DataGrid provider const runtime = useMemo(() => createDataGridRuntime(), [gridId])
// Child components access via context const ctx = useDataGridContext() const variant = ctx.variant // Reactive variant const gridApi = ctx.gridApi // Reactive grid API
Canonical source: src/lib/data-grid/components/UnifiedDataGrid.tsx
Pattern 2: GridVariant System — VARIANT-DRIVEN THEMING
When: Customizing grid appearance, density, or behavior.
Variants are not just themes. They encode:
-
Density — Row height, font size, padding
-
Colors — Background, text, signals, flash
-
Behavior — Selection, sorting, drag, resize
-
Typography — Font family, letter spacing, weight
Variant Structure
export const tmnlDenseDark: GridVariant = { id: 'tmnl-dense-dark', densityTier: 'dense', density: DENSITY_PRESETS.dense, colorScheme: 'dark',
colors: { background: { base: '#000000', alternateRow: '#0a0a0a', header: '#0d0d0d' }, text: { primary: '#ffffff', secondary: '#a3a3a3', muted: '#525252' }, signal: { positive: '#22c55e', negative: '#ef4444', accent: '#00ffcc' }, border: { primary: '#262626', muted: '#1a1a1a' }, flash: { up: 'rgba(34, 197, 94, 0.4)', down: 'rgba(239, 68, 68, 0.4)', durationMs: 1500, }, },
behavior: BEHAVIOR_PRESETS.interactive,
typography: { fontFamily: "'JetBrains Mono', monospace", headerLetterSpacing: '0.05em', },
intentOverrides: { // Per-column styling rules }, }
Density Presets
Preset Row Height Font Size Padding
dense
20px 10px 4px
compact
24px 11px 6px
normal
32px 13px 8px
comfortable
40px 14px 12px
Behavior Presets
Preset Selection Sorting Drag Resize
readonly
none true false false
interactive
single true true true
editable
multiple true true true
minimal
none false false false
Variant → Theme Conversion
import { composeAgGridTheme } from '@/lib/data-grid/composer/theme-composer'
const agTheme = composeAgGridTheme(tmnlDenseDark) // Returns AG-Grid themeQuartz.withParams({...})
Canonical source: src/lib/data-grid/variants/tmnl-dense-dark.ts
Pattern 3: Flash System — CELL CHANGE HIGHLIGHTING
When: Showing real-time data updates with visual feedback.
Severity Mapping
Delta Severity Visual Effect
0 none No flash
1-5 low Subtle background
6-10 medium Visible pulse
11-15 high Strong glow
16+ critical Full glow + pulse
Flash State Structure
interface FlashState { severity: 'none' | 'low' | 'medium' | 'high' | 'critical' intensity: number // 0-1, logarithmic scale direction: 'up' | 'down' | 'neutral' delta: number timestamp: number isActive: boolean }
useFlashTracker Hook
import { useFlashTracker } from '@/lib/data-grid/flash'
const { getFlashState, hasFlash, processUpdates, injectKeyframes } = useFlashTracker({ maxDelta: 20, flashExpirationMs: 1500, })
// Initialize (once) useEffect(() => injectKeyframes(), [])
// Process row updates useEffect(() => { processUpdates(newData, oldData, 'value') }, [newData])
// In cell renderer const flash = getFlashState(rowId, field) const styles = generateFlashStyles(flash, { colors: variant.colors.flash })
Flash in Cell Renderer
function ValueCellRenderer(params: ICellRendererParams) { const ctx = useDataGridContext() const { getFlashState } = ctx.flash
const flash = getFlashState(params.node.id, params.colDef.field)
return (
<div
className={flash.isActive ? flash-${flash.severity} : ''}
style={{
backgroundColor: flash.isActive
? flash.direction === 'up'
? ctx.variant.colors.flash.up
: ctx.variant.colors.flash.down
: undefined,
}}
>
{params.value}
</div>
)
}
Canonical source: src/lib/data-grid/flash/index.ts
Pattern 4: DataGridContext — PER-GRID SERVICES
When: Child components need access to grid state, variant, or API.
Context Shape
interface DataGridContextValue<TData = unknown> { gridId: string runtime: DataGridRuntime // Per-grid Effect services variant: GridVariantType // Reactive variant rowData: TData[] columnDefs: ColDef<TData>[] getRowId?: GetRowIdFunc<TData> gridApi: GridApi | null // AG-Grid API reference setGridApi: (api: GridApi | null) => void flash: FlashTrackerAPI }
Usage in Components
// Required context (throws if missing) const ctx = useDataGridContext()
// Optional context (returns null if outside grid) const ctx = useDataGridContextMaybe()
Color Extraction from Context
function StatusCellRenderer(params: ICellRendererParams) { const ctx = useDataGridContextMaybe()
// Variant colors with fallback const colors = ctx?.variant.colors ?? { signal: { positive: '#22c55e', negative: '#ef4444' } }
return ( <span style={{ color: colors.signal.positive }}> {params.value} </span> ) }
Canonical source: src/lib/data-grid/components/DataGridContext.tsx
Pattern 5: Context-Aware Cell Renderers
When: Renderers need variant colors or grid services.
Pattern: Fallback for Standalone Usage
import { useDataGridContextMaybe } from '@/lib/data-grid' import { COLORS } from '@/lib/tokens'
export function ValueCellRenderer(params: ValueCellRendererParams) { const ctx = useDataGridContextMaybe()
// Colors from variant OR token fallback const textColor = ctx?.variant.colors.text.primary ?? COLORS.textPrimary const accentColor = ctx?.variant.colors.signal.accent ?? COLORS.accentCyan
return (
<div style={{ color: textColor }}>
<span>{params.value}</span>
<div
style={{
backgroundColor: accentColor,
width: ${params.data.percentage}%,
}}
/>
</div>
)
}
Pattern: Flash-Aware Renderer
export function FlashValueRenderer(params: ICellRendererParams) { const ctx = useDataGridContext() const flash = ctx.flash.getFlashState(params.node.id, params.colDef.field!)
return (
<div
className={cn(
'transition-all duration-300',
flash.isActive && flash-${flash.severity},
flash.direction === 'up' && 'text-green-400',
flash.direction === 'down' && 'text-red-400',
)}
>
{params.value}
</div>
)
}
Canonical source: src/lib/data-grid/renderers/ValueCellRenderer.tsx
Pattern 6: Hybrid Drag System — GRID-TO-CANVAS
When: Dragging rows from AG-Grid onto a tldraw canvas.
Drag Phase Transitions
┌─────────────────────────────────┐ │ AG-Grid Internal Drag │ │ (rowDragManaged=true) │ └──────────────┬──────────────────┘ │ onRowDragMove ▼ Check: outside grid bounds? │ ┌──────┴──────┐ NO YES │ │ Continue Create ghost in grid shape on canvas │ │ │ onPointerMove │ updateGhost() │ │ └─────┬───────┘ │ onPointerUp spawnDataCard() Remove ghost
Drag State Schema
interface DragState { isDragging: boolean isOutsideGrid: boolean // Key flag for phase transition rowData: DataGridRow | null ghostId: string | null // tldraw shape ID }
Phase Handlers
const onRowDragMove = useCallback((event: RowDragMoveEvent) => { const { clientX, clientY } = event.event const gridRect = gridRef.current?.getBoundingClientRect()
const isOutside = !gridRect || clientX < gridRect.left || clientX > gridRect.right || clientY < gridRect.top || clientY > gridRect.bottom
if (isOutside && !dragState.isOutsideGrid) { // Transition: GridInternal → CanvasTracking const ghostId = createGhostShape(dragState.rowData, { x: clientX, y: clientY }) setDragState(prev => ({ ...prev, isOutsideGrid: true, ghostId })) } else if (!isOutside && dragState.isOutsideGrid) { // Transition: CanvasTracking → GridInternal removeGhostShape(dragState.ghostId) setDragState(prev => ({ ...prev, isOutsideGrid: false, ghostId: null })) } }, [dragState, createGhostShape, removeGhostShape])
Effect Service for Drag
export interface GridDragServiceApi { readonly getState: Effect.Effect<DragState> readonly dispatch: (event: GridDragEvent) => Effect.Effect<void> readonly subscribe: (handler: (state: DragState) => void) => Effect.Effect<() => void> readonly isDragging: Effect.Effect<boolean> readonly getPhase: Effect.Effect<DragPhase> }
Canonical source: src/lib/data-grid/services/GridDragService.ts
Pattern 7: Theme Composition via composeAgGridTheme
When: Converting a GridVariant to an AG-Grid theme.
import { themeQuartz } from 'ag-grid-community'
export function composeAgGridTheme(variant: GridVariant) { const { colors, density, typography } = variant
return themeQuartz.withParams({ // Core colors backgroundColor: colors.background.base, foregroundColor: colors.text.primary, accentColor: colors.signal.accent,
// Header
headerBackgroundColor: colors.background.header,
headerTextColor: colors.text.secondary,
// Rows
oddRowBackgroundColor: colors.background.alternateRow,
rowHoverColor: `${colors.background.base}cc`,
selectedRowBackgroundColor: `${colors.signal.accent}15`,
// Typography
fontFamily: typography.fontFamily,
fontSize: density.fontSize,
headerFontSize: density.fontSizeXs,
// Density-driven spacing
rowHeight: density.rowHeight,
headerHeight: density.headerHeight,
cellHorizontalPaddingScale: density.paddingX / 8,
// Borders
borderColor: colors.border.primary,
wrapperBorderRadius: 0,
}) }
Canonical source: src/lib/data-grid/composer/theme-composer.ts
Pattern 8: Color Extraction Helpers
When: Extracting semantic colors from variant for custom UI.
export function extractStatusColors(variant: GridVariant) { return { active: variant.colors.signal.positive, pending: variant.colors.signal.warning ?? variant.colors.signal.accent, inactive: variant.colors.signal.neutral ?? variant.colors.text.muted, error: variant.colors.signal.negative, default: variant.colors.text.muted, } as const }
export function extractFlashConfig(variant: GridVariant) { const flash = variant.colors.flash return { enabled: variant.behavior.microInteractions?.enableCellFlash ?? true, upColor: flash.up, downColor: flash.down, durationMs: flash.durationMs, } }
Pattern 9: Legacy Basic Patterns
Theme Creation via themeQuartz (Direct)
import { themeQuartz } from 'ag-grid-community' import { TMNL_TOKENS } from './data-grid-theme'
export const tmnlDataGridTheme = themeQuartz.withParams({ backgroundColor: TMNL_TOKENS.colors.background, foregroundColor: TMNL_TOKENS.colors.text.primary, borderColor: TMNL_TOKENS.colors.border, headerBackgroundColor: TMNL_TOKENS.colors.surface, // ... etc })
Basic Cell Renderer (Non-Context)
const IdCellRenderer = (params: ICellRendererParams) => ( <span style={{ color: TMNL_TOKENS.colors.text.muted, fontSize: TMNL_TOKENS.typography.sizes.xs, fontFamily: TMNL_TOKENS.typography.fontFamily, letterSpacing: '0.05em', }}
{params.value}
</span> )
Column Definitions
const columnDefs: ColDef[] = [ { field: 'id', headerName: 'ID', width: 80, cellRenderer: IdCellRenderer, sortable: true, }, { field: 'value', headerName: 'Value', width: 120, cellRenderer: ValueCellRenderer, comparator: (a, b) => a - b, }, ]
Anti-Patterns (BANNED)
- Importing CSS Files
// BANNED - No CSS imports with v34 import 'ag-grid-community/styles/ag-grid.css' import 'ag-grid-community/styles/ag-theme-quartz.css' // Use theme prop instead
- Missing Module Registration
// BANNED - Grid will render blank <AgGridReact rowData={data} columnDefs={cols} /> // Must register modules FIRST
- useState for Grid Data
// BANNED when data crosses boundaries const [rowData, setRowData] = useState([]) // Use Atom.make + service methods instead
- Direct Theme Customization
// BANNED - Bypasses variant system const theme = themeQuartz.withParams({ backgroundColor: '#123' }) // Use GridVariant + composeAgGridTheme() instead
- Cell Renderer Without Context Fallback
// BANNED - Breaks outside grid context function BadRenderer(params) { const ctx = useDataGridContext() // Throws if no context! return <span style={{ color: ctx.variant.colors.text.primary }}>...</span> }
// CORRECT - Graceful fallback function GoodRenderer(params) { const ctx = useDataGridContextMaybe() const color = ctx?.variant.colors.text.primary ?? COLORS.textPrimary return <span style={{ color }}>...</span> }
- Creating Atoms Inside Components
// BANNED - Recreates on every render function BadGrid() { const rowDataAtom = Atom.make([]) // BAD! return <AgGridReact ... /> }
// CORRECT - Module-level atoms const rowDataAtom = Atom.make<RowData[]>([]) function GoodGrid() { const rowData = useAtomValue(rowDataAtom) return <AgGridReact rowData={rowData} ... /> }
Decision Tree: Which API to Use
Building a data grid? │ ├─ Simple, one-off grid? │ └─ Use: Direct AgGridReact with composeAgGridTheme() │ ├─ Grid with header, status bar, controls? │ └─ Use: Tmnl.DataGrid compound component │ ├─ Grid in tldraw shape? │ └─ Use: V2 pattern (data-grid-shape-v2.tsx) │ ├─ Need real-time flash updates? │ └─ Use: useFlashTracker + FlashValueRenderer │ └─ Need custom variant? └─ Clone tmnlDenseDark and modify
File Locations Summary
Component File Purpose
Tmnl.DataGrid src/lib/data-grid/components/UnifiedDataGrid.tsx
Compound component
DataGridContext src/lib/data-grid/components/DataGridContext.tsx
Per-grid context
composeAgGridTheme src/lib/data-grid/composer/theme-composer.ts
Variant → theme
Flash system src/lib/data-grid/flash/index.ts
Cell highlighting
ValueCellRenderer src/lib/data-grid/renderers/ValueCellRenderer.tsx
Context-aware
tmnlDenseDark src/lib/data-grid/variants/tmnl-dense-dark.ts
Canonical variant
V2 tldraw shape src/components/tldraw/shapes/data-grid-shape-v2.tsx
Modern integration
Hybrid drag src/components/tldraw/shapes/data-grid-shape.tsx:366-594
Grid-to-canvas
Integration Points
-
effect-atom-integration — Atom-as-State for rowData
-
tmnl-design-tokens — Token fallbacks in renderers
-
common-conventions — Barrel exports, naming patterns
-
effect-patterns — Effect.Service for GridDragService