npm package: @luckydraw/blex
Description: Interactive content block rendering library — framework-agnostic, streaming-aware
Latest version: 0.1.7

# @luckydraw/blex

A TypeScript library for rendering interactive content blocks from JSON. Framework-agnostic, streaming-aware, with lazy-loaded heavy renderers.

Used by **cumulus** (AI chat) to display rich content inline in conversations, and by **plastic** (app platform) for charts, dashboards, and application view components.

## Install

```bash
npm install @luckydraw/blex
```

## Distribution

Two build outputs from the same source:

- **ESM** — `import { renderBlock } from '@luckydraw/blex'` — for bundled consumers (plastic, janus)
- **IIFE** — `<script src="blex.min.js">` → `window.Blex` — for cumulus widget (no build step)
- **Chart standalone** — `blex-chart.min.js` / `import { renderChart } from '@luckydraw/blex/chart'`

Target: ~11KB core (gzipped), heavy renderers lazy-loaded on first use.

## Quick Start

```typescript
import { renderBlock, registerBlockType } from '@luckydraw/blex';

// Render a block into a container
const handle = await renderBlock(
  { type: 'confirm', id: 'c1', data: { message: 'Deploy to production?' } },
  document.getElementById('container')
);

// Listen for interactions
handle.onInteraction((interaction) => {
  console.log(interaction.type);       // 'click'
  console.log(interaction.serialized); // 'User confirmed: Deploy to production?'
});

// Clean up
handle.destroy();
```

### Chart Subpath Export

A standalone chart API shared between cumulus and plastic. Fully independent — no side effects, no DOMPurify, no registry setup:

```typescript
import { renderChart } from '@luckydraw/blex/chart';

const chart = renderChart(container, {
  type: 'bar',
  data: {
    labels: ['Q1', 'Q2', 'Q3', 'Q4'],
    datasets: [{ label: 'Revenue', values: [10, 25, 15, 30] }]
  },
  options: { title: 'Quarterly Revenue', legend: true }
});

chart.update({ data: { labels: ['Q1', 'Q2'], datasets: [{ label: 'Revenue', values: [10, 25] }] } });
chart.destroy();
```

## API

### `renderBlock(block, container, options?): Promise<BlockHandle>`

Resolves the block type from the registry, loads the renderer (lazy if needed), renders into the container, and wires up interaction callbacks.

Options:
- `onReady?: () => void` — called when the block finishes initial render

### `renderPlaceholder(type, container): HTMLElement`

Renders a typed skeleton/spinner for a block that hasn't finished streaming yet. Returns the placeholder element for later replacement. Used by cumulus while buffering `~~~blex:TYPE` fence content.

### `registerBlockType(type, renderer): void`

Register a custom block type renderer. Overrides built-in renderers if the type matches.

```typescript
registerBlockType('my-widget', {
  render(block, container) { /* ... */ },
  update(block) { /* streaming updates */ },
  onInteraction(callback) { /* wire up events */ },
  destroy() { /* cleanup */ }
});
```

## Types

```typescript
interface ContentBlock<T = unknown> {
  type: string;
  data: T;
  id: string;         // unique per block instance, stable across re-renders
  streaming?: boolean; // true while data is still arriving
}

interface BlockRenderer<T = unknown> {
  render(block: ContentBlock<T>, container: HTMLElement): void;
  update?(block: ContentBlock<T>): void;
  onInteraction?(callback: (interaction: BlockInteraction) => void): void;
  destroy(): void;
}

interface BlockHandle {
  update(block: ContentBlock): void;
  onInteraction(callback: (interaction: BlockInteraction) => void): void;
  destroy(): void;
}

interface BlockInteraction {
  blockId: string;
  type: string;        // 'select', 'click', 'submit', 'apply', 'reject', 'move'
  payload: unknown;
  serialized: string;  // pre-formatted text for chat input injection
}
```

### Chart Types

```typescript
interface ChartConfig {
  type: 'bar' | 'line' | 'pie' | 'doughnut';
  data: { labels: string[]; datasets: DataSet[] };
  options?: { title?: string; legend?: boolean; responsive?: boolean };
}

interface DataSet {
  label: string;
  values: number[];
  color?: string;
}

interface ChartInstance {
  update(config: Partial<ChartConfig>): void;
  destroy(): void;
}
```

## Built-in Block Types

### Core Blocks (bundled, <5KB each)

| Type | Description | Interaction Types |
|------|-------------|-------------------|
| `confirm` | Yes/No/Cancel buttons | `click` |
| `poll` | Single/multi choice with optional write-in | `submit` |
| `status` | Key/value pairs with status indicators (ok/warning/error) | — |
| `metric` | Single big number with label and trend indicator | — |
| `image` | URL/base64 image with caption, zoom/download | `click` |
| `svg` | Sanitized inline SVG with download/copy | `click` |
| `table` | Sortable columns, row/cell/column selection | `select` |
| `code` | Syntax-highlighted code with line selection | `select` |
| `diff` | Unified or side-by-side diff | `apply`, `reject` |
| `file-tree` | Expandable directory tree | `select` |
| `form` | JSON schema → dynamic form | `submit` |
| `progress` | Live-updating step tracker | — |
| `terminal` | Collapsible command output with exit code | `click` |
| `timeline` | Vertical event timeline with timestamps | — |

