Building Pigment Frames
A Frame is a standalone, full-page custom visualisation rendered from author-supplied JavaScript that reads Pigment data through the PigmentSDK. Use a Frame only when native Views, Boards, and chart types cannot express the required visual.
When to Use
- Bespoke visual (canvas, inline SVG, custom DOM) that no native View mode (
Grid/Chart/KPI) can produce. No CDN; inline all code. - Full page in left navigation (like Boards). No Frame widget; a Frame is the page.
Frames do not support scenarios. Do not attempt to build them, edit them, compare them, or provide workarounds regarding them, it won't work. If the user requests to work with scenarios, tell them early that it is not supported.
Tools and Workflow
The Frame JS lives in a file on your filesystem. tool:create_frame and tool:update_frame take the path of that file, never the source itself.
- Identify the raw data (Metrics & Lists) that you would like to display on the Frame (ask the user to confirm if needed). No backing View or Table is needed: the Frame reads Metrics and List Properties directly through data sources that shape the rows (
labels), columns (values) and pre-aggregation filters (selectors) it needs. Ask the user to confirm that shape before writing any JS. - Declare the bindings (Metrics, Lists, List Properties, Variables) and one data source per dataset the Frame reads (see Data sources). Warnings: a) rows are capped at 1,000 per window ; b) concurrent subscriptions per Frame are limited to 10 data-source subscriptions and 20 List subscriptions.
tool:write_filethe JS body to a file (e.g./frames/revenue-heatmap.js), thentool:create_frameonce with thatpath. Never re-create (name collision). Usetool:search_frameswith anameif unsure it exists.- For every later change,
tool:edit_filethe same file, thentool:update_framewith the samepath. A Frame write reads the file at call time, so the file is the source of truth - keep editing it rather than rewriting it from scratch. - Always resend the complete
bindings,data_sources, anddata_sinksarrays when callingtool:create_frame/tool:update_frame; an omitted array drops every entry of that kind.tool:search_framesbyidornamegives you thebindingsarray but not the data sources/sinks — keep track of the ones you declared; a bare listing gives neither. - Grow a large body in the file, not in tool arguments. A Frame's JS body can be long enough that emitting it whole in one response risks hitting the model's max output size. For a substantial new Frame,
tool:write_filea working skeleton (IIFE,#appsetup,__cleanup, and clearly unique placeholder markers for the sections still to come — e.g.// SECTION: subscribe,// SECTION: render), then fill each placeholder withtool:edit_file, one section at a time. Calltool:create_frame/tool:update_frameonce the file is complete. Make one of those sections// SECTION: styles. - If the user's request asks for a Pigment-native, on-brand, or polished look, and skill:designing-pigment-frames is among your available skills (it is feature-flagged, so it may not be): code the Frame first, leaving
// SECTION: stylesas a placeholder, then load that skill and fill the placeholder with its design pass before you finish. Without that ask, or when the skill is unavailable, a plain placeholder style is fine; do not load the skill on your own. - If a Frame write reports the file was not found, the file was never written or the path is wrong: check with
tool:ls, thentool:write_filebefore retrying. - Whenever the user reports a bug (blank page or anything that does not work expectedly), you can ask the user to record the logs using the dedicated buttons in the UI. You will be able to read the recorded logs.
Sandboxed Runtime
Iframe: sandbox="allow-scripts", opaque origin. Host shell loads /pigment-sdk.js, then your file contents as a separate script into #app.
Allowed: ES2015+ JS, DOM, Canvas, inline SVG/CSS. Blocked: network (fetch, CDN, @import), storage, alert/confirm/prompt, Workers, nested iframes, parent/top.
Rules:
- The file is pure JS (not HTML). Populate
#app; no<script>wrapper. - Wrap in an IIFE; call
root.__cleanup()before re-init on hot-reload. - Layout from
window.innerWidth/window.innerHeight(#apphas near-zero height). - Single-quote HTML attributes in JS strings; avoid multiline template literals in tool args.
Do not copy the Frame editor placeholder; it omits IIFE cleanup and uses template literals.
Bindings
JSON array on create_frame / update_frame. A binding is only a concept mapping: it maps a name used in the Frame's JS to a concept in the underlying Pigment model (a Metric, List, List Property, Variable, or legacy View). Tool input uses snake_case (metric_id, list_id, list_property_technical_name, can_read, can_write).
Important: the SDK can natively subscribeToItems on any List binding declared here directly — no data_source or data_sink needed just to list a List's Items.
can_read and can_write are legacy fields. Their value has no impact on any SDK call. Read access comes solely from referencing the binding in a data_source; write access comes solely from referencing it in a data_sink.
Data sources
Purpose: read data from the Pigment model. A data source describes how a Frame can access data. They are declared as a JSON array called data_sources and passed to create_frame / update_frame, next to bindings. data_sources can only reference existing entities by referencing bindings declared above by their names.
Where a binding just grants access to one entity, a data source shapes several of them into a dataset returned via subscribeToDataSource. The same binding can back several data sources.
We support two kinds of data sources: data source responsible to extract data linked to one or several Metrics and data source on List to extract values linked to some specific properties of the considered List.
Here are the extra constraints for a data source on Metrics:
Dimension rules. Both labels and selectors must satisfy, against every Metric specified in values:
- Binding of type
List: that List must be a structural Dimension of the Metric. - Binding of type
ListProperty: the List the property is extracted from must be a structural Dimension of the Metric. The property only groups the rows, so the Dimension it targets is never what gets checked.
And for one on a List:
The example below declares a data source on a single Metric declared as revMetric in the bindings. It asks for the monthList to be used as labels and countryList to be used as selectors. It implicitely implies that revMetric must be at least dimensioned by monthList and countryList. For any Dimension not declared in labels, at least countryList, it will apply a sum of the values for the countries being asked (or all of them if not specified).
Data sinks
Purpose: write data back to the Pigment model. data_sinks is a field in the Frame manifest, alongside bindings and data_sources. It lists every Pigment block (List or Metric) the Frame needs to write to, and listing a binding there is what grants write access to it — nothing else does.
Each entry is { "name": <string>, "binding": <binding name> }:
With no writes needed, pass data_sinks as an empty array [].
PigmentSDK
Methods: subscribeToDataSource, subscribeToItems, addItem, editItem, editValue. subscribeToVizualization still exists but is legacy and should never be used in new code and be removed from existing code.
Data source subscription
Use it to pull data from Pigment for your Frame. You can only pull data from data sources you previously defined into the data_sources field. Data source get pulled via subscribeToDataSource by passing their name.
Data shape
The shape of the data received on onData is the following:
data.rowsis an array made of all the rows pulled from the data set. You should expect one row per combination oflabelscoming with data linked to it on one of the requestedvalues. When defining for nolabels, you should expect to receive one row not moredata.rows[i]contains two fields:labelsis an array containing the labels you requested in the order defined on the definition of the data source. Entries in this array are either strings,{ kind: 'blank' }for a label being explicitly set to unassigned, or{ kind: 'loading' }if we are still pulling the label.valuesis an array containing the values you requested in the order defined on the definition of the data source. Each value is either a number, boolean, ISO-8601 date string, item label, plain string,null(no value), or{ kind: 'loading' | 'unknown' }for a List item reference still resolving or unaccessible. The type you get matches the type of the value you requested.
data.rowOffsetis the 0-based index of the first row indata.rowswithin the full data source, i.e.data.rows[n]is rowdata.rowOffset + nof the data source.data.totalRowCountis the total number of rows in the data source, regardless of the requested scroll window.
Applied to the same revenueByTime data source defined above (values: [revMetric] summed with Sum, labels: [monthList], selectors: [countryList]), subscribing with no dynamicFilters (all countries aggregated together) delivers one row per month:
Since no dynamicFilters were applied, January's value is revMetric summed across every countryList item, restricted to that item's data for January. If we filtered on France and Italy instead, January's value would only be the sum of revMetric for those two countries' January data.
Dynamic filters
dynamicFilters items are { binding, selection }:
bindingmust be aListbinding, not aListPropertyone. In case theselectorsdeclared aListProperty, thebindingused here must be the List being targeted by the property. In other words, the List that we reach fromlist_id.list_property_technical_name.selectionis an array of Item labels of that List to keep. An emptyselectionkeeps all the Items
Dynamic filters apply to selectors before aggregation. An unfiltered selector aggregates all its Items together, while a selection aggregates only those Items. A filter matching none of the data source's selectors is silently ignored.
Use updateDynamicFilters on the existing handle, do not resubscribe when relying on the same data source.
Scroll & windowing
scroll/updateScroll and data.rowOffset/data.totalRowCount are two sides of the same mechanism:
- Restricted to data sources on Lists. Passing
scrollon a data source on Metrics has no effect for now but this may change in the future. data.rowOffset/data.totalRowCountare always returned, regardless of whetherscrollapplies.numberOfRowsis limited to 1,000
Use updateScroll on the existing handle, do not resubscribe when relying on the same data source.
List subscription
partialResult === true: truncated list; no pagination API. Show a warning.
Warning: subscribeToItems only returns the Item name, not the Properties. To fetch List Items with Properties, use subscribeToDataSource.
Writes
addItem, editItem, and editValue all take a data sink name as their first argument — the name declared in data_sinks — never the underlying binding's name. The sink's binding resolves to the actual List or Metric being written to.
editItem(sinkName, item, values): item is the current name of the Item; values is a partial map of ListProperty binding names (declared in bindings) to new values.
editValue(sinkName, coordinates, value): coordinates maps each List binding name (dimension) to the selected item label; value is boolean | number | string | null.
Implementation Patterns
Loading guard
onData fires multiple times (loading → final, then again on every refresh). Guard with isReady(); zero rows after loading is valid empty.
Lifecycle
- One subscription per data source; filter changes via
updateDynamicFilters; never resubscribe to refresh. - Always implement
onError; show user-visible loading / error / empty states. partialResult: show warning banner.
Render and performance
- Prefer canvas/SVG for charts; create once, clear and redraw. Use canvas if
rows × cols > 500. - HiDPI/Retina: always scale canvas by
devicePixelRatio— setting canvas dimensions in CSS pixels only (canvas.width = el.offsetWidth) causes blurriness on Retina screens because the browser stretches the low-res bitmap to fill the CSS size. Set physical pixels viacanvas.width = el.offsetWidth * dpr; canvas.height = el.offsetHeight * dpr; ctx.scale(dpr, dpr);and keep CSS size viacanvas.style.width/height. root.innerHTMLtears down listeners; callattachEvents()after each render, or delegate onrootonce.- Debounce
onDatarenders (~16ms) and resize (~120ms). CachelastData. - Resize:
window.resize+ResizeObserverondocument.documentElement(host usesAutoSizer). - Tooltips on
document.body; remove in__cleanup. - For PDF printing, use the browser's native print-to-PDF (
window.print()) only.
Cleanup (leak prevention)
In root.__cleanup: unsubscribe() all subs; removeEventListener all named global listeners; clearTimeout/clearInterval; cancelAnimationFrame; disconnect() observers; remove document.body nodes; set lastData = null. Never use anonymous functions for global listeners.
Legacy: View subscription (subscribeToVizualization)
Discouraged, scheduled for decommission. Frames used to have to plug into a pre-built View (on a List, Metric, or Table) to read anything at all; that is no longer the case —
bindings+data_sourcesread Metrics and Lists directly, without any View.subscribeToVizualizationis the legacy path that still reads through aViewbinding, and it only survives in Frames that predatedata_sources. Never use it in a new Frame. When you touch an existing Frame that still relies on it, offer the user to migrate it to the newdata_sources-based manifest instead of only patching around it. The rest of this section only exists to understand such legacy code.
- Backing Views: legacy Frames needed a backing View on a List, Metric or Table with the right layout. Data sources replace this step entirely.
- Bindings: a
Viewbinding (view_id,can_read: true) per View, plusList/Variablebindings forpageDefinitions(nocan_readneeded). - Limits: shares the 10 concurrent subscriptions with
subscribeToDataSource; rows capped at 1,000 per window.
Data shape
Column-major: cells[c][r]. cells.length === labels.columns.length; cells[c].length === labels.rows.length (for the current window).
Label paths: labels.rows[r] and labels.columns[c] are arrays (one entry per pivot level). Use last string entry as display name.
Cells: number | string | boolean | null | { kind: 'loading' | 'unknown' } (dates as ISO strings). Labels: string | { kind: 'total' | 'blank' | 'loading' }. Only 'loading' means not ready.
Windowed data: data.rowOffset is the 0-based index of the first row in the current window. data.totalRowCount is the total number of rows in the View (independent of the window). Use updateScroll to page through large datasets; numberOfRows is capped at 1,000.
Page selection
String aliases work for both List and Variable bindings. Pass { kind: 'metric' } as the alias to select specific Metrics from the View by their ID. No scenario placeholder.
Use updatePageDefinitions on the existing handle; do not resubscribe.
Loading guard
onData fires multiple times (empty → loading → final). Guard with isReady(); zero rows after loading is valid empty.


