dataface 0.1.6.dev360__py3-none-any.whl → 0.1.6.dev476__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 (72) hide show
  1. dataface/DATAFACE_SYNTAX.md +26 -0
  2. dataface/agent_api/dashboards.py +1 -1
  3. dataface/agent_api/describe.py +10 -8
  4. dataface/agent_api/docs/yaml-reference.md +33 -0
  5. dataface/agent_api/project_session.py +6 -6
  6. dataface/agent_api/query.py +2 -2
  7. dataface/agent_api/render_face.py +12 -4
  8. dataface/agent_api/validate.py +10 -8
  9. dataface/ai/agent.py +1 -1
  10. dataface/ai/skills/dashboard-build/SKILL.md +2 -0
  11. dataface/core/compile/__init__.py +1 -1
  12. dataface/core/compile/channel.py +10 -3
  13. dataface/core/compile/compiler.py +22 -25
  14. dataface/core/compile/data_table_attachment.py +22 -15
  15. dataface/core/compile/detect.py +21 -37
  16. dataface/core/compile/models/chart/authored.py +11 -0
  17. dataface/core/compile/models/chart/normalized.py +7 -0
  18. dataface/core/compile/models/style/resolved.py +2 -0
  19. dataface/core/compile/models/style/theme.py +127 -2
  20. dataface/core/compile/style_cascade.py +4 -0
  21. dataface/core/dashboard.py +8 -5
  22. dataface/core/defaults/themes/_base.yaml +5 -0
  23. dataface/core/defaults/themes/stark.yaml +12 -0
  24. dataface/core/execute/_duckdb_cache_base.py +272 -0
  25. dataface/core/execute/adapters/schema_adapter.py +83 -0
  26. dataface/core/execute/batch.py +1 -2
  27. dataface/core/execute/cache_backend.py +1 -35
  28. dataface/core/execute/duckdb_cache.py +25 -333
  29. dataface/core/execute/executor.py +0 -158
  30. dataface/core/execute/parallel.py +7 -114
  31. dataface/core/execute/trivial_local_cache.py +183 -0
  32. dataface/core/inspect/templates/categorical_column.yml +1 -1
  33. dataface/core/inspect/templates/date_column.yml +1 -1
  34. dataface/core/inspect/templates/model.yml +2 -2
  35. dataface/core/inspect/templates/numeric_column.yml +1 -1
  36. dataface/core/inspect/templates/quality.yml +1 -1
  37. dataface/core/inspect/templates/string_column.yml +1 -1
  38. dataface/core/project.py +61 -2
  39. dataface/core/registered_views/link_keys.py +17 -14
  40. dataface/core/registered_views/render_pipeline.py +16 -10
  41. dataface/core/registered_views/variable_planner.py +19 -19
  42. dataface/core/render/chart/auto_link.py +349 -72
  43. dataface/core/render/chart/decisions.py +18 -10
  44. dataface/core/render/chart/render_single.py +292 -23
  45. dataface/core/render/chart/standard_renderer.py +220 -9
  46. dataface/core/render/chart/table.py +123 -17
  47. dataface/core/render/chart/table_support.py +49 -4
  48. dataface/core/render/chart/vega_lite_types.py +17 -0
  49. dataface/core/render/chart/vl_field_maps.py +2 -0
  50. dataface/core/render/chrome_css.py +56 -0
  51. dataface/core/render/face_api.py +2 -2
  52. dataface/core/render/renderer.py +37 -33
  53. dataface/core/render/templates/controls/_styles.css +60 -62
  54. dataface/core/render/templates/nav/nav-fragment.html +1 -0
  55. dataface/core/render/templates/nav/nav.css +18 -19
  56. dataface/core/render/templates/scripts/variables.js +4 -4
  57. dataface/core/render/templates/variable_controls/container.html +2 -8
  58. dataface/core/render/text/case.py +79 -8
  59. dataface/core/render/variable_controls.py +2 -9
  60. dataface/core/render/warnings/base.py +17 -0
  61. dataface/core/render/warnings/pie_dominant_segment.py +68 -0
  62. dataface/core/render/warnings/pie_too_many_segments.py +47 -0
  63. dataface/core/render/warnings/registry.py +8 -0
  64. dataface/core/render/warnings/too_many_color_categories.py +68 -0
  65. dataface/core/render/warnings/too_many_x_categories.py +63 -0
  66. dataface/core/serve/alias_index.py +55 -6
  67. dataface/core/serve/server.py +33 -25
  68. {dataface-0.1.6.dev360.dist-info → dataface-0.1.6.dev476.dist-info}/METADATA +1 -1
  69. {dataface-0.1.6.dev360.dist-info → dataface-0.1.6.dev476.dist-info}/RECORD +72 -65
  70. {dataface-0.1.6.dev360.dist-info → dataface-0.1.6.dev476.dist-info}/WHEEL +0 -0
  71. {dataface-0.1.6.dev360.dist-info → dataface-0.1.6.dev476.dist-info}/entry_points.txt +0 -0
  72. {dataface-0.1.6.dev360.dist-info → dataface-0.1.6.dev476.dist-info}/licenses/LICENSE +0 -0
@@ -253,6 +253,32 @@ Rules:
253
253
  - Each alias must be unique across the project — two faces claiming the same alias is a startup error.
