dataface 0.3.2.dev22__py3-none-any.whl → 0.3.2.dev111__py3-none-any.whl

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.
Files changed (185) hide show
  1. dataface/__init__.py +70 -23
  2. dataface/agent_api/__init__.py +162 -68
  3. dataface/agent_api/_paths.py +9 -3
  4. dataface/agent_api/docs/yaml-reference.md +240 -106
  5. dataface/agent_api/init.py +1 -1
  6. dataface/agent_api/project_session.py +2 -1
  7. dataface/agent_api/serve.py +115 -0
  8. dataface/ai/agent.py +28 -2
  9. dataface/cli/_project.py +100 -1
  10. dataface/cli/commands/board_artifact.py +5 -2
  11. dataface/cli/commands/describe.py +7 -3
  12. dataface/cli/commands/docs.py +12 -3
  13. dataface/cli/commands/init.py +7 -14
  14. dataface/cli/commands/inspect.py +6 -1
  15. dataface/cli/commands/mcp_init.py +23 -43
  16. dataface/cli/commands/migrate.py +8 -3
  17. dataface/cli/commands/query.py +11 -8
  18. dataface/cli/commands/render.py +20 -9
  19. dataface/cli/commands/search.py +8 -2
  20. dataface/cli/commands/serve.py +26 -76
  21. dataface/cli/commands/skills_init.py +2 -19
  22. dataface/cli/commands/validate.py +7 -2
  23. dataface/cli/filesystem_project.py +5 -4
  24. dataface/cli/main.py +2 -19
  25. dataface/core/__init__.py +73 -28
  26. dataface/core/{compile/colors.py → colors.py} +20 -5
  27. dataface/core/compile/compiler.py +97 -34
  28. dataface/core/compile/data_table.py +7 -2
  29. dataface/core/compile/enrich.py +18 -44
  30. dataface/core/compile/face_warnings.py +46 -8
  31. dataface/core/compile/format.py +47 -1
  32. dataface/core/compile/inherit_graph.py +3 -3
  33. dataface/core/compile/inherit_registry.yaml +1 -1
  34. dataface/core/compile/migrations.py +112 -9
  35. dataface/core/compile/models/chart/normalized/kpi.py +0 -4
  36. dataface/core/compile/models/chart/resolved/_base.py +28 -10
  37. dataface/core/compile/models/chart/resolved/callout.py +2 -3
  38. dataface/core/compile/models/chart/resolved/kpi.py +7 -3
  39. dataface/core/compile/models/chart/resolved/table.py +16 -2
  40. dataface/core/compile/models/markers.py +1 -1
  41. dataface/core/compile/models/primitives.py +175 -2
  42. dataface/core/compile/models/source.py +5 -5
  43. dataface/core/compile/models/style/authored/__init__.py +32 -8
  44. dataface/core/compile/models/style/authored/_base.py +126 -69
  45. dataface/core/compile/models/style/authored/table.py +61 -3
  46. dataface/core/compile/models/style/resolved/__init__.py +12 -2
  47. dataface/core/compile/models/style/resolved/_base.py +142 -57
  48. dataface/core/compile/models/style/resolved/_cartesian.py +6 -11
  49. dataface/core/compile/models/style/resolved/_cascade.py +141 -54
  50. dataface/core/compile/models/style/resolved/area.py +1 -1
  51. dataface/core/compile/models/style/resolved/bar.py +1 -1
  52. dataface/core/compile/models/style/resolved/line.py +1 -1
  53. dataface/core/compile/models/style/resolved/scatter.py +3 -2
  54. dataface/core/compile/models/style/theme/__init__.py +36 -14
  55. dataface/core/compile/models/style/theme/_chart_base.py +17 -9
  56. dataface/core/compile/models/style/theme/axis.py +432 -312
  57. dataface/core/compile/models/style/theme/charts.py +10 -6
  58. dataface/core/compile/models/style/theme/kpi.py +2 -1
  59. dataface/core/compile/models/style/theme/page.py +5 -2
  60. dataface/core/compile/models/style/theme/table.py +2 -2
  61. dataface/core/compile/models/vega_lite/config.py +1 -1
  62. dataface/core/compile/normalize_charts.py +6 -2
  63. dataface/core/compile/normalize_queries.py +21 -7
  64. dataface/core/compile/resolve/__init__.py +2 -0
  65. dataface/core/compile/resolve/_axes.py +147 -44
  66. dataface/core/compile/resolve/_channels.py +1 -1
  67. dataface/core/compile/resolve/_dispatch.py +1 -1
  68. dataface/core/compile/resolve/_domain.py +49 -32
  69. dataface/core/compile/resolve/_kwargs.py +39 -12
  70. dataface/core/compile/resolve/_marks.py +8 -3
  71. dataface/core/compile/resolve/_palette.py +7 -3
  72. dataface/core/compile/resolve/_table.py +124 -33
  73. dataface/core/compile/resolve/area.py +48 -18
  74. dataface/core/compile/resolve/bar.py +245 -64
  75. dataface/core/compile/resolve/geo.py +10 -3
  76. dataface/core/compile/resolve/heatmap.py +16 -8
  77. dataface/core/compile/resolve/line.py +35 -12
  78. dataface/core/compile/resolve/pie.py +4 -1
  79. dataface/core/compile/resolve/pie_attachment.py +1 -1
  80. dataface/core/compile/resolve/scatter.py +25 -13
  81. dataface/core/compile/resolve/simple.py +25 -2
  82. dataface/core/compile/schema_renderers/prompt.py +1 -1
  83. dataface/core/compile/schema_renderers/textmate_grammar.py +8 -1
  84. dataface/core/compile/source_map.py +211 -28
  85. dataface/core/compile/style_cascade.py +126 -55
  86. dataface/core/compile/yaml_error_formatter.py +1 -1
  87. dataface/core/connections.py +10 -3
  88. dataface/core/dashboard.py +24 -18
  89. dataface/core/defaults/themes/_base.yaml +49 -39
  90. dataface/core/defaults/themes/cream.yaml +4 -4
  91. dataface/core/defaults/themes/diagnostics-title-baseline.yaml +1 -1
  92. dataface/core/defaults/themes/diagnostics-title-color-extreme.yaml +1 -1
  93. dataface/core/defaults/themes/diagnostics-title-font-extreme.yaml +1 -1
  94. dataface/core/defaults/themes/diagnostics-title-size-extreme.yaml +1 -1
  95. dataface/core/defaults/themes/diagnostics-title-weight-extreme.yaml +1 -1
  96. dataface/core/defaults/themes/editorial.yaml +6 -6
  97. dataface/core/defaults/themes/neon.yaml +15 -8
  98. dataface/core/defaults/themes/plain.yaml +6 -4
  99. dataface/core/defaults/themes/stark.yaml +16 -7
  100. dataface/core/defaults/themes/vivid.yaml +15 -8
  101. dataface/core/diagnostics/__init__.py +2 -2
  102. dataface/core/diagnostics/codes_compile.py +13 -31
  103. dataface/core/diagnostics/codes_execute.py +79 -0
  104. dataface/core/diagnostics/codes_render.py +87 -28
  105. dataface/core/diagnostics/diagnostic.py +30 -0
  106. dataface/core/diagnostics/hints.py +19 -0
  107. dataface/core/dialects/base.py +17 -0
  108. dataface/core/dialects/postgres.py +14 -0
  109. dataface/core/dialects/redshift.py +5 -0
  110. dataface/core/execute/__init__.py +59 -14
  111. dataface/core/execute/adapters/dbt_adapter.py +4 -1
  112. dataface/core/execute/adapters/dbt_adapter_factory.py +19 -0
  113. dataface/core/execute/adapters/dbt_utils.py +84 -20
  114. dataface/core/execute/adapters/postgres_connection_manager.py +48 -0
  115. dataface/core/execute/adapters/sql_adapter.py +72 -4
  116. dataface/core/execute/dbt_jinja.py +65 -10
  117. dataface/core/execute/executor.py +69 -16
  118. dataface/core/font_measure.py +28 -0
  119. dataface/core/fonts.py +8 -5
  120. dataface/core/inspect/__init__.py +46 -6
  121. dataface/core/project.py +13 -1
  122. dataface/core/render/chart/data_table_attachment.py +14 -12
  123. dataface/core/render/chart/emitters/_cartesian.py +21 -18
  124. dataface/core/render/chart/emitters/_channels.py +56 -8
  125. dataface/core/render/chart/emitters/_label_overlap.py +18 -15
  126. dataface/core/render/chart/emitters/_measured_label_padding.py +4 -4
  127. dataface/core/render/chart/emitters/area.py +3 -3
  128. dataface/core/render/chart/emitters/bar.py +60 -35
  129. dataface/core/render/chart/emitters/geo.py +8 -13
  130. dataface/core/render/chart/emitters/heatmap.py +20 -26
  131. dataface/core/render/chart/emitters/line.py +2 -2
  132. dataface/core/render/chart/emitters/scatter.py +7 -30
  133. dataface/core/render/chart/features/baseline.py +22 -9
  134. dataface/core/render/chart/features/endpoint_labels.py +55 -32
  135. dataface/core/render/chart/features/mirror_axis.py +12 -9
  136. dataface/core/render/chart/features/value_labels.py +1 -1
  137. dataface/core/render/chart/kpi.py +11 -90
  138. dataface/core/render/chart/presentation.py +6 -8
  139. dataface/core/render/chart/rendering.py +3 -12
  140. dataface/core/render/chart/session.py +10 -0
  141. dataface/core/render/chart/spec.py +3 -0
  142. dataface/core/render/chart/spec_builders.py +18 -18
  143. dataface/core/render/chart/table.py +81 -36
  144. dataface/core/render/chart/table_support.py +2 -53
  145. dataface/core/render/chart/time_unit_detect.py +3 -2
  146. dataface/core/render/chart/translate.py +82 -44
  147. dataface/core/render/chart/type_inference.py +45 -25
  148. dataface/core/render/chart/validation.py +91 -0
  149. dataface/core/render/chart/vega_lite.py +30 -11
  150. dataface/core/render/chart/vl_field_maps.py +111 -76
  151. dataface/core/render/converters/chart.py +16 -6
  152. dataface/core/render/faces.py +3 -2
  153. dataface/core/render/fonts/DFTSerifOldstyleProportional-Regular.woff2 +0 -0
  154. dataface/core/render/fonts/DFTSerifOldstyleTabular-Regular.woff2 +0 -0
  155. dataface/core/render/fonts/SOURCE_CODE_PRO_LICENSE.txt +93 -93
  156. dataface/core/render/fonts/_font_face.css +10 -0
  157. dataface/core/render/layout_sizing.py +267 -238
  158. dataface/core/render/numeral_expr.py +80 -0
  159. dataface/core/render/warnings/bar_band_width_too_narrow.py +64 -6
  160. dataface/core/render/warnings/layered_chart_shared_y_axis_scale_mismatch.py +25 -3
  161. dataface/core/render/warnings/likely_currency_or_percent_missing_formatter.py +12 -6
  162. dataface/core/render/warnings/palette_unsupported.py +6 -0
  163. dataface/core/render/warnings/pie_dominant_segment.py +1 -0
  164. dataface/core/render/warnings/pie_too_many_segments.py +1 -0
  165. dataface/core/render/warnings/point_map_out_of_projection.py +1 -0
  166. dataface/core/render/warnings/query_returned_zero_rows.py +1 -0
  167. dataface/core/render/warnings/redundant_encoding.py +20 -1
  168. dataface/core/render/warnings/registry.py +7 -3
  169. dataface/core/render/warnings/temporal_single_point.py +1 -0
  170. dataface/core/render/warnings/too_many_color_categories.py +1 -0
  171. dataface/core/render/warnings/too_many_x_categories.py +18 -2
  172. dataface/core/render/warnings/value_labels_crowd_width.py +9 -0
  173. dataface/core/render/warnings/y_encoding_mostly_null.py +1 -0
  174. dataface/core/render_format.py +18 -0
  175. dataface/core/text/format_d3.py +56 -0
  176. dataface/core/text/numeral_scale.py +218 -0
  177. dataface/core/utils.py +103 -10
  178. dataface/data/schemas/board-resolved/board-resolved.schema.json +9487 -7978
  179. dataface/data/schemas/yaml/0.3.2.json +1 -0
  180. dataface/data/schemas/yaml/manifest.json +7 -0
  181. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/METADATA +1 -1
  182. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/RECORD +185 -177
  183. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/WHEEL +0 -0
  184. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/entry_points.txt +0 -0
  185. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/licenses/LICENSE +0 -0
