tasks/workstreams/graph-library/initiatives/m2-table-formatting-and-linking-architecture/spec.md

Spec

Design Goals

Non-Goals (M2)

M2 Configuration Shape (table charts)

Table presentation lives under the chart’s existing style key, nested so it stays separate from generic chart chrome (background, border_radius, …) and matches how Vega groups mark-specific options.

charts:
  my_table:
    type: table
    query: my_query
    style:
      background: null
      table:
        density: compact
        zebra: true
        rowBackgroundColor: row_highlight_color
      columns:
        revenue:
          hidden: false
          header:
            title: Revenue
            align: right
          value:
            number:
              style: currency
              currency: USD
              decimals: 0
          link: "https://wiki.internal/metrics/revenue"

Every key under style.table and style.columns is optional; omitted keys use product defaults (renderer/theme). Generic style keys (background, etc.) follow existing chart behavior.

Naming note: Vega-Lite uses the field name format (and formatType) inside axis/tooltip/value specs for number/date display. That is unrelated to a top-level format key — those leaves still appear under style.columns.<id>.value.* per Vega-Lite parity.

Tables, columns, and cells (what we did not put in YAML)

So: we dropped cell as a config concept, not cell as a rendered thing.

1) style.table (table-wide)

Shared defaults affecting whole table behavior. All fields optional. Only meaningful for type: table (compiler errors or ignores on other chart types — pick one and document).

style:
  background: "#fafafa"
  table:
    density: compact
    zebra: true
    defaultRowHeight: 32
    gridLines: horizontal

Terminology: density controls row padding / vertical rhythm (e.g. compact vs comfortable). zebra enables alternating row backgrounds for readability.

Whole-row background from a column (table scope)

Row-level rules are still out of scope (no rules engine). One explicit table-level exception: a single background for all cells in the same data row, driven by a column (query computes #ffcccc, null, etc., per row).

style:
  table:
    rowBackgroundColor: row_highlight_color

Workaround if a row key is not implemented yet: set the same source column on style.columns.<col>.style.backgroundColor for every visible body column (verbose but equivalent visually).

2) style.columns

Per-column overrides keyed by stable query column ID.

style:
  columns:
    revenue:
      hidden: false
      header:
        title: Revenue
        align: right
      value:
        number:
          style: currency
          currency: USD
          decimals: 0
      link: "https://wiki.internal/metrics/revenue"

Why hidden is column-level (not cell-level)

Column Keying (M2)

Column-ID-first resolution (normative, global within column config)

For scalar string values under style.columns.<id> where the field means “display / URL / CSS-like value for this cell,” resolution is always:

  1. If the string equals a known column ID for this query → use per-row values from that column.
  2. Otherwise → treat as a literal (validate: color, URL scheme, font weight token, etc.).

One key per concern — never parallel *FromColumn keys.

Where this applies (string fields, column scope)

Use column-ID-first for any of these when the YAML value is a plain string (not a nested mapping):

Area Examples
Column style object (style.columns.<id>.style) textColor, backgroundColor, fontWeight, borderColor, …
link Scalar link: … (static URL, column ID, or template string — see Links)
visual series; color strings such as positiveColor, negativeColor if they are plain strings
value String adornments that are allowed as strings, e.g. prefix, suffix on number display, if present as scalars

Templates: A string containing curly-brace placeholders (for example, <code>&#123;&#123; column_id &#125;&#125;</code>) or the product’s one canonical placeholder syntax is a template, not a column ID lookup on the whole string. Placeholders refer to column IDs inside the template; the outer string is still a template literal.

Where this does not apply

Area Why
style.table.* Defaults are literals/enums except explicitly row-scoping keys (currently rowBackgroundColor), which use column-ID-first like column style strings.
Generic chart style keys background, border_radius, … — literals/enums for chart chrome, not column-ID-first (unless explicitly documented otherwise).
Booleans / numbers / enums hidden, visual.type, visual.mode, density, gridLines, align enums — not column-ID strings.
Nested value specs Objects under value.number, value.date, etc. follow Vega-Lite format parity (see below); individual string leaves inside those objects still use column-ID-first if the field is defined as row-varying in schema.
header.title Literal only for M2 (display name for the column). Avoid accidental resolution if a column ID equals the intended title text; authors can use query metadata for titles when needed.

Collision (column ID vs literal)

If a literal would be valid but matches a column ID, column wins. Authors who must force a literal that equals an ID use an explicit escape documented in schema (for example a reserved prefix) or rename the column in the query — pick one rule in the compiler and test it.

Column-level behavior (applies to each cell in that column)

Attributes that may reference another column (column-ID-first)

Only plain strings in these places participate in column-ID-first resolution (see exceptions above). Below, placeholder names must be query column IDs on the same query as the table.

style.table (row-scoping exception)

Attribute Example YAML
rowBackgroundColor under style.table: rowBackgroundColor: row_highlight_color

Column style object (style.columns.&lt;id&gt;.style)

Attribute Example YAML
textColor textColor: status_text_color
backgroundColor backgroundColor: row_bg_hex
fontWeight fontWeight: emphasis_flag (cell must contain valid weight token, e.g. bold)
borderColor borderColor: border_hex_col
style:
  columns:
    amount:
      style:
        textColor: amount_text_color
        backgroundColor: amount_bg_color
        fontWeight: amount_weight
        borderColor: amount_border_color
Form Example YAML
URL from column link: profile_url
Static URL link: &quot;https://example.com/docs&quot;
Template {% raw %}link: &quot;https://app.example/u/{{ user_id }}&quot;{% endraw %}
style:
  columns:
    name:
      link: profile_url

visual.* (in-cell sparkline / bar on the target column)

Attribute Example YAML
series series: revenue_series (column holds series data per row)
positiveColor positiveColor: pos_hex_col
negativeColor negativeColor: neg_hex_col
style:
  columns:
    trend:
      visual:
        type: sparkline
        series: spark_values
    margin_pct:
      visual:
        type: bar
        mode: diverging
        positiveColor: bar_pos_color
        negativeColor: bar_neg_color

value.* string leaves (Vega-Lite-shaped; only listed string fields)

String fields under value that the schema exposes as scalars may use column-ID-first (e.g. per-row unit glyph or suffix). Exact leaf names follow Vega-Lite parity—commonly includes adornments such as:

Attribute (illustrative) Example YAML
value.number.prefix prefix: currency_symbol_col
value.number.suffix suffix: unit_suffix_col
style:
  columns:
    revenue:
      value:
        number:
          style: decimal
          prefix: currency_glyph
          suffix: scale_suffix

If a string equals a column ID, the cell shows that row’s value from the source column; otherwise it is a fixed literal. Non-string value fields (decimals, enums like value.number.style, nested spec objects, etc.) do not use column-ID-first unless explicitly documented as row-varying strings.

Cross-Column Mapping (examples)

A) Text column + URL column => linked text column