254
254
  - Aliasing a generated system route (data or inspector view) is allowed: the redirect takes precedence, letting you override what that URL serves.
255
255
 
256
+ #### Parameterized aliases (capture a path segment into a variable)
257
+
258
+ An alias may contain `<name>` capture segments. A request matching the pattern
259
+ 302-redirects to the face's canonical URL with each captured segment appended as a
260
+ query param of the same name — which the face then reads as its variable. This
261
+ gives a single detail face a clean per-entity URL:
262
+
263
+ ```yaml
264
+ # faces/milestone.yml (canonical URL: /milestone)
265
+ aliases:
266
+ - /milestones/<name>
267
+ variables:
268
+ name: { input: select, options: { query: milestone_options, column: slug } }
269
+ ```
270
+
271
+ Now `/milestones/m3-public-launch` redirects to `/milestone/?name=m3-public-launch`,
272
+ and `milestone.yml` renders with `name` bound to `m3-public-launch`.
273
+
274
+ - Capture names use the same grammar as built-in routes: `<name>` matches exactly
275
+ one non-empty path segment (no `/`). Use a capture name that matches the
276
+ variable you want it to fill.
277
+ - A real face file always wins over a pattern alias, so `/milestones/` (the list
278
+ face) and `/milestones/<name>` (the detail redirect) coexist without conflict.
279
+ - Plain aliases (no `<...>`) redirect as before; only aliases with a capture are
280
+ treated as patterns.
281
+
256
282
  There are two ways to override a data/inspector route for a given path:
257
283
  - **Face file at that path** — create `faces/data/warehouse/schema/table.yml` and the server renders it directly (no redirect).
258
284
  - **`aliases:` entry** — any face can declare `/data/…/` as an alias; the server issues a 302 to the declaring face's canonical URL.
@@ -244,7 +244,7 @@ def get_dashboard(
244
244
  ],
245
245
  )
246
246
 
247
- result = compile_file(file_path, project=project)
247
+ result = compile_file(project.file_for_path(file_path), project=project)
248
248
  return CompiledDashboard(
249
249
  success=result.success,
250
250
  dashboard=result.face if result.success else None,
@@ -224,7 +224,7 @@ def describe_face(path: Path, *, project: Project) -> DescribeFaceResult:
224
224
  )
225
225
 
226
226
  try:
227
- result = compile_file(resolved, project=project)
227
+ result = compile_file(project.file_for_path(resolved), project=project)
228
228
  except OSError as exc:
229
229
  return DescribeFaceResult(
230
230
  success=False,
@@ -350,13 +350,15 @@ def _describe_one_path(
350
350
  if not resolved.is_dir():
351
351
  return [describe_face(resolved, project=project)]
352
352
 
353
- template_dirs = {m.parent for m in resolved.glob(f"**/{INSPECT_TEMPLATE_MANIFEST}")}
354
- yaml_files = sorted(
355
- f
356
- for f in (list(resolved.glob("**/*.yml")) + list(resolved.glob("**/*.yaml")))
357
- if not f.name.startswith("_") and f.parent not in template_dirs
353
+ under = str(resolved.relative_to(project.root))
354
+ faces = sorted(
355
+ project.root / pf.relpath
356
+ for pf in project.iter_faces(under=under, recursive=True)
357
+ if pf.is_yaml
358
+ and not pf.is_private
359
+ and not pf.sibling(INSPECT_TEMPLATE_MANIFEST).exists()
358
360
  )
359
- if not yaml_files:
361
+ if not faces:
360
362
  return [
361
363
  DescribeFaceResult(
362
364
  success=False,
@@ -369,4 +371,4 @@ def _describe_one_path(
369
371
  ],
370
372
  )
371
373
  ]
372
- return [describe_face(f, project=project) for f in yaml_files]
374
+ return [describe_face(f, project=project) for f in faces]
@@ -287,6 +287,7 @@ Authored patch for KPI (key performance indicator) charts.
287
287
  | `label` | str | ✓ | KPI label rendered above the headline value. |
288
288
  | `support` | [KpiSupportConfig](#kpisupportconfig) | ✓ | Optional support line beneath the KPI value. |
289
289
  | `style` | [KpiChartStyle](#kpichartstyle) | ✓ | Chart-local style overrides. |
290
+ | `background` | str \| dict[str, Any] | ✓ | Gradient background channel — {column, scale} shape. Paints the card background by the value's position in the scale. See channel.py parse_style_channel for the full grammar. |
290
291
 
291
292
  <a id="tablechart"></a>
292
293
  ## TableChart
@@ -848,6 +849,7 @@ Authored overlay for TableChartStyle. Table chart style overrides layered on top
848
849
  | `more_rows` | [TableEdgeStyle](#tableedgestyle) | ✓ | Style for the 'more rows' edge-case indicator. |
849
850
  | `empty_state` | [TableEdgeStyle](#tableedgestyle) | ✓ | Style for the empty-state (no data) indicator. |
850
851
  | `spark` | [SparkStyle](#sparkstyle) | ✓ | Inline sparkline defaults for table cells. |
852
+ | `transpose` | bool | ✓ | When True, render a single wide data row as N (label, value) rows — one per column. Raises ChartDataError when data has more than one row. Used for Looker single-value summary tiles with multiple measures. |
851
853
  | `text_baseline_offset` | float | ✓ | Vertical offset to align SVG text baseline with cell grid in pixels. |
852
854
  | `title_baseline_offset` | float | ✓ | Vertical offset to align SVG title baseline in pixels. |
853
855
  | `title_subtitle_gap` | float | ✓ | Gap between table title and subtitle lines in pixels. |
@@ -1348,6 +1350,7 @@ Authored overlay for LegendStyle.
1348
1350
  | `title` | [LegendElementStyle](#legendelementstyle) | ✓ | Legend title style. |
1349
1351
  | `visible` | bool | ✓ | Show the legend. None = legend visible; False = explicitly suppressed. |
1350
1352
  | `interactive_legend` | bool | ✓ | Emit a dft_legend point-selection param for single-click series toggle. |
1353
+ | `symbol_limit` | int | ✓ | Maximum number of legend entries to display; maps to VL symbolLimit. None uses Vega-Lite's default (no cap). Set to a positive integer to prevent legend overflow on high-cardinality series. |
1351
1354
 
1352
1355
  <a id="tooltipstyle"></a>
1353
1356
  ## TooltipStyle
@@ -1569,6 +1572,7 @@ Authored overlay for TableColumnsStyle.
1569
1572
  | `default_width` | float | ✓ | Default column width in pixels. |
1570
1573
  | `cell_padding` | float | ✓ | Horizontal padding inside table cells in pixels. |
1571
1574
  | `width_similarity_threshold` | float | ✓ | Auto-width columns whose min/max ratio &gt;= this threshold are snapped to a shared width before budget allocation. 1.0 disables clustering; 0.0 forces all auto-columns to equal width. |
1575
+ | `content_headroom` | float | ✓ | Fractional breathing room added above the raw p95 column demand when pinning compact columns in mixed (compact + text) tables. 0.10 means each compact column is pinned at 10 % above its measured demand; the extra width is funded by the text-column budget. Has no effect on all-compact tables (proportional scaling already fills the budget). 0.0 disables headroom. |
1572
1576
 
1573
1577
  <a id="tableheaderstyle"></a>
1574
1578
  ## TableHeaderStyle
@@ -2209,6 +2213,7 @@ Authored overlay for BarMarkStyle. Bar mark geometry and stroke. Chart-level bar
2209
2213
  | `padding` | float | ✓ | Padding around bar marks in pixels. |
2210
2214
  | `size` | float | ✓ | Bar width in pixels (fixed-width mode). |
2211
2215
  | `band_width` | float | ✓ | Bar width as a fraction of the band step (0–1). |
2216
+ | `labels` | [BarLabelsStyle](#barlabelsstyle) | ✓ | Value label style for bar marks. |
2212
2217
 
2213
2218
  <a id="textmarkstyle"></a>
2214
2219
  ## TextMarkStyle
@@ -2228,6 +2233,7 @@ Authored overlay for LineMarkStyle. Line mark stroke, interpolation, and halo. P
2228
2233
  | `stroke` | [StrokeStyle](#strokestyle) | ✓ | Line stroke style. |
2229
2234
  | `curve` | str | ✓ | Line interpolation curve (e.g. 'linear', 'monotone'). |
2230
2235
  | `halo_multiplier` | float | ✓ | Halo stroke width multiplier relative to stroke.width; 0 disables the halo. |
2236
+ | `labels` | [PointLabelsStyle](#pointlabelsstyle) | ✓ | Value label style for line marks. |
2231
2237
 
2232
2238
  <a id="pointmarkstyle"></a>
2233
2239
  ## PointMarkStyle
@@ -2242,6 +2248,7 @@ Authored overlay for PointMarkStyle. Point mark style (data-point markers on lin
2242
2248
  | `filled` | bool | ✓ | Whether points are filled; None uses VL default. |
2243
2249
  | `fill` | str | ✓ | Point interior fill color; only applied when filled=false. |
2244
2250
  | `stroke_width` | float | ✓ | Stroke width in pixels for hollow point rings; None uses VL default. |
2251
+ | `labels` | [PointLabelsStyle](#pointlabelsstyle) | ✓ | Value label style for point marks. |
2245
2252
 
2246
2253
  <a id="rulemarkstyle"></a>
2247
2254
  ## RuleMarkStyle
@@ -2522,6 +2529,19 @@ Authored overlay for DataTableRowPaddingStyle.
2522
2529
  | `vertical` | float | ✓ | Vertical (top/bottom) padding inside data_table rows in pixels. |
2523
2530
  | `horizontal` | float | ✓ | Horizontal (left/right) padding inside data_table rows in pixels. |
2524
2531
 
2532
+ <a id="barlabelsstyle"></a>
2533
+ ## BarLabelsStyle
2534
+ Authored overlay for BarLabelsStyle. Bar mark value-label config. Extends MarkLabelsStyle with bar-specific positions.
2535
+
2536
+ | Field | Type | Optional | Description |
2537
+ |-------|------|:--------:|-------------|
2538
+ | `visible` | bool | ✓ | Show numeric value labels on each mark; False by default. |
2539
+ | `format` | str | ✓ | Number format string for value labels. None inherits from style.charts.axis_quantitative.format at render time (the same format string used for measure-axis tick labels, e.g. '~s'). |
2540
+ | `dx` | int | ✓ | Horizontal pixel offset for value labels; overrides the position default. |
2541
+ | `dy` | int | ✓ | Vertical pixel offset for value labels; overrides the position default. |
2542
+ | `font` | [FontStyle](#fontstyle) | ✓ | Value label font style (color, size, family, etc.). |
2543
+ | `position` | enum: "top", "inside_top", "middle" | ✓ | Label position relative to the bar. 'top' places labels above the bar top (outside). 'inside_top' places labels just inside the top edge. 'middle' centers labels vertically in the bar. |
2544
+
2525
2545
  <a id="strokestyle"></a>
2526
2546
  ## StrokeStyle
2527
2547
  Authored overlay for StrokeStyle. Stroke appearance sub-block shared across mark families.
@@ -2534,6 +2554,19 @@ Authored overlay for StrokeStyle. Stroke appearance sub-block shared across mark
2534
2554
  | `join` | enum: "miter", "round", "bevel" | ✓ | Stroke line join style (miter, round, or bevel). |
2535
2555
  | `dasharray` | str | ✓ | SVG stroke-dasharray pattern (e.g. '4 2'). |
2536
2556
 
2557
+ <a id="pointlabelsstyle"></a>
2558
+ ## PointLabelsStyle
2559
+ Authored overlay for PointLabelsStyle. Point/line mark value-label config. Used by both LineMarkStyle and PointMarkStyle.
2560
+
2561
+ | Field | Type | Optional | Description |
2562
+ |-------|------|:--------:|-------------|
2563
+ | `visible` | bool | ✓ | Show numeric value labels on each mark; False by default. |
2564
+ | `format` | str | ✓ | Number format string for value labels. None inherits from style.charts.axis_quantitative.format at render time (the same format string used for measure-axis tick labels, e.g. '~s'). |
2565
+ | `dx` | int | ✓ | Horizontal pixel offset for value labels; overrides the position default. |
2566
+ | `dy` | int | ✓ | Vertical pixel offset for value labels; overrides the position default. |
2567
+ | `font` | [FontStyle](#fontstyle) | ✓ | Value label font style (color, size, family, etc.). |
2568
+ | `position` | enum: "top", "bottom", "left", "right", "middle" | ✓ | Label position relative to the point. 'top' places labels above the point. 'bottom' places labels below. 'left' to the left. 'right' to the right. 'middle' centers on the point. |
2569
+
2537
2570
  <a id="fontcolorstrokestyle"></a>
2538
2571
  ## FontColorStrokeStyle
2539
2572
  Authored overlay for FontColorStrokeStyle. Stroke for tick/rule marks whose color defaults to the root font color.
@@ -35,7 +35,7 @@ from dataface.core.execute.adapters.adapter_registry import (
35
35
  AdapterRegistry,
36
36
  build_adapter_registry,
37
37
  )
38
- from dataface.core.execute.duckdb_cache import DuckDBCache
38
+ from dataface.core.execute.cache_backend import QueryResultCache
39
39
  from dataface.core.execute.observability import WarehouseObserver
40
40
  from dataface.core.inspect.query_validator import (
41
41
  QueryDiagnostic,
@@ -68,7 +68,7 @@ class ProjectSession:
68
68
  """
69
69
 
70
70
  project: Project
71
- cache: DuckDBCache | None
71
+ cache: QueryResultCache | None
72
72
  _owns_registry: bool
73
73
  _read_only: bool
74
74
  _connection_string: str | None
@@ -82,7 +82,7 @@ class ProjectSession:
82
82
  def __init__(
83
83
  self,
84
84
  project: Project,
85
- cache: DuckDBCache | None = None,
85
+ cache: QueryResultCache | None = None,
86
86
  adapter_registry: AdapterRegistry | None = None,
87
87
  read_only: bool = True,
88
88
  observers: list[WarehouseObserver] | None = None,
@@ -110,7 +110,7 @@ class ProjectSession:
110
110
  cls,
111
111
  project_dir: Path | str,
112
112
  *,
113
- cache: DuckDBCache | None = None,
113
+ cache: QueryResultCache | None = None,
114
114
  read_only: bool = True,
115
115
  connection_string: str | None = None,
116
116
  dialect: str = "duckdb",
@@ -150,7 +150,7 @@ class ProjectSession:
150
150
  cls,
151
151
  project: Project,
152
152
  *,
153
- cache: DuckDBCache | None = None,
153
+ cache: QueryResultCache | None = None,
154
154
  read_only: bool = True,
155
155
  connection_string: str | None = None,
156
156
  dialect: str = "duckdb",
@@ -183,7 +183,7 @@ class ProjectSession:
183
183
  face_file: Path | str,
184
184
  *,
185
185
  read_only: bool = True,
186
- cache: DuckDBCache | None = None,
186
+ cache: QueryResultCache | None = None,
187
187
  ) -> Self:
188
188
  """Open a ProjectSession rooted at the project directory discovered upward from face_file."""
189
189
  resolved = Path(face_file).resolve()
@@ -47,7 +47,7 @@ def lookup_face_query_sql(
47
47
  success=False, errors=[f"File not found: {file_path}"]
48
48
  )
49
49
 
50
- compile_result = compile_file(file_path, project=project)
50
+ compile_result = compile_file(project.file_for_path(file_path), project=project)
51
51
  if not compile_result.success:
52
52
  return FaceQueryLookupResult(
53
53
  success=False, errors=[e.message for e in compile_result.errors]
@@ -275,7 +275,7 @@ def query_face(
275
275
  hint = no_project_hint(project.root)
276
276
  return _fail(name, file_path, [f"File not found: {file_path}{hint}"])
277
277
 
278
- compile_result = compile_file(file_path, project=project)
278
+ compile_result = compile_file(project.file_for_path(file_path), project=project)
279
279
  if not compile_result.success:
280
280
  return _fail(name, file_path, [e.message for e in compile_result.errors])
281
281
 
@@ -13,8 +13,8 @@ from dataface.core.compile.markdown import (
13
13
  is_markdown_face,
14
14
  markdown_to_yaml,
15
15
  )
16
- from dataface.core.execute.duckdb_cache import DuckDBCache
17
- from dataface.core.project import Project
16
+ from dataface.core.execute.cache_backend import QueryResultCache
17
+ from dataface.core.project import Project, ProjectFile
18
18
  from dataface.core.render.face_api import compile_and_render
19
19
 
20
20
 
@@ -26,7 +26,7 @@ def render_face(
26
26
  project: Project | None = None,
27
27
  variables: dict[str, str] | None = None,
28
28
  use_cache: bool = True,
29
- cache: DuckDBCache | None = None,
29
+ cache: QueryResultCache | None = None,
30
30
  **options: Any,
31
31
  ) -> str | bytes:
32
32
  """Render a Dataface face file (.yml or .md) to embeddable output.
@@ -70,7 +70,15 @@ def render_face(
70
70
  project_session = ProjectSession.from_face(face_file, cache=cache)
71
71
  try:
72
72
  p = project_session.project
73
- base_file = p.file_for_path(face_file)
73
+ # The face may live outside an explicitly-supplied project root — e.g.
74
+ # the looker_migrate eval renders faces under evals/runs/ against a
75
+ # project rooted at runtime/ for its sources/schema. Such a face has no
76
+ # project-relative handle; nested sub-file refs are unsupported in that
77
+ # mode (relative reads still resolve via face_dir below).
78
+ try:
79
+ base_file: ProjectFile | None = p.file_for_path(face_file)
80
+ except ValueError:
81
+ base_file = None
74
82
  return compile_and_render(
75
83
  yaml_content,
76
84
  p,
@@ -110,13 +110,15 @@ def _validate_one_path(
110
110
  if not resolved.is_dir():
111
111
  return [validate(resolved, project=project)]
112
112
 
113
- template_dirs = {m.parent for m in resolved.glob(f"**/{INSPECT_TEMPLATE_MANIFEST}")}
114
- yaml_files = sorted(
115
- f
116
- for f in (list(resolved.glob("**/*.yml")) + list(resolved.glob("**/*.yaml")))
117
- if not f.name.startswith("_") and f.parent not in template_dirs
113
+ under = str(resolved.relative_to(project.root))
114
+ faces = sorted(
115
+ project.root / pf.relpath
116
+ for pf in project.iter_faces(under=under, recursive=True)
117
+ if pf.is_yaml
118
+ and not pf.is_private
119
+ and not pf.sibling(INSPECT_TEMPLATE_MANIFEST).exists()
118
120
  )
119
- if not yaml_files:
121
+ if not faces:
120
122
  return [
121
123
  ValidateResult(
122
124
  success=False,
@@ -129,7 +131,7 @@ def _validate_one_path(
129
131
  ],
130
132
  )
131
133
  ]
132
- return [validate(f, project=project) for f in yaml_files]
134
+ return [validate(f, project=project) for f in faces]
133
135
 
134
136
 
135
137
  def validate(path: Path, *, project: Project) -> ValidateResult:
@@ -163,7 +165,7 @@ def validate(path: Path, *, project: Project) -> ValidateResult:
163
165
  )
164
166
 
165
167
  try:
166
- result = compile_file(resolved, project=project)
168
+ result = compile_file(project.file_for_path(resolved), project=project)
167
169
  except OSError as exc:
168
170
  return ValidateResult(
169
171
  success=False,
dataface/ai/agent.py CHANGED
@@ -120,7 +120,7 @@ DASHBOARD_PROFILE = AgentProfile(
120
120
 
121
121
 
122
122
  def run_agent(
123
- prompt: str,
123
+ prompt: str | list[dict[str, Any]],
124
124
  *,
125
125
  client: LLMClient,
126
126
  context: DatafaceAIContext,
@@ -129,6 +129,8 @@ Skip this step when you are previewing ephemerally — see "Previewing vs. savin
129
129
 
130
130
  Fix any errors `{{ s_validate_dashboard }}` reports, then re-run until it passes.
131
131
 
132
+ **YAML style:** write block style — one key per line. Avoid JSON-like inline flow maps (`{ type: bar, x: month }`); they read and diff worse than indented blocks. Inline arrays (`y: [revenue, cost]`) are fine.
133
+
132
134
  ## Parameterized Queries — Use From the Start
133
135
 
134
136
  Use `{{ variable_name }}` syntax for any configurable values, even during exploration:
@@ -6,7 +6,7 @@ ready for execution and rendering.
6
6
 
7
7
  Entry Points:
8
8
  - compile(yaml_content: str) -> CompileResult
9
- - compile_file(file_path: Path, *, project: Project) -> CompileResult
9
+ - compile_file(face_file: ProjectFile, *, project: Project) -> CompileResult
10
10
 
11
11
  Outputs:
12
12
  - Face: Fully normalized face with all references resolved
@@ -130,7 +130,7 @@ def validate_channel_fields(
130
130
  )
131
131
 
132
132
 
133
- _COLOR_CHANNELS = frozenset({"color"})
133
+ _COLOR_CHANNELS = frozenset({"color", "background"})
134
134
  _NUMERIC_CHANNELS: frozenset[str] = frozenset()
135
135
 
136
136
 
@@ -165,8 +165,10 @@ def normalize_chart_channels(
165
165
  ) -> dict[str, ResolvedStyleChannel]:
166
166
  """Parse and validate chart-level style channels.
167
167
 
168
- Only ``color`` is a data channel at chart root. background/opacity/stroke
169
- were removed as data channels (they are paint fields, not data bindings).
168
+ ``color`` is a data channel at chart root for all chart types.
169
+ ``background`` is a data channel at chart root for KPI charts only
170
+ (gradient background painted by value position in scale).
171
+ Other paint fields (opacity, stroke) are not data channels.
170
172
 
171
173
  ``style_color``: when a ``StyleColorConfig`` with a scale is provided, the
172
174
  series color channel is upgraded to gradient mode. A plain string value
@@ -182,6 +184,11 @@ def normalize_chart_channels(
182
184
  if color_raw is not None:
183
185
  channels["color"] = parse_style_channel(color_raw, "color")
184
186
 
187
+ if chart_type == "kpi":
188
+ background_raw = chart.background
189
+ if background_raw is not None:
190
+ channels["background"] = parse_style_channel(background_raw, "background")
191
+
185
192
  # Upgrade series channel to gradient when style.color carries a scale.
186
193
  if isinstance(style_color, StyleColorConfig):
187
194
  if "color" not in channels:
@@ -7,7 +7,7 @@ ready for execution and rendering.
7
7
  Entry Points:
8
8
  - compile(yaml_content: str) -> CompileResult
9
9
  - compile_authored_face(authored: AuthoredFace) -> CompileResult
10
- - compile_file(file_path: Path, *, project: Project) -> CompileResult
10
+ - compile_file(face_file: ProjectFile, *, project: Project) -> CompileResult
11
11
 
12
12
  This is the main orchestrator for compilation. It:
13
13
  1. Parses YAML to AuthoredFace (parser.py)
@@ -32,7 +32,6 @@ from __future__ import annotations
32
32
 
33
33
  import logging
34
34
  from dataclasses import dataclass, field
35
- from pathlib import Path
36
35
  from typing import TYPE_CHECKING, Any
37
36
 
38
37
  import yaml
@@ -462,7 +461,7 @@ def validate_compiled_queries(
462
461
 
463
462
 
464
463
  def compile_file(
465
- file_path: str | Path,
464
+ face_file: ProjectFile,
466
465
  apply_meta: bool = True,
467
466
  *,
468
467
  project: Project,
@@ -474,34 +473,30 @@ def compile_file(
474
473
  applies meta.yaml cascading configuration from parent directories.
475
474
 
476
475
  Args:
477
- file_path: Path to YAML file
476
+ face_file: ProjectFile handle for the YAML (or Markdown) face file
478
477
  apply_meta: If True, resolve and apply meta.yaml chain (default: True)
479
478
  project: Project for meta resolution and source loading
480
- markdown_metadata_table: When True and file_path is a .md, prepend
479
+ markdown_metadata_table: When True and face_file is a .md, prepend
481
480
  non-face frontmatter keys as a metadata table before the body.
482
481
 
483
482
  Returns:
484
483
  CompileResult with compiled face or errors
485
484
 
486
- Raises:
487
- FileNotFoundError: If file doesn't exist
488
-
489
485
  Example:
490
- >>> result = compile_file(Path("face.yml"), project=project)
486
+ >>> result = compile_file(project.file("face.yml"), project=project)
491
487
  >>> if result.success:
492
488
  ... print(result.face.title)
493
489
  """
494
- file_path = Path(file_path)
495
-
496
- if not file_path.exists():
490
+ if not face_file.exists():
497
491
  return CompileResult(
498
- errors=[CompilationError(f"File not found: {file_path}").to_structured()]
492
+ errors=[
493
+ CompilationError(f"File not found: {face_file.relpath}").to_structured()
494
+ ]
499
495
  )
500
496
 
501
- face_file = project.file_for_path(file_path)
502
-
503
497
  # Markdown report files: translate to YAML before compiling.
504
- # is_markdown_face stays path-based (upward directory walk, out of scope).
498
+ # is_markdown_face and resolve_meta_chain stay path-based (disk-coupled helpers,
499
+ # out of scope for this task); reconstruct the absolute path for them.
505
500
  from dataface.core.compile.markdown import (
506
501
  MARKDOWN_NOT_FACE_MESSAGE,
507
502
  MARKDOWN_SUFFIXES,
@@ -509,14 +504,16 @@ def compile_file(
509
504
  markdown_to_yaml,
510
505
  )
511
506
 
512
- if file_path.suffix.lower() in MARKDOWN_SUFFIXES:
513
- if not is_markdown_face(file_path):
507
+ abs_path = project.root / face_file.relpath
508
+
509
+ if abs_path.suffix.lower() in MARKDOWN_SUFFIXES:
510
+ if not is_markdown_face(abs_path):
514
511
  return CompileResult(
515
512
  errors=[CompilationError(MARKDOWN_NOT_FACE_MESSAGE).to_structured()]
516
513
  )
517
514
 
518
515
  try:
519
- raw_text = file_path.read_text(encoding="utf-8")
516
+ raw_text = face_file.read_text()
520
517
  yaml_content = markdown_to_yaml(
521
518
  raw_text, metadata_table=markdown_metadata_table
522
519
  )
@@ -532,8 +529,6 @@ def compile_file(
532
529
  errors=[CompilationError(f"Failed to read file: {e}").to_structured()]
533
530
  )
534
531
 
535
- file_str = str(file_path)
536
-
537
532
  # Parse the face text to a mapping. Meta is deep-merged under it (when
538
533
  # enabled) and the merged mapping is validated once as an AuthoredFace —
539
534
  # no YAML round-trip. A malformed meta.yaml raises CompilationError immediately.
@@ -541,7 +536,7 @@ def compile_file(
541
536
  try:
542
537
  face_data = load_yaml_mapping(yaml_content)
543
538
  if apply_meta:
544
- meta_dict, lint = resolve_meta_chain(file_path, root_path=project.root)
539
+ meta_dict, lint = resolve_meta_chain(abs_path, root_path=project.root)
545
540
  if lint.ignore or lint.ignore_queries:
546
541
  meta_lint = lint
547
542
  face_data = apply_meta_to_face(meta_dict, face_data)
@@ -549,12 +544,14 @@ def compile_file(
549
544
  except ParseError as e:
550
545
  return CompileResult(
551
546
  errors=[
552
- _stamp_error_file(err, file_str)
547
+ _stamp_error_file(err, face_file.relpath)
553
548
  for err in _parse_error_to_structured(e, yaml_content)
554
549
  ]
555
550
  )
556
551
  except CompilationError as e:
557
- return CompileResult(errors=[_stamp_error_file(e.to_structured(), file_str)])
552
+ return CompileResult(
553
+ errors=[_stamp_error_file(e.to_structured(), face_file.relpath)]
554
+ )
558
555
 
559
556
  result = _compile_with_text(
560
557
  face,
@@ -566,7 +563,7 @@ def compile_file(
566
563
  # Stamp file path and add validate next_command on each compile error so
567
564
  # UI surfaces and CLI can surface "dft validate <file>" without re-deriving the path.
568
565
  if result.errors:
569
- result.errors = [_stamp_error_file(e, file_str) for e in result.errors]
566
+ result.errors = [_stamp_error_file(e, face_file.relpath) for e in result.errors]
570
567
  return result
571
568
 
572
569
 
@@ -40,6 +40,7 @@ from dataface.core.compile.models.chart.authored import (
40
40
  ChartDataTablePerSeries,
41
41
  ChartDataTableSource,
42
42
  )
43
+ from dataface.core.compile.models.chart.resolved import FormatState
43
44
  from dataface.core.compile.models.style.resolved import (
44
45
  ResolvedChartsStyle,
45
46
  deep_merge,
@@ -274,7 +275,7 @@ def _inter_row_rule_y_pixel(
274
275
 
275
276
  def _vl_format_calc(
276
277
  source: str,
277
- format_spec: str | None,
278
+ format_spec: FormatState,
278
279
  as_name: str,
279
280
  formats: dict[str, str] | None = None,
280
281
  ) -> dict[str, Any]:
@@ -417,6 +418,7 @@ def _row_text_layer(
417
418
  style: DataTableStyle,
418
419
  charts_style: ResolvedChartsStyle,
419
420
  spec_height: float,
421
+ value_format: FormatState = None,
420
422
  sampling_step: int = 1,
421
423
  dx: float | None = None,
422
424
  label_period_filter_expr: str | None = None,
@@ -457,12 +459,7 @@ def _row_text_layer(
457
459
  # Sampling after period filter: thin further if still over the cap.
458
460
  if sampling_step > 1:
459
461
  transforms.extend(_sampling_transforms(x_field, sampling_step))
460
- if entry.format is not None:
461
- transforms.append(
462
- _vl_format_calc(agg_name, entry.format, cell_name, formats)
463
- )
464
- else:
465
- transforms.append(_vl_format_calc(agg_name, None, cell_name, formats))
462
+ transforms.append(_vl_format_calc(agg_name, value_format, cell_name, formats))
466
463
  else:
467
464
  # Source entry: G1 guarantees 1 row per x.
468
465
  # Period filter before sampling: thin to label-period openers first.
@@ -470,18 +467,21 @@ def _row_text_layer(
470
467
  transforms.append({"filter": label_period_filter_expr})
471
468
  if sampling_step > 1:
472
469
  transforms.extend(_sampling_transforms(x_field, sampling_step))
473
- if entry.format is not None:
474
- transforms.append(
475
- _vl_format_calc(entry.source, entry.format, cell_name, formats)
476
- )
477
- else:
478
- transforms.append(_vl_format_calc(entry.source, None, cell_name, formats))
470
+ transforms.append(
471
+ _vl_format_calc(entry.source, value_format, cell_name, formats)
472
+ )
479
473
 
480
474
  y_pixel = _row_y_pixel(index, style, charts_style, spec_height)
481
475
  encoding: dict[str, Any] = {
482
476
  "x": _shared_x_encoding(parent_x_enc),
483
477
  "y": {"value": y_pixel},
484
478
  "text": {"field": cell_name},
479
+ # Opt out of inherited color encoding. Without this, VL sees own data
480
+ # that lacks the series field after aggregation (groupby omits the color
481
+ # field), adds null to the categorical domain, and null sorts first —
482
+ # consuming palette[0] and shifting every series mark one slot up (the
483
+ # dark-companion / endpoint-label off-by-one).
484
+ "color": None,
485
485
  }
486
486
  layer: dict[str, Any] = {
487
487
  "mark": _text_mark_props(style, dx=dx),
@@ -504,6 +504,7 @@ def _per_series_row_layers(
504
504
  charts_style: ResolvedChartsStyle,
505
505
  spec_height: float,
506
506
  spec_width: float | None,
507
+ value_format: FormatState = None,
507
508
  sampling_step: int = 1,
508
509
  dx: float | None = None,
509
510
  label_period_filter_expr: str | None = None,
@@ -569,7 +570,7 @@ def _per_series_row_layers(
569
570
  _sampling_transforms(parent_x_enc["field"], sampling_step)
570
571
  )
571
572
  bm_transforms.append(
572
- _vl_format_calc(entry.per_series, entry.format, bm_cell_name, formats)
573
+ _vl_format_calc(entry.per_series, value_format, bm_cell_name, formats)
573
574
  )
574
575
  bm_y_pixel = _row_y_pixel(row_idx, style, charts_style, spec_height)
575
576
  bm_cell_layer: dict[str, Any] = {
@@ -645,7 +646,7 @@ def _per_series_row_layers(
645
646
  transforms.append({"filter": label_period_filter_expr})
646
647
  if sampling_step > 1:
647
648
  transforms.extend(_sampling_transforms(x_field, sampling_step))
648
- transforms.append(_vl_format_calc(agg_name, entry.format, cell_name, formats))
649
+ transforms.append(_vl_format_calc(agg_name, value_format, cell_name, formats))
649
650
 
650
651
  y_pixel = _row_y_pixel(row_idx, style, charts_style, spec_height)
651
652
  cell_layer: dict[str, Any] = {
@@ -975,6 +976,7 @@ def attach_data_table(
975
976
  series_palette: list[str] | None = None,
976
977
  label_period_filter_expr: str | None = None,
977
978
  axis_y_orient: Literal["left", "right"],
979
+ entry_formats: list[FormatState] | None = None,
978
980
  formats: dict[str, str] | None = None,
979
981
  ) -> dict[str, Any]:
980
982
  """Append attached-data-table layers to a Vega-Lite chart spec.
@@ -1160,6 +1162,7 @@ def attach_data_table(
1160
1162
  visual_row_idx = 0
1161
1163
  for i, entry in enumerate(data_table.entries):
1162
1164
  row_dx = entry_dx[i] if entry_dx is not None else None
1165
+ row_format = entry_formats[i] if entry_formats is not None else entry.format
1163
1166
  if isinstance(entry, ChartDataTablePerSeries):
1164
1167
  if entry.by_measure:
1165
1168
  # by_measure: one row per entry, no series expansion needed.
@@ -1174,11 +1177,13 @@ def attach_data_table(
1174
1177
  charts_style=charts_style,
1175
1178
  spec_height=spec_height,
1176
1179
  spec_width=spec_width,
1180
+ value_format=row_format,
1177
1181
  sampling_step=sampling_step,
1178
1182
  dx=row_dx,
1179
1183
  label_period_filter_expr=label_period_filter_expr,
1180
1184
  axis_y_orient=axis_y_orient,
1181
1185
  label_limit=_label_stub_limit,
1186
+ formats=formats,
1182
1187
  )
1183
1188
  layers.extend(bm_layers)
1184
1189
  if style.row.rule.width > 0 and visual_row_idx < n_visual_rows - 1:
@@ -1211,6 +1216,7 @@ def attach_data_table(
1211
1216
  charts_style=charts_style,
1212
1217
  spec_height=spec_height,
1213
1218
  spec_width=spec_width,
1219
+ value_format=row_format,
1214
1220
  sampling_step=sampling_step,
1215
1221
  dx=row_dx,
1216
1222
  label_period_filter_expr=label_period_filter_expr,
@@ -1250,6 +1256,7 @@ def attach_data_table(
1250
1256
  style=style,
1251
1257
  charts_style=charts_style,
1252
1258
  spec_height=spec_height,
1259
+ value_format=row_format,
1253
1260
  sampling_step=sampling_step,
1254
1261
  dx=row_dx,
1255
1262
  label_period_filter_expr=label_period_filter_expr,