@luckydraw/blex
v0.1.23
Published
Interactive content block rendering library — framework-agnostic, streaming-aware
Maintainers
Readme
@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
npm install @luckydraw/blexDistribution
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
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:
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
MediaLightbox
Hosts can reuse the block viewer for attachments and Markdown images. Import
MediaLightbox and the MediaItem type from @luckydraw/blex, or use
new window.Blex.MediaLightbox() with the IIFE bundle.
import { MediaLightbox, type MediaItem } from '@luckydraw/blex';
const viewer = new MediaLightbox();
const items: MediaItem[] = [{ url: '/media/screenshot.png', alt: 'Screenshot' }];
// Inside a host button's click handler; index is zero-based:
viewer.open(items, 0, previewButton, 'Attachments');
// When the host view unmounts or its media is removed:
viewer.close();open(items, index, opener, title?) initializes styles itself, even before any
block is rendered. opener is the focusable host element used for theme and
focus restoration. title defaults to Media viewer. Items have url, optional
type: 'image' | 'video' (default image), alt, plain-text caption, and poster.
Opening, navigating, and closing emit no block interaction. Omit the optional
fifth onAdd(index) callback for viewing only; providing it adds an explicit
“Add to response” button that calls the host callback only on that button click.
Host and block viewers share one active dialog per document. Opening another
closes the previous one. close() is safe to repeat and stops/unloads video;
call it before removing the host view or revoking media object URLs. Native
<dialog> support is required. Load one Blex bundle per document so all callers
share viewer ownership.
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 renderallowMediaAttachments?: boolean— setfalseto hide gallery “Add to response” controls in render-only views. Viewing and navigation remain available.
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.
parseBlockData(type, source): BlockDataParseResult
Parse the JSON payload of a completed block fence. Available from the main
ESM export and window.Blex. Consumers own fence extraction and streaming state.
Strict JSON passes unchanged; this is not renderer schema validation.
For invalid table JSON, Blex can insert missing row property names using the
declared columns. A damaged row must have exactly one cell per unique, nonempty
column, an explicit first-column key, and any other explicit keys in their
declared positions. Only string/number cells are recovered. For example,
{"Concept":"captain","A person"} with columns ['Concept', 'Meaning']
becomes {"Concept":"captain","Meaning":"A person"}. Ambiguous mappings,
truncated JSON, and other syntax errors fall back without guessing.
Results always retain source exactly:
- Success:
{ ok: true, data, source, repaired, repairs, repairedSource? }. Each repair has{ code: 'table-row-missing-key', row, column, offset }, whererowis zero-based andoffsetis a UTF-16 index into original source. - Failure:
{ ok: false, source, error }.erroris the original native JSON diagnostic, for debugging; display original content to the reader.
Recovery is limited to 1,000,000 source code units, 100,000 tokens, and 64 nested levels. These limits apply after strict parsing fails. No model or network calls occur. Persist original source, even when the displayed block was repaired.
renderBlockSourceFallback(type, source, container): HTMLElement
Replaces container content with an open disclosure showing the exact source as
wrapped plain text. HTML in source/type is inert. Returns the disclosure and
sets data-blex-ready="true", data-loading="false", and aria-busy="false".
Destroy any existing block handle before replacing its content.
const parsed = parseBlockData(type, source);
if (!parsed.ok) {
renderBlockSourceFallback(type, parsed.source, container);
} else {
try {
const handle = await renderBlock({ type, id, data: parsed.data }, container);
// Retain handle for interactions and destroy() during cleanup.
} catch {
// Valid JSON can still contain data that a renderer cannot display.
renderBlockSourceFallback(type, parsed.source, container);
}
}registerBlockType(type, renderer): void
Register a custom block type renderer. Overrides built-in renderers if the type matches.
registerBlockType('my-widget', {
render(block, container) { /* ... */ },
update(block) { /* streaming updates */ },
onInteraction(callback) { /* wire up events */ },
destroy() { /* cleanup */ }
});Types
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
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/video grid or carousel with a shared lightbox | ~7KB |
Heavy renderers are loaded on first use. Subsequent renders of the same type reuse the loaded module.
Gallery images preserve their natural aspect ratios in both grid and carousel layouts.
Grid columns (default 3) is a maximum: the grid reduces its column count to
keep images at least 240px wide where space permits, and fits a single column
to smaller containers. A gallery with fewer images expands them to fill the row.
Visual blocks and lightbox
Click a standalone image, gallery thumbnail, SVG, Mermaid diagram, or chart to open the shared large lightbox. Openers also work with Enter and Space. Viewing never selects the block or adds anything to the response. SVG and Mermaid remain vectors, including Mermaid HTML labels; chart previews show the current canvas image. Updates and destruction close the preview. The standalone chart API does not add a lightbox.
Gallery viewing supports Previous/Next buttons and Left/Right arrows; video controls retain their native seek/volume keys. Close with Escape, the Close button, or a click outside the panel. Focus returns to the thumbnail or Zoom button. Images retain their proportions; videos have native controls and never autoplay. Closing, navigating, updating, or destroying the block stops and unloads video.
The existing images array accepts both images and videos:
const handle = await renderBlock({
type: 'gallery', id: 'field-notes', data: {
title: 'Field notes', layout: 'grid', columns: 3,
images: [
{ url: '/media/photo.jpg', alt: 'Mountain landscape', caption: 'Day one' },
{ type: 'video', url: '/media/clip.webm', poster: '/media/poster.jpg', caption: 'Trail footage' },
],
},
}, container);
handle.onInteraction(interaction => addChip(interaction));type defaults to image; use explicit type: 'video' for video URLs, including
opaque URLs. poster is optional: a video without one displays a “View video”
placeholder. Video bytes are requested only when the viewer opens that entry.
Supported codecs and seeking depend on the browser and media server; hosts are
responsible for MIME types and HTTP range support.
Only Add to response (below each item and inside the lightbox) emits a deferred
select interaction. Image payloads remain { imageIndex, url }; video payloads
add mediaType: 'video'. imageIndex always indexes the images array. Both use
immediate: false and revertable: true, with no local selection state to revert.
Opening, navigating, playing, and closing never emit. Standalone image blocks
keep their local Zoom and Download actions.
Attachment buttons stay hidden until handle.onInteraction() is registered.
For a render-only host that registers a no-op callback, pass
{ allowMediaAttachments: false } to renderBlock. With a no-op callback and the
default option, Add emits harmlessly but cannot attach anything or show a success
state; hosts should suppress that action. React's <BexBlock> accepts the same
allowMediaAttachments prop and automatically hides actions without onInteraction.
The viewer uses native <dialog>.showModal() in the browser top layer, mounted
on document.body, with the originating block's Blex colors and font. It can
appear above clipped/isolated chat containers and host dialogs. Native dialog
support is required. Ataran owns plugin integration; this feature does not
replace host-owned attachment thumbnails or file previews.
Interactive test IDs use blex-gallery-open-{blockId}-{index},
blex-gallery-add-{blockId}-{index}, and blex-image-open-{blockId}. The single
active viewer uses blex-lightbox, blex-lightbox-close, blex-lightbox-prev,
blex-lightbox-next, blex-lightbox-add, and blex-lightbox-media.
SVG, Mermaid, and chart openers use blex-svg-content-{blockId},
blex-mermaid-diagram-{blockId}, and blex-chart-open-{blockId} respectively.
Video embeds
embed blocks recognize direct .mp4, .m4v, .webm, and .ogv URL paths
(case insensitive, with query strings/fragments allowed) and display native
video controls after click-to-load. Videos fill the block width, contain the
whole image, and cap the requested height (default 400px) at 70% of viewport
height, with a 160px minimum for controls. They do not autoplay or emit chat
interactions. Updating or destroying the block stops and unloads playback.
{"url":"/media/recording.mp4","title":"Interaction recording","height":480}For opaque video URLs, use mediaType: "video". To force an application URL
with a video-looking path to remain an iframe, use mediaType: "iframe".
Other URLs still use the sandboxed iframe and its existing postMessage bridge.
No HTML player wrapper is needed. A non-video response sent to the native player
shows a loading error; Blex never retries it as unsandboxed HTML.
Hosts remain responsible for correct MIME types, codecs, range responses, and
deployment of the Blex bundle. Uploaded HTML should retain its sandbox policy;
native <video src> does not require removing that policy from media responses.
Use blex-embed-video-{blockId} and blex-embed-status-{blockId} for testing.
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:
/* 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.
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.
// 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/chartis fully independent: no side effects, no registry, no DOMPurify. JustrenderChart()+ 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-loadedLicense
MIT
