@malloydata/render 0.0.423 → 0.0.425

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,321 @@
1
+ # Malloy Render Plugin System
2
+
3
+ ## Overview
4
+
5
+ The Malloy Render plugin system provides a flexible way to extend the visualization capabilities of Malloy. Plugins allow you to create custom renderers for specific field types or data patterns, enabling rich, interactive visualizations beyond the built-in rendering options.
6
+
7
+ ## Architecture
8
+
9
+ The plugin system consists of three main components:
10
+
11
+ 1. **Plugin Factories** - Create plugin instances for matching fields
12
+ 2. **Plugin Instances** - Handle the actual rendering of data
13
+ 3. **Plugin Registry** - Manages available plugins and their lifecycle
14
+
15
+ ## Plugin Types
16
+
17
+ ### SolidJS Plugins
18
+ Use SolidJS components for reactive, efficient rendering.
19
+
20
+ ```typescript
21
+ interface SolidJSRenderPluginInstance {
22
+ readonly renderMode: 'solidjs';
23
+ renderComponent(props: RenderProps): JSXElement;
24
+ // ... other methods
25
+ }
26
+ ```
27
+
28
+ ### DOM Plugins
29
+ Direct DOM manipulation for custom rendering needs.
30
+
31
+ ```typescript
32
+ interface DOMRenderPluginInstance {
33
+ readonly renderMode: 'dom';
34
+ renderToDOM(container: HTMLElement, props: RenderProps): void;
35
+ cleanup?(container: HTMLElement): void;
36
+ // ... other methods
37
+ }
38
+ ```
39
+
40
+ ## Plugin API
41
+
42
+ ### RenderPluginFactory
43
+
44
+ The factory interface defines how plugins are matched and instantiated:
45
+
46
+ ```typescript
47
+ interface RenderPluginFactory<TInstance extends RenderPluginInstance> {
48
+ readonly name: string;
49
+
50
+ // Determine if this plugin should handle a field
51
+ matches(field: Field, fieldTag: Tag, fieldType: FieldType): boolean;
52
+
53
+ // Create a plugin instance for a matched field
54
+ create(field: Field, pluginOptions?: unknown, modelTag?: Tag): TInstance;
55
+ }
56
+ ```
57
+
58
+ ### RenderPluginInstance
59
+
60
+ Base interface for all plugin instances:
61
+
62
+ ```typescript
63
+ interface BaseRenderPluginInstance<TMetadata = unknown> {
64
+ readonly name: string;
65
+ readonly field: Field;
66
+ readonly sizingStrategy: 'fixed' | 'fill';
67
+
68
+ // Return plugin-specific metadata
69
+ getMetadata(): TMetadata;
70
+
71
+ // Optional: Process data before rendering
72
+ processData?(field: NestField, cell: NestCell): void;
73
+
74
+ // Optional: Prepare for rendering
75
+ beforeRender?(metadata: RenderMetadata, options: GetResultMetadataOptions): void;
76
+ }
77
+ ```
78
+
79
+ ### Core Visualization Plugins
80
+
81
+ Core visualization plugins extend the base interface with additional methods:
82
+
83
+ ```typescript
84
+ interface CoreVizPluginMethods {
85
+ getSchema(): JSONSchemaObject;
86
+ getSettings(): Record<string, unknown>;
87
+ getDefaultSettings(): Record<string, unknown>;
88
+ settingsToTag(settings: Record<string, unknown>): Tag;
89
+ }
90
+
91
+ type CoreVizPluginInstance = SolidJSRenderPluginInstance & CoreVizPluginMethods;
92
+ ```
93
+
94
+ ## Writing a Plugin
95
+
96
+ ### 1. Basic SolidJS Plugin
97
+
98
+ ```typescript
99
+ import type { RenderPluginFactory, SolidJSRenderPluginInstance } from '@/api/plugin-types';
100
+ import { Field, FieldType } from '@/data_tree';
101
+ import type { Tag } from '@malloydata/malloy-tag';
102
+
103
+ const MyPluginFactory: RenderPluginFactory<SolidJSRenderPluginInstance> = {
104
+ name: 'my_plugin',
105
+
106
+ matches: (field: Field, fieldTag: Tag, fieldType: FieldType): boolean => {
107
+ // Match fields with #my_plugin tag
108
+ return fieldTag.has('my_plugin');
109
+ },
110
+
111
+ create: (field: Field): SolidJSRenderPluginInstance => {
112
+ return {
113
+ name: 'my_plugin',
114
+ field,
115
+ renderMode: 'solidjs',
116
+ sizingStrategy: 'fixed',
117
+
118
+ renderComponent: (props) => {
119
+ const value = props.dataColumn.value;
120
+ return <div class="my-plugin">{value}</div>;
121
+ },
122
+
123
+ getMetadata: () => ({
124
+ type: 'my_plugin',
125
+ fieldName: field.name
126
+ })
127
+ };
128
+ }
129
+ };
130
+ ```
131
+
132
+ ### 2. DOM Plugin Example
133
+
134
+ ```typescript
135
+ const MyDOMPluginFactory: RenderPluginFactory<DOMRenderPluginInstance> = {
136
+ name: 'my_dom_plugin',
137
+
138
+ matches: (field, fieldTag, fieldType) => {
139
+ return fieldTag.has('my_dom_plugin');
140
+ },
141
+
142
+ create: (field): DOMRenderPluginInstance => {
143
+ return {
144
+ name: 'my_dom_plugin',
145
+ field,
146
+ renderMode: 'dom',
147
+ sizingStrategy: 'fixed',
148
+
149
+ renderToDOM: (container, props) => {
150
+ container.innerHTML = `<div>Value: ${props.dataColumn.value}</div>`;
151
+ },
152
+
153
+ cleanup: (container) => {
154
+ container.innerHTML = '';
155
+ },
156
+
157
+ getMetadata: () => ({ type: 'my_dom_plugin' })
158
+ };
159
+ }
160
+ };
161
+ ```
162
+
163
+ ### 3. Advanced Visualization Plugin
164
+
165
+ For complex visualizations with settings and data processing:
166
+
167
+ ```typescript
168
+ const AdvancedVizFactory: RenderPluginFactory<CoreVizPluginInstance> = {
169
+ name: 'advanced_viz',
170
+
171
+ matches: (field, fieldTag, fieldType) => {
172
+ return fieldType === FieldType.RepeatedRecord && fieldTag.has('advanced_viz');
173
+ },
174
+
175
+ create: (field, pluginOptions, modelTag) => {
176
+ let processedData: any = null;
177
+
178
+ return {
179
+ name: 'advanced_viz',
180
+ field,
181
+ renderMode: 'solidjs',
182
+ sizingStrategy: 'fill',
183
+
184
+ processData: (field, cell) => {
185
+ // Pre-process data for efficiency
186
+ processedData = analyzeData(cell);
187
+ },
188
+
189
+ beforeRender: (metadata, options) => {
190
+ // Prepare rendering context
191
+ },
192
+
193
+ renderComponent: (props) => {
194
+ return <MyComplexVisualization data={processedData} />;
195
+ },
196
+
197
+ getMetadata: () => ({ type: 'advanced_viz' }),
198
+
199
+ // Core viz methods
200
+ getSchema: () => mySettingsSchema,
201
+ getSettings: () => currentSettings,
202
+ getDefaultSettings: () => defaultSettings,
203
+ settingsToTag: (settings) => convertToTag(settings)
204
+ };
205
+ }
206
+ };
207
+ ```
208
+
209
+ ## Using Plugins
210
+
211
+ ### 1. Register Plugins with Renderer
212
+
213
+ ```typescript
214
+ import { MalloyRenderer } from '@malloydata/malloy-render';
215
+
216
+ const renderer = new MalloyRenderer({
217
+ plugins: [
218
+ MyPluginFactory,
219
+ MyDOMPluginFactory,
220
+ AdvancedVizFactory
221
+ ],
222
+ pluginOptions: {
223
+ 'my_plugin': { color: 'blue' },
224
+ 'advanced_viz': { theme: 'dark' }
225
+ }
226
+ });
227
+ ```
228
+
229
+ ### 2. Tag Fields in Malloy
230
+
231
+ ```malloy
232
+ source: users is table('users') {
233
+ dimension:
234
+ status # my_plugin
235
+ age # advanced_viz { "max_value": 100 }
236
+ }
237
+ ```
238
+
239
+ ## Plugin Lifecycle
240
+
241
+ 1. **Registration** - Plugins are registered when creating a MalloyRenderer
242
+ 2. **Matching** - For each field, the system checks all plugins' `matches()` method
243
+ 3. **Instantiation** - Matching plugins are instantiated via `create()`
244
+ 4. **Data Processing** - Optional `processData()` is called during result processing
245
+ 5. **Pre-render** - Optional `beforeRender()` prepares the rendering context
246
+ 6. **Rendering** - `renderComponent()` or `renderToDOM()` displays the visualization
247
+ 7. **Cleanup** - For DOM plugins, `cleanup()` is called when removing the visualization
248
+
249
+ ## Best Practices
250
+
251
+ ### 1. Field Type Validation
252
+ Throw from `matches()` when the user asked for this plugin (tag present) but the field cannot support it — the user sees a red error tile in place of the visualization:
253
+
254
+ ```typescript
255
+ matches: (field, fieldTag, fieldType) => {
256
+ if (fieldTag.has('my_chart') && fieldType !== FieldType.RepeatedRecord) {
257
+ throw new Error('my_chart requires a repeated record field');
258
+ }
259
+ return fieldTag.has('my_chart') && fieldType === FieldType.RepeatedRecord;
260
+ }
261
+ ```
262
+
263
+ For tag-setting errors that shouldn't fail the whole render (e.g. invalid enum value, wrong field type for one setting), use `validateFieldTags()` with `log.error(msg, tagRef)` instead so the user gets a source-located error and the plugin still renders with defaults. See [validation.md](validation.md) for the full throw-vs-log rule.
264
+
265
+ ### 2. Efficient Data Processing
266
+ Use `processData()` for expensive computations to avoid re-processing during re-renders:
267
+
268
+ ```typescript
269
+ processData: (field, cell) => {
270
+ // Calculate once during data loading
271
+ this.aggregatedData = expensiveCalculation(cell);
272
+ }
273
+ ```
274
+
275
+ ### 3. Sizing Strategy
276
+ - Use `'fixed'` for plugins with predetermined dimensions
277
+ - Use `'fill'` for plugins that adapt to container size
278
+
279
+ ### 4. Error Handling
280
+ The right place to catch tag/configuration mistakes is setup time (in `create()` or in a resolver), not render time. A try/catch in `renderComponent()` is a last resort for defensive coverage of unexpected runtime failures — see [validation.md](validation.md) for the setup-time pattern.
281
+
282
+ ```typescript
283
+ renderComponent: (props) => {
284
+ try {
285
+ return <MyVisualization {...props} />;
286
+ } catch (error) {
287
+ return <div class="error">Visualization error: {error.message}</div>;
288
+ }
289
+ }
290
+ ```
291
+
292
+ ### 5. Plugin Options
293
+ Accept configuration through pluginOptions for flexibility:
294
+
295
+ ```typescript
296
+ create: (field, pluginOptions) => {
297
+ const options = pluginOptions as MyPluginOptions || defaultOptions;
298
+ // Use options in plugin implementation
299
+ }
300
+ ```
301
+
302
+ ## Built-in Plugins
303
+
304
+ Malloy Render includes several built-in plugins:
305
+
306
+ - **LineChartPlugin** - Time series and trend visualizations
307
+ - **BarChartPlugin** - Categorical data comparisons
308
+
309
+ These serve as excellent examples for creating custom plugins.
310
+
311
+ ## Debugging
312
+
313
+ Plugin throws from `matches()` and `create()` are caught by `RenderFieldMetadata.instantiatePluginsForField` and rendered as red error tiles (via `ErrorPlugin`). They also appear in `MalloyViz.getLogs()` as errors. Tag validation errors emitted via `log.error(msg, tagRef)` appear in the same log stream with source locations. See [validation.md](validation.md) for the full error flow.
314
+
315
+ ## Future Considerations
316
+
317
+ The plugin system is designed to be extensible. Future enhancements may include:
318
+ - Plugin communication mechanisms
319
+ - Shared plugin state management
320
+ - Plugin composition patterns
321
+ - Enhanced lifecycle hooks
@@ -0,0 +1,72 @@
1
+ | Tag | Description | Details | Example |
2
+ | :-------------------------------- | :----------------------------------------- | :------------------------------------------------------------------------------------------------ | :---------------------------------------------------------------------------------------------------------------------------- |
3
+ | **Charts & Visualization** | | | |
4
+ | `# bar_chart` | Render data as a bar chart. | Base tag for bar chart configuration. Axes/series often inferred. | `view: my_view is { # bar_chart ... }` |
5
+ | `# bar_chart.stack` | Stack bars in a series. | Boolean property. Use when a `series` field is defined. | `view: my_view is { # bar_chart.stack ... }` |
6
+ | `# bar_chart.size` | Set preset chart size. | Values: `spark`, `xs`, `sm`, `md`, `lg`, `xl`, `2xl`. | `view: my_view is { # bar_chart { size=lg } ... }` |
7
+ | `# bar_chart.size.width` | Set specific chart width. | Value is in pixels. Overrides preset width. | `view: my_view is { # bar_chart { size.width=450 } ... }` |
8
+ | `# bar_chart.size.height` | Set specific chart height. | Value is in pixels. Overrides preset height. | `view: my_view is { # bar_chart { size.height=300 } ... }` |
9
+ | `# bar_chart.x` | Define x-axis field. | Assigns a field to the category axis. | `view: my_view is { # bar_chart { x=category } ... }` |
10
+ | `# bar_chart.x.limit` | Limit categories on x-axis. | Numeric value. Limits bars shown. Auto-calculated if not set. | `view: my_view is { # bar_chart { x.limit=10 } ... }` |
11
+ | `# bar_chart.x.independent` | Control x-axis scale sharing. | Boolean (`true`/`false`). Overrides auto-sharing based on cardinality (default shares if <=20). | `view: my_nest is { # bar_chart { x.independent } ...}` |
12
+ | `# bar_chart.y` | Define y-axis field(s). | Assigns field(s) to the value axis. Use array `['m1', 'm2']` for measure series. | `view: my_view is { # bar_chart { y=total_sales } ... }` <br> `view: my_view is { # bar_chart { y=['Sales', 'Cost'] } ... }` |
13
+ | `# bar_chart.y.independent` | Control y-axis scale sharing. | Boolean (`true`/`false`). Default is shared. Use `true` for independent scales in nested charts. | `view: my_nest is { # bar_chart { y.independent } ... }` |
14
+ | `# bar_chart.series` | Define series field. | Assigns a field for grouping/coloring bars. | `view: my_view is { # bar_chart { series=department } ... }` |
15
+ | `# bar_chart.series.limit` | Limit number of series shown. | Numeric value or `auto`. Default `auto` uses 20. Series ranked by sum of Y-values. | `view: my_view is { # bar_chart { series.limit=5 } ... }` <br> `view: my_view is { # bar_chart { series.limit=auto } ... }` |
16
+ | `# bar_chart.series.independent` | Control series legend/color sharing. | Boolean (`true`/`false`). Overrides auto-sharing based on cardinality (default shares if <=20). | `view: my_nest is { # bar_chart { series.independent } ... }` |
17
+ | `# bar_chart.title` | Set chart title. | String value. | `view: my_view is { # bar_chart { title='Sales Distribution' } ... }` |
18
+ | `# bar_chart.subtitle` | Set chart subtitle. | String value. | `view: my_view is { # bar_chart { subtitle='Q1 Data' } ... }` |
19
+ | `# line_chart` | Render data as a line chart. | Base tag for line chart configuration. Axes/series often inferred. | `view: my_view is { # line_chart ... }` |
20
+ | `# line_chart.zero_baseline` | Control if y-axis includes zero. | Boolean (`true`/`false`). Default `false` (unlike bar charts). `true` forces axis to start at zero. | `view: my_view is { # line_chart { zero_baseline=true } ... }` |
21
+ | `# line_chart.size` | Set preset chart size. | Values: `spark`, `xs`...`2xl`. | `view: my_view is { # line_chart { size=spark } ... }` |
22
+ | `# line_chart.interpolate` | Set line interpolation mode. | E.g., `step`. | `view: my_view is { # line_chart { interpolate=step } ... }` |
23
+ | `# line_chart.x` | Define x-axis field. | Assigns a field (often time-based) to the x-axis. | `view: my_view is { # line_chart { x=sale_date } ... }` |
24
+ | `# line_chart.x.independent` | Control x-axis scale sharing. | Boolean (`true`/`false`). Overrides auto-sharing (default shares if <=20). | `view: my_nest is { # line_chart { x.independent } ... }` |
25
+ | `# line_chart.y` | Define y-axis field(s). | Assigns field(s) to the value axis. Use array `['m1', 'm2']` for multiple lines from measures. | `view: my_view is { # line_chart { y=value } ... }` <br> `view: my_view is { # line_chart { y=['MetricA', 'MetricB'] } ... }` |
26
+ | `# line_chart.y.independent` | Control y-axis scale sharing. | Boolean (`true`/`false`). Default is shared, unless `series.limit` is used. | `view: my_nest is { # line_chart { y.independent } ... }` |
27
+ | `# line_chart.series` | Define series field. | Assigns a field for multiple lines. | `view: my_view is { # line_chart { series=category } ... }` |
28
+ | `# line_chart.series.limit` | Limit number of series lines. | Numeric value or `auto`. Default `auto` uses 12. Series ranked by sum of Y-values. Implies `y.independent=true`. | `view: my_view is { # line_chart { series.limit=5 } ... }` <br> `view: my_view is { # line_chart { series.limit=auto } ... }` |
29
+ | `# line_chart.series.independent` | Control series legend/color sharing. | Boolean (`true`/`false`). Overrides auto-sharing (default shares if <=20). | `view: my_nest is { # line_chart { series.independent } ... }` |
30
+ | `# line_chart.title` | Set chart title. | String value. | `view: my_view is { # line_chart { title='Trend Over Time' } ... }` |
31
+ | `# line_chart.subtitle` | Set chart subtitle. | String value. | `view: my_view is { # line_chart { subtitle='Daily Values' } ... }` |
32
+ | `# scatter_chart` | Render as scatter plot. | Legacy renderer. Uses field order (x, y, color, size, shape). | `view: my_view is { # scatter_chart ... }` |
33
+ | **Layout & Structure** | | | |
34
+ | `# dashboard` | Render nested views as a dashboard. | Base tag for dashboard layout. | `view: my_dashboard is { # dashboard nest: ... }` |
35
+ | `# dashboard.table.max_height` | Set max height for tables in dashboard. | Numeric pixel value or `'none'`. | `view: my_dashboard is { # dashboard { table.max_height=400 } ... }` |
36
+ | `# dashboard.columns` | Columns mode: N equal-width columns. | Integer. A tile takes one column by default; tiles wrap onto new rows. Use `# colspan` to widen a tile. | `view: my_dashboard is { # dashboard { columns=3 } nest: ... }` |
37
+ | `# dashboard.gap` | Spacing between tiles (pixels). | Applies in flex mode and columns mode. Default 16. | `view: my_dashboard is { # dashboard { gap=24 } nest: ... }` |
38
+ | `# colspan` | Tile width in columns mode (in columns). | Applied to a `nest:`. Makes the tile span N columns (one by default); ignored in flex mode. | `view: my_dashboard is { # dashboard { columns=12 } # colspan=8 nest: a is {...} # colspan=4 nest: b is {...} }` |
39
+ | `# break` | Start a new row in a dashboard. | Applied to a `nest:` definition. Tiles after it begin a fresh row. | `view: my_dashboard is { nest: chart1 is {...} # break nest: chart2 is {...} }` |
40
+ | `# table` | Render data as a table. | Often the default for nested data. | `view: my_table is { # table ... }` |
41
+ | `# table.pivot` | Pivot table dimensions into columns. | Use `# pivot` for automatic, or `# pivot { dimensions=["d1"] }` for specific dimensions. | `nest: my_pivot is { # pivot ... }` <br> `nest: my_pivot is { # pivot { dimensions=["country"] } ... }` |
42
+ | `# table.flatten` | Flatten nested record into columns. | Applied to a `nest:` definition. Nested query must not have `group_by`. | `nest: metrics is { # flatten ... }` |
43
+ | `# table.size=fill` | Make table fill container width. | Boolean property applied via value. | `view: full_width_table is { # table.size=fill ... }` |
44
+ | `# table.column.width` | Set specific column width. | Apply to a field. Value: `xs`, `sm`, `md`, `lg`, `xl`, `2xl` or pixels. | `dimension: my_col is ... # column { width=lg }` <br> `dimension: my_col is ... # column { width=150 }` |
45
+ | `# table.column.height` | Set specific row height for column cells. | Apply to a field. Value in pixels. | `dimension: my_col is ... # column { height=50 }` |
46
+ | `# table.column.word_break` | Control word breaking in cells. | Value: `break_all`. Apply to a field. | `dimension: long_text is ... # column { word_break=break_all }` |
47
+ | `# list` | Render first field as list. | Comma-separated values from the first non-hidden field of a nest. | `nest: top_items is { # list ... }` |
48
+ | `# list_detail` | Render first two fields as list. | Comma-separated `value (detail)` from first two non-hidden fields. | `nest: item_details is { # list_detail ... }` |
49
+ | `# transpose` | Transpose table rows/columns. | Applied at the view level. Default limit of 20 columns; use `.limit=N` to increase. | `view: metrics_row is { # transpose ... }` <br> `# transpose.limit=100` |
50
+ | **Field Formatting/Rendering** | | | |
51
+ | `# currency` | Format number as currency. | Default is USD ($). Specify units like `euro` or `pound`. | `measure: revenue is ... # currency` <br> `measure: revenue_eur is ... # currency=euro` |
52
+ | `# percent` | Format number as percentage. | Multiplies by 100, adds '%'. | `measure: margin is ... # percent` |
53
+ | `# number` | Format number with `ssf` string. | Provide format string as value. | `measure: my_num is ... # number="#,##0.0"` |
54
+ | `# duration` | Format number as duration. | Default input unit: `seconds`. Specify units (`nanoseconds`..`days`). | `measure: avg_time is ... # duration` <br> `measure: compute is ... # duration=milliseconds` |
55
+ | `# duration.terse` | Use abbreviated duration units. | E.g., `ns`, `s`, `m`, `h`, `d`. | `measure: short_time is ... # duration.terse` |
56
+ | `# duration.number` | Format numeric parts of duration. | Uses `ssf` format string. | `measure: precise_duration is ... # duration { number="0.00" }` |
57
+ | `# image` | Render string as image URL. | Applied to a string field containing a URL. | `dimension: logo_url is ... # image` |
58
+ | `# image.height` | Set image height. | CSS value (e.g., `40px`). | `dimension: logo_url is ... # image { height=40px }` |
59
+ | `# image.width` | Set image width. | CSS value (e.g., `100px`). | `dimension: logo_url is ... # image { width=100px }` |
60
+ | `# image.alt` | Set image alt text. | Literal string value. | `dimension: logo_url is ... # image { alt='Company Logo' }` |
61
+ | `# image.alt.field` | Use another field for alt text. | Value is relative path to field (e.g., `field_name`, `'../parent_field'`). | `dimension: logo_url is ... # image { alt.field=product_name }` |
62
+ | `# link` | Render field as hyperlink. | Applied to a field whose value is the link text. | `dimension: url is ... # link` |
63
+ | `# link.url_template` | Template for link href. | String where `$$` is replaced by value (from this field or `.field`). Appends if `$$` is missing. | `dimension: name is ... # link { url_template="https://example.com/$$" }` |
64
+ | `# link.field` | Use another field's value for href. | Value is relative path to the field containing the href data. | `dimension: link_text is 'Search' # link { field=query_term url_template="https://google.com/search?q=$$" }` |
65
+ | **Utilities & Configuration** | | | |
66
+ | `# hidden` | Hide field from rendering. | Field remains in data, just not displayed in tables/dashboards. | `dimension: internal_id is ... # hidden` |
67
+ | `# label` | Override display name/title. | String value. Applied to fields or dashboard items. | `measure: total_revenue is ... # label="Total Sales ($)"` |
68
+ | `# tooltip` | Include nested view in tooltip. | Applied to a `nest:`. Can contain render hints for the tooltip itself. | `nest: details is { # tooltip ... }` <br> `nest: chart_tip is { # tooltip bar_chart.size=xs ... }` |
69
+ | `# size` | Set preset size (legacy). | Prefer `.size` on specific renderer tags. Values: `spark`, `xs`...`2xl`. Applied to view/nest. | `view: my_view is { # size=lg ... }` |
70
+ | `# theme` | Apply theme style overrides (View level). | Contains CSS-like properties (e.g., `tableBodyColor`, `tableRowHeight`). | `view: my_view is { # theme { tableBodyColor=red } ... }` |
71
+ | `## theme` | Apply theme style overrides (Model level). | Contains CSS-like properties. Sets defaults for the model. | `## theme { tableBodyColor=blue }` |
72
+ | `## renderer_legacy` | Use legacy HTML renderer for model. | Model-level tag. No properties. | `## renderer_legacy` |