### Lazy-loaded Blocks

| Type | Description | Loaded Size |
|------|-------------|-------------|
| `mermaid` | Diagram rendering via mermaid.js | ~200KB |
| `chart` | Bar/line/pie/doughnut via chart library | ~60KB |
| `kanban` | Drag-and-drop columns via HTML5 DnD | ~8KB |
| `calendar` | Month/week/day event views | ~12KB |
| `gallery` | Image grid/carousel with lazy loading | ~5KB |

Heavy renderers are loaded on first use. Subsequent renders of the same type reuse the loaded module.

### Unknown Types

Any unrecognized block type renders as raw JSON inside a collapsible `<details>` element, so content is never lost.

## Theming

blex uses CSS custom properties with sensible defaults. Consumers override them to match their theme:

```css
/* blex defaults — override in your app */
--blex-bg: #ffffff;
--blex-text: #1a1a1a;
--blex-border: #e5e5e5;
--blex-accent: #3b82f6;
--blex-success: #22c55e;
--blex-warning: #f59e0b;
--blex-error: #ef4444;
--blex-chart-bg: var(--blex-bg);
--blex-chart-text: var(--blex-text);
--blex-animation-duration: 200ms;
```

**Dark mode:** Set `data-theme="dark"` on a parent element. blex adjusts defaults automatically.

**Test mode:** Set `data-test-mode="true"` or `--blex-animation-duration: 0ms` to disable all animations for puppet/browser testing.

## Streaming

Renderers that implement `update()` receive incremental data without a full destroy/re-render cycle. The `streaming` flag on `ContentBlock` indicates data is still arriving.

```typescript
const handle = await renderBlock(
  { type: 'table', id: 't1', data: { rows: [] }, streaming: true },
  container
);

// As data arrives:
handle.update({ type: 'table', id: 't1', data: { rows: newRows }, streaming: true });

// When complete:
handle.update({ type: 'table', id: 't1', data: { rows: allRows }, streaming: false });
```

When `streaming: false` (e.g., historical message replay), blocks render immediately with no transition/animation.

## Interaction Serialization

Every `BlockInteraction` includes a `serialized` field — a pre-formatted text string suitable for injecting into a chat input. Each block type controls its own serialization format.

```typescript
// Table selection might serialize as:
{
  blockId: 't1',
  type: 'select',
  payload: { rows: [0, 2], columns: ['name', 'status'] },
  serialized: 'Selected from table:\n| name | status |\n| Alice | active |\n| Charlie | pending |'
}
```

Consumers receive interactions via the `BlockHandle.onInteraction()` callback and can forward `serialized` text directly to the chat input.

## Cleanup Contract

`destroy()` is a hard guarantee: after calling it, **zero references remain**. This includes:

- Event listeners (click, resize, keyboard)
- Animation frames (RAF callbacks)
- Canvas contexts
- Resize/intersection/mutation observers
- Timers (setTimeout, setInterval)
- Pending lazy-load promises
- DOM references

Enforced via `BaseRenderer` abstract class with cleanup tracking. All built-in renderers extend it.

## Design Decisions

- **Vanilla JS/TS** — no React dependency. Consumers wrap as needed.
- **Dual output** — ESM for bundled consumers, IIFE (`window.Blex`) for cumulus widget.
- **DOMPurify** — the one required runtime dependency, used for SVG/HTML sanitization.
- **Lazy loading** — heavy renderers (mermaid, chart.js, kanban, calendar, gallery) loaded on first use.
- **Subpath export** — `@luckydraw/blex/chart` is fully independent: no side effects, no registry, no DOMPurify. Just `renderChart()` + Chart.js lazy load.
- **CSS custom properties** — `--blex-*` variables for theming. No hardcoded colors.
- **`data-testid`** — on all interactive elements for puppet/browser automation.
- **BaseRenderer** — abstract class enforcing `destroy()` cleanup contract.

## Package Structure

```
src/
├── index.ts          # main entry: renderBlock, registerBlockType, renderPlaceholder
├── types.ts          # ContentBlock, BlockRenderer, BlockInteraction, BlockHandle
├── registry.ts       # block type registry + lazy loader
├── base-renderer.ts  # BaseRenderer abstract class with cleanup tracking
├── fallback.ts       # unknown type fallback renderer
├── placeholder.ts    # streaming skeleton/placeholder utility
├── theme.ts          # CSS custom property defaults + dark mode
├── chart/
│   └── index.ts      # subpath export: renderChart (fully independent)
└── renderers/
    ├── confirm.ts
    ├── poll.ts
    ├── status.ts
    ├── metric.ts
    ├── image.ts
    ├── mermaid.ts    # lazy-loaded
    ├── svg.ts
    ├── table.ts
    ├── chart.ts      # lazy-loaded, wraps chart/index.ts
    ├── code.ts
    ├── diff.ts
    ├── file-tree.ts
    ├── form.ts
    ├── progress.ts
    ├── terminal.ts
    ├── timeline.ts
    ├── kanban.ts     # lazy-loaded
    ├── calendar.ts   # lazy-loaded
    └── gallery.ts    # lazy-loaded
```

## License

MIT