style:
  columns:
    profile_url:
      hidden: true
    name:
      link: profile_url

Behavior: - name is displayed; URL comes from profile_url in the same row. - If URL value is null/invalid, row renders plain text (no link).

B) Color / typography from other columns

style:
  columns:
    status_text:
      style:
        backgroundColor: status_color
        textColor: status_text_color
        fontWeight: status_font_weight

C) Same pattern for bar colors driven by data

style:
  columns:
    margin_pct:
      visual:
        type: bar
        mode: diverging
        positiveColor: pos_color_column
        negativeColor: neg_color_column

If pos_color_column is a column ID, per-row colors apply; if not, fall back to literal hex/CSS.

In-cell visual encoding (formatting-owned)

Rule: Sparklines, data bars, and related micro-charts are cell presentation, not a separate chart type. They live under style.columns.&lt;id&gt;.visual, use query-owned data shape (series array / numeric value in row data), and use the same column-ID-first rule for any string field that can be row-sourced (e.g. series, optional color strings).

Supported in M2: - visual.type: sparkline - visual.type: bar

style:
  columns:
    revenue_trend:
      visual:
        type: sparkline
        series: revenue_series
    margin_pct:
      visual:
        type: bar
        mode: diverging
        positiveColor: &quot;#1B9E5A&quot;
        negativeColor: &quot;#D64545&quot;

Value formatting and Vega-Lite parity

Number and date display under style.columns.&lt;id&gt;.value should mirror Vega-Lite’s format and formatType fields (same names, same meaning) so authors learn one model across charts and tables. Implementation may live in shared Python formatting used by SVG charts and table renderers; see initiative task Vega-Lite format parity for tables and Python formatters.

This is intentionally not a bespoke mini-i18n engine in YAML: advanced locale behavior follows whatever Vega-Lite’s format model supports once implemented.

Chart style for table charts (vs other keys)

Table charts (M2): under type: table, style may include:

Other chart types: they do not use style.table / style.columns. They keep using style for chrome, plus settings, mark / encoding passthrough, KPI format, etc. — see CompiledChart / chart defaults.

Flatter vs nested (Vega-Lite alignment)

Precedence (M2)

For table-specific presentation:

  1. style.table
  2. style.columns.&lt;id&gt;

Generic chart style keys (e.g. chart background) compose with table rendering as documented (container vs table body).

Duplicate YAML keys (global)

Duplicate keys in a single YAML mapping are invalid — compile error. This policy applies to all dashboard/face YAML the compiler loads. Do not rely on parser-specific “last key wins.”

Implementation note: Centralize YAML loading on a path that rejects duplicates (loader option or post-parse check). This is a small, one-time wiring change if parsing already goes through one module; no need for a heavy validator framework.

Query/Data Responsibility (M2)

Convenience exceptions: header.title (literal) and value.* objects (Vega-Lite-shaped, including VL’s format / formatType leaves) are allowed in YAML so authors are not forced to push every display concern into SQL — document precedence when both query metadata and formatter settings exist.

Jinja Best Practice (Normative for M2)

Example:

{% raw %}

{% macro signed_text_color(expr) -%}
case
  when {{ expr }} &lt; 0 then &#x27;#D64545&#x27;
  when {{ expr }} &gt; 0 then &#x27;#111111&#x27;
  else &#x27;#666666&#x27;
end
{%- endmacro %}
select
  metric_value,
  {{ signed_text_color(&#x27;metric_value&#x27;) }} as metric_value_text_color
from my_table

{% endraw %}

style:
  columns:
    metric_value:
      style:
        textColor: metric_value_text_color

M2 deliverables (single milestone)

Everything below ships in this milestone. Work may land in small implementation PRs, but there is no staged product rollout (“half the model in prod”) — the released behavior matches this spec.

Acceptance Criteria