@@ -18,6 +18,7 @@ from dataface.core.render.chart.emitters._cartesian import (
18
18
  last_nonnull_value_per_series,
19
19
  sorted_series_by_stack_order,
20
20
  )
21
+ from dataface.core.utils import numeric_column_values, stacked_x_domain_order
21
22
 
22
23
  # Cascade geometry constants.
23
24
  # 1.3: bounding rects kiss at 1.0× but descenders on ascenders touch; 1.3
@@ -124,7 +125,8 @@ def _y_domain(
124
125
  back to raw data range when no explicit domain is authored.
125
126
  """
126
127
  if axis_y.scale is not None:
127
- domain = axis_y.scale.domain
128
+ _ay_cont_el = axis_y.scale.continuous
129
+ domain = _ay_cont_el.domain if _ay_cont_el is not None else None
128
130
  if isinstance(domain, list) and len(domain) == 2:
129
131
  try:
130
132
  lo, hi = float(domain[0]), float(domain[1])
@@ -191,8 +193,8 @@ def _wide_y_domain(
191
193
  axis_y: ResolvedAxisStyle,
192
194
  ) -> tuple[float, float]:
193
195
  """Return the shared measure extent for an area with folded wide measures."""
194
- if axis_y.scale is not None:
195
- domain = axis_y.scale.domain
196
+ if axis_y.scale is not None and axis_y.scale.continuous is not None:
197
+ domain = axis_y.scale.continuous.domain
196
198
  if isinstance(domain, list) and len(domain) == 2:
197
199
  try:
198
200
  lo, hi = float(domain[0]), float(domain[1])
@@ -263,6 +265,30 @@ def _cascade_gap(
263
265
  return label_gap_px * (y_range / (height * _PLOT_AREA_FRACTION))
264
266
 
265
267
 
268
+ def _refuse_unorderable_sort(
269
+ chart_id: str, data: list[dict[str, Any]], sort: Any
270
+ ) -> None:
271
+ """Refuse a `sort:` whose column carries no numbers to total.
272
+
273
+ ``stacked_x_domain_order`` reproduces Vega-Lite's domain order by summing
274
+ the sort field per category, which is what VL's default ``sum`` op does.
275
+ On a non-numeric column VL concatenates the strings instead — an order this
276
+ cannot reproduce — so the rail would anchor on a row VL does not draw on
277
+ top. Resolve steers the default away from this shape; an explicit opt-in
278
+ lands here and gets told why.
279
+ """
280
+ if sort is None or numeric_column_values(data, sort.by):
281
+ return
282
+ raise ChartDataError(
283
+ f"chart.sort by {sort.by!r} cannot be combined with stacked bar "
284
+ "endpoint labels — the label rail reproduces Vega-Lite's domain order "
285
+ "by totalling that column per category, and it carries no numeric "
286
+ "values. To fix: sort by a measure, or set "
287
+ "endpoint_labels.visible: false on this chart.",
288
+ chart_id,
289
+ )
290
+
291
+
266
292
  def _stacked_midpoints(
267
293
  data: list[dict[str, Any]],
268
294
  x_field: str,
@@ -270,6 +296,8 @@ def _stacked_midpoints(
270
296
  series_field: str,
271
297
  stack_mode: str,
272
298
  max_column_total: float,
299
+ sort_by: str,
300
+ descending: bool,
273
301
  stack_order: str | None = None,
274
302
  ) -> list[tuple[str, float]]:
275
303
  """Compute cumulative segment midpoints for vertical stacked bars or areas.
@@ -299,14 +327,8 @@ def _stacked_midpoints(
299
327
  ``_stacked_y_domain`` computes for this data when ``stack_mode ==
300
328
  "center"``.
301
329
  """
302
- # Largest x value = last bar column.
303
- last_x = None
304
- for row in data:
305
- v = row.get(x_field)
306
- if v is None:
307
- continue
308
- if last_x is None or v > last_x:
309
- last_x = v
330
+ domain = stacked_x_domain_order(data, x_field, sort_by, descending)
331
+ last_x = domain[-1] if domain else None
310
332
 
311
333
  values_at_last: dict[str, float] = {}
312
334
  for row in data:
@@ -405,11 +427,14 @@ def _cumulative_midpoints(
405
427
  x_field: str,
406
428
  y_field: str,
407
429
  series_field: str,
430
+ sort_by: str,
431
+ descending: bool,
408
432
  stack_mode: str = "zero",
409
433
  ) -> list[tuple[str, float]]:
410
434
  """Return (series, x_midpoint) for the top categorical row (un-nudged).
411
435
 
412
- The "top row" is the alphabetically first x-domain value. Midpoint is the
436
+ The "top row" is the first value of the rendered x domain — alphabetically
437
+ first, or first under an authored ``sort:``. Midpoint is the
413
438
  cumulative x at the series segment's centre, ordered largest-global-sum first
414
439
  (matching V1's _apply_stacked_bar_z_order default/"value" ordering, which VL
415
440
  enforces via encoding.order + joinaggregate). Sufficient for correct
@@ -418,8 +443,7 @@ def _cumulative_midpoints(
418
443
  For ``stack_mode == "normalize"``, midpoints are divided by the top-row total
419
444
  so they land on the 0..1 scale that VL renders for normalize stacks.
420
445
  """
421
- # Collect all x-domain values to find the alphabetical first.
422
- x_values = sorted({row[x_field] for row in data if row.get(x_field) is not None})
446
+ x_values = stacked_x_domain_order(data, x_field, sort_by, descending)
423
447
  if not x_values:
424
448
  return []
425
449
  top_row_x = x_values[0]
@@ -517,10 +541,7 @@ class EndpointLabelFeature:
517
541
  # Top-row series rail only applies to stacked horizontals; grouped
518
542
  # bars sit side-by-side and form no stacks to label.
519
543
  return chart.stack not in (None, "none")
520
- # Vertical stacked bar has no computed center-stack anchor (unlike
521
- # area's streamgraph case below), so it still skips the feature
522
- # rather than mislabeling a diverging domain.
523
- return not (isinstance(chart, ResolvedBarChart) and chart.stack == "center")
544
+ return True
524
545
 
525
546
  def apply(
526
547
  self,
@@ -579,6 +600,7 @@ class EndpointLabelFeature:
579
600
  y_field = chart.y
580
601
  if not isinstance(y_field, str):
581
602
  return spec
603
+ _refuse_unorderable_sort(chart.id, data, chart.sort)
582
604
  if chart.stack == "center":
583
605
  raise ChartDataError(
584
606
  "stack: 'center' is not supported for horizontal stacked bar "
@@ -586,13 +608,6 @@ class EndpointLabelFeature:
586
608
  "axis, not the diverging (-Σ/2..+Σ/2) center-stack domain.",
587
609
  chart.id,
588
610
  )
589
- if chart.sort is not None:
590
- raise ChartDataError(
591
- "chart.sort cannot be combined with horizontal stacked bar "
592
- "endpoint labels — the label rail anchors at the "
593
- "alphabetical-first row, which chart.sort would move.",
594
- chart.id,
595
- )
596
611
  if _has_negative_measure(data, y_field):
597
612
  raise ChartDataError(
598
613
  "negative values are not supported for horizontal stacked bar "
@@ -600,7 +615,13 @@ class EndpointLabelFeature:
600
615
  chart.id,
601
616
  )
602
617
  positions = _cumulative_midpoints(
603
- data, x_field, y_field, series_field, stack_mode=chart.stack or "zero"
618
+ data,
619
+ x_field,
620
+ y_field,
621
+ series_field,
622
+ chart.sort.by if chart.sort else "",
623
+ bool(chart.sort and chart.sort.order == "desc"),
624
+ stack_mode=chart.stack or "zero",
604
625
  )
605
626
  # For normalize stacks the chart pane's x encoding must explicitly
606
627
  # pin [0, 1] so the shared vconcat x-scale propagates to the rail
@@ -645,12 +666,10 @@ class EndpointLabelFeature:
645
666
  ) and chart.stack not in (None, "none")
646
667
  if is_stacked:
647
668
  assert isinstance(chart, (ResolvedBarChart, ResolvedAreaChart))
648
- # Vertical bar's center-stack never reaches here — applies_to()
649
- # still skips it. Area's center-stack (streamgraph) does reach
650
- # here; its anchors use the same cumulative math offset by the
651
- # center baseline (see _stacked_midpoints/_stacked_y_domain).
652
- assert not (
653
- isinstance(chart, ResolvedBarChart) and chart.stack == "center"
669
+ _refuse_unorderable_sort(
670
+ chart.id,
671
+ data,
672
+ chart.sort if isinstance(chart, ResolvedBarChart) else None,
654
673
  )
655
674
  if _has_negative_measure(data, y_field):
656
675
  raise ChartDataError(
@@ -669,6 +688,8 @@ class EndpointLabelFeature:
669
688
  if isinstance(chart, ResolvedBarChart)
670
689
  else NATIVE_STACK_ORDER
671
690
  )
691
+ # Area has no authored sort; only bar carries one.
692
+ _sort = chart.sort if isinstance(chart, ResolvedBarChart) else None
672
693
  y_domain_min, y_domain_max = _stacked_y_domain(
673
694
  data, x_field, y_field, stack_mode
674
695
  )
@@ -679,6 +700,8 @@ class EndpointLabelFeature:
679
700
  series_field,
680
701
  stack_mode,
681
702
  y_domain_max,
703
+ _sort.by if _sort else "",
704
+ bool(_sort and _sort.order == "desc"),
682
705
  stack_order=stack_order,
683
706
  )
684
707
  # Pin the main pane's y domain so VL's shared hconcat y-scale
@@ -108,7 +108,7 @@ class MirrorAxisFeature:
108
108
  if isinstance(mirror, AxisMirrorStyle):
109
109
  if mirror.format is not None:
110
110
  ghost_axis["format"] = mirror.format
111
- # The primary may carry a labelExpr (style.axis_y.label.expr),
111
+ # The primary may carry a labelExpr (style.axis_y.labels.expr),
112
112
  # which VL prefers over format — drop the inherited copy so the
113
113
  # explicit per-edge format actually takes effect.
114
114
  ghost_axis.pop("labelExpr", None)
@@ -140,7 +140,10 @@ class MirrorAxisFeature:
140
140
  tuple(axis_y.tick_values), ghost_format
141
141
  )
142
142
  elif isinstance(chart.y, str):
143
- domain = axis_y.scale.domain if axis_y.scale is not None else None
143
+ _ay_cont_m = (
144
+ axis_y.scale.continuous if axis_y.scale is not None else None
145
+ )
146
+ domain = _ay_cont_m.domain if _ay_cont_m is not None else None
144
147
  bounds = numeric_domain_bounds(domain)
145
148
  values = numeric_values(chart_rows(chart, datasets), (chart.y,)) + (
146
149
  list(bounds) if bounds is not None else []
@@ -148,14 +151,14 @@ class MirrorAxisFeature:
148
151
  labels = estimated_quantitative_tick_labels(values, ghost_format)
149
152
  if labels:
150
153
  tick_size = (
151
- axis_y.ticks.size
152
- if axis_y.ticks.size is not None
154
+ axis_y.ticks.length
155
+ if axis_y.ticks.length is not None
153
156
  else DEFAULT_VL_TICK_SIZE
154
157
  )
155
- padding = measured_label_padding(labels, axis_y.label.font, tick_size)
158
+ padding = measured_label_padding(labels, axis_y.labels.font, tick_size)
156
159
  label_limit = (
157
- axis_y.label.max_width
158
- if axis_y.label.max_width is not None
160
+ axis_y.labels.max_width
161
+ if axis_y.labels.max_width is not None
159
162
  else DEFAULT_VL_LABEL_LIMIT
160
163
  )
161
164
  ghost_axis["labelPadding"] = cap_padding_to_label_limit(
@@ -163,12 +166,12 @@ class MirrorAxisFeature:
163
166
  )
164
167
  else:
165
168
  raise ChartDataError(
166
- f"axis_y.label.align: {ghost_align!r} cannot be "
169
+ f"axis_y.labels.align: {ghost_align!r} cannot be "
167
170
  "combined with axis_y.mirror — the alignment that keeps "
168
171
  f"labels outside the plot on the {primary} edge draws the "
169
172
  f"mirrored {opposite}-edge labels back across the axis "
170
173
  "into the plot, and Dataface has no baked tick content to "
171
- "measure a safe labelPadding from. Omit label.align; each "
174
+ "measure a safe labelPadding from. Omit labels.align; each "
172
175
  "edge then uses Vega-Lite's safe per-orient default.",
173
176
  chart_id=chart.id,
174
177
  )
@@ -441,7 +441,7 @@ class ValueLabelFeature:
441
441
  y,
442
442
  is_horiz,
443
443
  is_stacked,
444
- chart.style.background,
444
+ chart.background,
445
445
  )
446
446
  if text_layer is None:
447
447
  return
@@ -18,7 +18,7 @@ import html
18
18
  from dataclasses import dataclass
19
19
  from typing import Any
20
20
 
21
- from dataface.core.compile.colors import sanitize_color
21
+ from dataface.core.colors import sanitize_color
22
22
  from dataface.core.compile.config import get_chart_rendering
23
23
  from dataface.core.compile.format import resolve_format
24
24
  from dataface.core.compile.models.chart.authored import (
@@ -28,11 +28,11 @@ from dataface.core.compile.models.chart.authored import (
28
28
  match_predicate,
29
29
  )
30
30
  from dataface.core.compile.models.chart.resolved import (
31
+ FormatState,
31
32
  ResolvedKpiChart,
32
33
  ResolvedStyleChannel,
33
34
  )
34
35
  from dataface.core.compile.models.primitives import (
35
- FormatConfig,
36
36
  SpacingValues,
37
37
  ToneLiteral,
38
38
  )
@@ -62,6 +62,7 @@ from dataface.core.render.chart.title_overflow import (
62
62
  )
63
63
  from dataface.core.render.format_utils import format_kpi_parts
64
64
  from dataface.core.render.utils import resolve_tone_color
65
+ from dataface.core.utils import coerce_numeric_cell
65
66
 
66
67
  # Typographic ratios for KPI geometry. Tuned by eye against the playground
67
68
  # specimen.
@@ -214,87 +215,9 @@ def _resolve_value(raw: str, row: dict[str, Any], chart_id: str) -> tuple[Any, s
214
215
  )
215
216
 
216
217
 
217
- def _coerce_numeric_value(cell: Any) -> float | None:
218
- """Coerce ``cell`` to a float or return None if it should be rendered as a string.
219
-
220
- ``bool`` is treated as a string-class value even though it is technically
221
- an ``int`` subclass — rendering ``True`` through the narrative-notation
222
- pipeline as ``1.0`` would be magic behavior the author did not ask for.
223
- """
224
- if cell is None or isinstance(cell, bool):
225
- return None
226
- if isinstance(cell, (int, float)):
227
- return float(cell)
228
- try:
229
- return float(cell)
230
- except (ValueError, TypeError):
231
- # String literals like "At risk" stay as strings.
232
- return None
233
-
234
-
235
- # d3's first SI prefix ("k") engages at 1000. Below it, ".2s" doesn't compact —
236
- # it rounds to 2 significant figures on plain digits instead (131 -> "130"),
237
- # which drops information the author never asked to lose. Digit-integrity
238
- # rule: rounding is acceptable under SI compaction, never on plain digits —
239
- # so the default only compacts once compaction is real.
240
- _SI_COMPACT_THRESHOLD = 1000.0
241
-
242
-
243
- def _narrative_format_default(
244
- format_input: str | FormatConfig | dict[str, Any] | None,
245
- value: float | None,
246
- ) -> str | FormatConfig | dict[str, Any] | None:
247
- """Apply KPI's narrative defaults when the author did not pick them.
248
-
249
- KPI is a hero number that the reader pauses on, so it defaults to the
250
- narrative notation register (``1.5mn``) instead of the analytic register
251
- (``1.5 M``) used on axis ticks and table cells. When neither spec nor
252
- notation is authored, and ``value`` is large enough to actually compact
253
- (``|value| >= 1000``), we also default to compact SI form (``".2s"``) so
254
- narrative notation shows up — notation alone is a no-op against a non-SI
255
- spec. Below that threshold there is nothing to compact, so the value
256
- renders its exact digits (no spec at all — the render layer's raw
257
- numeric fallback) rather than a misleadingly-rounded plain number.
258
- """
259
- compact_eligible = value is not None and abs(value) >= _SI_COMPACT_THRESHOLD
260
- if format_input is None:
261
- if compact_eligible:
262
- return FormatConfig(spec=".2s", notation="narrative")
263
- return None
264
- if isinstance(format_input, FormatConfig):
265
- spec = format_input.spec
266
- notation = format_input.notation
267
- updates: dict[str, Any] = {}
268
- if notation is None:
269
- updates["notation"] = "narrative"
270
- if (
271
- spec is None
272
- and (notation is None or notation in ("narrative", "analytic"))
273
- and compact_eligible
274
- ):
275
- updates["spec"] = ".2s"
276
- return format_input.model_copy(update=updates) if updates else format_input
277
- if isinstance(format_input, dict):
278
- spec = format_input.get("spec")
279
- notation = format_input.get("notation")
280
- updated = dict(format_input)
281
- if notation is None:
282
- updated["notation"] = "narrative"
283
- if (
284
- spec is None
285
- and (notation is None or notation in ("narrative", "analytic"))
286
- and compact_eligible
287
- ):
288
- updated["spec"] = ".2s"
289
- return updated
290
- # Plain string spec (e.g. ",.0f"): respect the author's spec; just add
291
- # narrative notation so any SI-form spec they used renders narratively.
292
- return FormatConfig(spec=str(format_input), notation="narrative")
293
-
294
-
295
218
  def _format_value_parts(
296
219
  cell: Any,
297
- format_input: str | FormatConfig | dict[str, Any] | None,
220
+ format_input: FormatState,
298
221
  formats: dict[str, Any] | None = None,
299
222
  ) -> tuple[str, str, str, bool]:
300
223
  """Format a KPI cell into ``(prefix, number_str, suffix, is_numeric)``.
@@ -302,7 +225,7 @@ def _format_value_parts(
302
225
  Non-numeric cells are returned as a string in ``number_str`` with empty
303
226
  affixes — used for status-style KPIs like ``"At risk"``.
304
227
  """
305
- numeric = _coerce_numeric_value(cell)
228
+ numeric = coerce_numeric_cell(cell)
306
229
  if numeric is None:
307
230
  text = "" if cell is None else str(cell)
308
231
  return "", text, "", False
@@ -695,7 +618,7 @@ def _resolve_kpi_layout(
695
618
 
696
619
  def _resolve_kpi_colors(
697
620
  row: dict[str, Any],
698
- main_format: str | FormatConfig | dict[str, Any] | None,
621
+ main_format: FormatState,
699
622
  kpi_config: KpiChartStyle,
700
623
  resolved_channels: dict[str, ResolvedStyleChannel],
701
624
  chart_background: str | None,
@@ -866,13 +789,11 @@ def _render_kpi_svg_core(
866
789
  raw_value = chart.value
867
790
  row = data[0]
868
791
  cell, _value_column = _resolve_value(raw_value, row, chart_id)
869
- # Apply narrative-notation default to the top-level KPI block only —
870
- # support-block format defaults to analytic (its register matches dense
871
- # axis/table use rather than the hero number above it).
872
- # KPI format lives at style.value.format and is cascade-resolved into kpi_config.
873
- main_format = _narrative_format_default(
874
- kpi_config.value.format, _coerce_numeric_value(cell)
875
- )
792
+ # chart.format is the final headline format — resolve() already applied
793
+ # the narrative-notation and SI-compaction defaults against this row's
794
+ # value; render just consumes it (support-block format stays analytic,
795
+ # resolved separately in _resolve_support_row).
796
+ main_format = chart.format
876
797
  prefix, number_str, suffix, value_is_numeric = _format_value_parts(
877
798
  cell, main_format, formats
878
799
  )
@@ -10,8 +10,6 @@ from __future__ import annotations
10
10
  import copy
11
11
  from typing import Any
12
12
 
13
- from dataface.core.compile.models.style.resolved import ResolvedChartsStyle
14
-
15
13
  # Internal field name for the calculate-transform output backing the href
16
14
  # click-interactivity encoding. Consumed by translate.py.
17
15
  _HREF_FIELD = "__df_href__"
@@ -32,7 +30,7 @@ def overlay_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, An
32
30
 
33
31
  def apply_presentation_defaults(
34
32
  spec: dict[str, Any],
35
- resolved_chart_style: ResolvedChartsStyle,
33
+ background: str,
36
34
  effective_vega_config: dict[str, Any],
37
35
  ) -> dict[str, Any]:
38
36
  """Apply compiled presentation defaults to an emitted Vega-Lite spec once.
@@ -53,15 +51,15 @@ def apply_presentation_defaults(
53
51
 
54
52
  result_spec["config"] = compiled
55
53
 
56
- # Background cascade: resolved_chart_style.background carries the board-level
57
- # background (cascaded in from ResolvedStyle at resolve time) or a
58
- # chart-local explicit override. Skip for geo charts that keep a transparent
59
- # spec root (projection present, no authored background).
54
+ # Background cascade: background is the chart's own effective background
55
+ # (chart-local style.<family>.background when authored, otherwise the
56
+ # board/face background), baked at resolve time. Skip for geo charts that
57
+ # keep a transparent spec root (projection present, no authored background).
60
58
  preserve_transparent_background = (
61
59
  result_spec.get("background") is None and "projection" in result_spec
62
60
  )
63
61
  if not preserve_transparent_background:
64
- result_spec["background"] = resolved_chart_style.background
62
+ result_spec["background"] = background
65
63
 
66
64
  result_spec["config"] = overlay_merge(result_spec["config"], authored_config)
67
65
  return result_spec
@@ -159,19 +159,10 @@ def render_layout_item(
159
159
  rendered = ""
160
160
  actual_height = height
161
161
  if item.type == "chart" and item.chart:
162
- # Per-family padding: baked into layout_padding at resolve time.
163
- # Falls back to board-level padding for charts resolved outside the
164
- # full pipeline (e.g. unit tests).
165
- from dataface.core.compile.style_cascade import effective_chart_padding
166
-
162
+ # Per-family padding: baked into layout_padding at construction time
163
+ # by every family resolver — the single source of truth.
167
164
  card_padding = float(resolved_style.board.card_padding)
168
- chart_padding = (
169
- item.chart.layout_padding
170
- if item.chart.layout_padding is not None
171
- else effective_chart_padding(
172
- resolved_style.charts, item.chart.chart_type
173
- )
174
- )
165
+ chart_padding = item.chart.layout_padding
175
166
  chart_svg, chart_actual_height = render_chart_item(
176
167
  item.chart,
177
168
  executor,
@@ -23,6 +23,9 @@ from dataface.core.compile.models.chart.resolved import (
23
23
  ResolvedSparkBarChart,
24
24
  ResolvedTableChart,
25
25
  )
26
+ from dataface.core.compile.models.chart.resolved._base import (
27
+ _SharedResolvedChartFields,
28
+ )
26
29
  from dataface.core.compile.models.chart.resolved._layer import LayeredResolvedChart
27
30
  from dataface.core.compile.models.style.resolved import ResolvedStyle
28
31
  from dataface.core.render.chart._types import VLDict
@@ -134,6 +137,13 @@ class FaceRenderSession:
134
137
  spec.title = chart.title
135
138
  spec.subtitle = chart.subtitle
136
139
  spec.title_font = getattr(chart.style, "title_font", None)
140
+ # get_emitter above only returns for the VL families, all of which are
141
+ # _SharedResolvedChartFields subclasses (KPI/callout/table/spark_bar
142
+ # are dispatched to render_svg_family before this point) — background
143
+ # and title_style are guaranteed present, so read them directly.
144
+ assert isinstance(chart, _SharedResolvedChartFields)
145
+ spec.background = chart.background
146
+ spec.title_style = chart.title_style
137
147
  return spec
138
148
 
139
149
  def finalize_vl(self, spec: ChartSpec) -> VLDict:
@@ -20,6 +20,7 @@ from typing import TYPE_CHECKING, Any, Literal
20
20
 
21
21
  if TYPE_CHECKING:
22
22
  from dataface.core.compile.models.primitives import ResolvedFontStyle
23
+ from dataface.core.compile.models.style.theme import TitleStyle
23
24
 
24
25
 
25
26
  @dataclass(frozen=True)
@@ -145,6 +146,8 @@ class ChartSpec:
145
146
  title: str | None = None
146
147
  subtitle: str | None = None
147
148
  title_font: ResolvedFontStyle | None = None
149
+ background: str | None = None
150
+ title_style: TitleStyle | None = None
148
151
  geo_data: dict[str, Any] | None = None
149
152
  transforms: list[dict[str, Any]] = field(default_factory=list)
150
153
  # Row data for non-geo VL families; set centrally by FaceRenderSession.emit_chart.
@@ -7,22 +7,21 @@ from typing import TYPE_CHECKING, Any
7
7
  from dataface.core.text.case import apply_case
8
8
 
9
9
  if TYPE_CHECKING:
10
- from dataface.core.compile.models.style.resolved import (
11
- ResolvedChartsStyle,
12
- )
13
10
  from dataface.core.compile.models.style.theme import PaddingStyle
11
+ from dataface.core.text.case import CaseValue
14
12
 
15
13
 
16
14
  def additive_padding(card_pad: float, chart_padding: PaddingStyle) -> dict[str, float]:
17
15
  """Add card_padding to per-family chart padding on each side independently.
18
16
 
19
- ``chart_padding`` is the per-family resolved padding (from
20
- ``effective_chart_padding``) — it already carries face → family fill via
21
- InheritSlot and any chart-local ``style.<family>.padding`` override merged
22
- in by ``build_resolved_style``. Each side stacks ON TOP of card_padding
23
- so author-specified insets compose with the global card layout rather
24
- than replacing it. With no chart-local override and face default
25
- {0,0,0,0}, this collapses to a uniform card_pad on all four sides.
17
+ ``chart_padding`` is the per-family resolved padding — typically
18
+ ``resolved_chart.layout_padding``, baked in at construction time. It
19
+ already carries face → family fill via InheritSlot and any chart-local
20
+ ``style.<family>.padding`` override merged in by ``build_resolved_style``.
21
+ Each side stacks ON TOP of card_padding so author-specified insets compose
22
+ with the global card layout rather than replacing it. With no chart-local
23
+ override and face default {0,0,0,0}, this collapses to a uniform
24
+ card_pad on all four sides.
26
25
  """
27
26
  return {
28
27
  "left": card_pad + chart_padding.left,
@@ -58,18 +57,19 @@ def set_chart_title(
58
57
  title: str | None,
59
58
  subtitle: str | None = None,
60
59
  *,
61
- style: ResolvedChartsStyle | None = None,
60
+ case: CaseValue | None = None,
62
61
  ) -> None:
63
62
  """Apply a standard title block when a title is present.
64
63
 
65
- When *style* is provided, ``style.title.font.case`` is applied to the
66
- title text. Subtitle is not yet case-transformed here — chart subtitles
67
- have always been emitted raw.
64
+ ``case`` is a plain title-case value (``chart.title_style.font.case``),
65
+ not a style bag — title-case is VL-presentation-only: ``chart.title``
66
+ itself stays raw because it also backs non-VL consumers (e.g. the
67
+ ``data-chart-title`` wire attribute) that emit the authored text as-is.
68
+ Subtitle is not case-transformed — chart subtitles have always been
69
+ emitted raw.
68
70
  """
69
- if title and style is not None:
70
- case = style.title.font.case
71
- if case is not None and case != "none":
72
- title = apply_case(title, case)
71
+ if title and case is not None and case != "none":
72
+ title = apply_case(title, case)
73
73
  if title or subtitle:
74
74
  title_block: dict[str, Any] = {"text": title or ""}
75
75
  if subtitle: