dataface 0.4.1.dev53__py3-none-any.whl → 0.4.1.dev134__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 (136) hide show
  1. dataface/DATAFACE_SYNTAX.md +4 -2
  2. dataface/agent_api/board_artifact.py +15 -1
  3. dataface/agent_api/docs/yaml-reference.md +14 -4
  4. dataface/agent_api/project_session.py +4 -1
  5. dataface/agent_api/query.py +53 -7
  6. dataface/ai/skills/dashboard-build/SKILL.md +2 -0
  7. dataface/ai/tools/__init__.py +10 -3
  8. dataface/cli/commands/query.py +1 -1
  9. dataface/core/compile/data_table.py +8 -8
  10. dataface/core/compile/format.py +12 -2
  11. dataface/core/compile/inherit_graph.py +22 -1
  12. dataface/core/compile/inherit_resolver.py +1 -1
  13. dataface/core/compile/introspection.py +6 -0
  14. dataface/core/compile/models/chart/normalized/callout.py +7 -0
  15. dataface/core/compile/models/chart/resolved/_base.py +13 -0
  16. dataface/core/compile/models/config.py +0 -17
  17. dataface/core/compile/models/face/authored.py +12 -7
  18. dataface/core/compile/models/face/normalized.py +14 -7
  19. dataface/core/compile/models/face/resolved.py +67 -1
  20. dataface/core/compile/models/markers.py +20 -0
  21. dataface/core/compile/models/primitives.py +2 -1
  22. dataface/core/compile/models/style/authored/table.py +22 -3
  23. dataface/core/compile/models/style/context.py +225 -0
  24. dataface/core/compile/models/style/resolved/__init__.py +19 -8
  25. dataface/core/compile/models/style/resolved/_base.py +63 -124
  26. dataface/core/compile/models/style/resolved/_cascade.py +94 -51
  27. dataface/core/compile/models/style/resolved/point_map.py +3 -0
  28. dataface/core/compile/models/style/resolved/table.py +1 -1
  29. dataface/core/compile/models/style/theme/__init__.py +2 -4
  30. dataface/core/compile/models/style/theme/board.py +13 -40
  31. dataface/core/compile/models/style/theme/charts.py +1 -1
  32. dataface/core/compile/models/style/theme/marks.py +20 -7
  33. dataface/core/compile/normalize_layout.py +6 -0
  34. dataface/core/compile/normalizer.py +48 -19
  35. dataface/core/compile/parameterized.py +16 -2
  36. dataface/core/compile/resolve/__init__.py +6 -4
  37. dataface/core/compile/resolve/_axes.py +28 -19
  38. dataface/core/compile/resolve/_dispatch.py +18 -20
  39. dataface/core/compile/resolve/_kwargs.py +90 -46
  40. dataface/core/compile/resolve/_layers.py +36 -12
  41. dataface/core/compile/resolve/_marks.py +21 -5
  42. dataface/core/compile/resolve/_palette.py +25 -18
  43. dataface/core/compile/resolve/_table.py +42 -25
  44. dataface/core/compile/resolve/area.py +28 -21
  45. dataface/core/compile/resolve/bar.py +69 -47
  46. dataface/core/compile/resolve/geo.py +40 -28
  47. dataface/core/compile/resolve/heatmap.py +23 -19
  48. dataface/core/compile/resolve/line.py +31 -22
  49. dataface/core/compile/resolve/pie.py +25 -18
  50. dataface/core/compile/resolve/scatter.py +38 -25
  51. dataface/core/compile/resolve/simple.py +23 -20
  52. dataface/core/compile/schema_renderers/json_schema.py +5 -1
  53. dataface/core/compile/schema_renderers/prompt.py +7 -1
  54. dataface/core/compile/single_series_allocation.py +1 -1
  55. dataface/core/compile/sizing.py +8 -2
  56. dataface/core/compile/style_cascade.py +88 -54
  57. dataface/core/compile/typography.py +18 -18
  58. dataface/core/compile/validate_formats.py +2 -2
  59. dataface/core/compile/vega_config.py +3 -3
  60. dataface/core/compile/vega_lite/mapping.py +23 -23
  61. dataface/core/dashboard.py +6 -5
  62. dataface/core/defaults/default_config.yml +0 -39
  63. dataface/core/defaults/themes/_base.yaml +3 -0
  64. dataface/core/defaults/themes/solid.yaml +245 -0
  65. dataface/core/defaults/themes/stark.yaml +1 -1
  66. dataface/core/diagnostics/codes_render.py +108 -1
  67. dataface/core/dialects/athena.py +0 -2
  68. dataface/core/dialects/base.py +0 -40
  69. dataface/core/dialects/bigquery.py +0 -2
  70. dataface/core/dialects/databricks.py +0 -2
  71. dataface/core/dialects/duckdb.py +0 -2
  72. dataface/core/dialects/mysql.py +0 -2
  73. dataface/core/dialects/postgres.py +0 -2
  74. dataface/core/dialects/snowflake.py +0 -2
  75. dataface/core/dialects/sqlite.py +0 -2
  76. dataface/core/dialects/sqlserver.py +0 -2
  77. dataface/core/execute/adapters/adapter_registry.py +31 -0
  78. dataface/core/execute/chart_resolution.py +3 -3
  79. dataface/core/fonts.py +5 -5
  80. dataface/core/render/board_links.py +1 -1
  81. dataface/core/render/chart/data_table_attachment.py +83 -53
  82. dataface/core/render/chart/emitters/_cartesian.py +87 -12
  83. dataface/core/render/chart/emitters/_layers.py +90 -43
  84. dataface/core/render/chart/emitters/_overlay.py +9 -3
  85. dataface/core/render/chart/emitters/area.py +41 -16
  86. dataface/core/render/chart/emitters/bar.py +50 -34
  87. dataface/core/render/chart/emitters/geo.py +2 -0
  88. dataface/core/render/chart/emitters/line.py +17 -13
  89. dataface/core/render/chart/emitters/pie.py +8 -6
  90. dataface/core/render/chart/emitters/scatter.py +11 -7
  91. dataface/core/render/chart/features/endpoint_labels.py +3 -14
  92. dataface/core/render/chart/features/facet.py +12 -10
  93. dataface/core/render/chart/features/mirror_axis.py +20 -6
  94. dataface/core/render/chart/kpi.py +2 -2
  95. dataface/core/render/chart/rendering.py +96 -30
  96. dataface/core/render/chart/spark.py +7 -7
  97. dataface/core/render/chart/spec.py +14 -2
  98. dataface/core/render/chart/spec_builders.py +1 -1
  99. dataface/core/render/chart/table.py +106 -27
  100. dataface/core/render/chart/table_support.py +3 -41
  101. dataface/core/render/chart/time_unit_detect.py +16 -5
  102. dataface/core/render/chart/type_inference.py +14 -0
  103. dataface/core/render/chart/vega_lite.py +64 -19
  104. dataface/core/render/chart/vl_field_maps.py +5 -0
  105. dataface/core/render/chart_diagnostics.py +27 -0
  106. dataface/core/render/chart_interactivity.py +4 -2
  107. dataface/core/render/chrome_css.py +2 -2
  108. dataface/core/render/column_packer.py +65 -29
  109. dataface/core/render/converters/chart.py +11 -7
  110. dataface/core/render/face_resolve.py +57 -23
  111. dataface/core/render/face_to_dict.py +8 -9
  112. dataface/core/render/faces.py +4 -4
  113. dataface/core/render/font_support.py +0 -70
  114. dataface/core/render/format_utils.py +44 -19
  115. dataface/core/render/layout_sizing.py +261 -164
  116. dataface/core/render/layouts.py +6 -6
  117. dataface/core/render/numeral_expr.py +27 -17
  118. dataface/core/render/prose.py +29 -25
  119. dataface/core/render/renderer.py +3 -0
  120. dataface/core/render/sizing.py +21 -21
  121. dataface/core/render/templates/scripts/chart_interactivity.js +1 -1
  122. dataface/core/render/terminal.py +8 -2
  123. dataface/core/render/terminal_charts.py +2 -1
  124. dataface/core/render/warnings/value_labels_crowd_width.py +1 -1
  125. dataface/core/text/format_d3.py +115 -9
  126. dataface/core/text/numeral_scale.py +8 -7
  127. dataface/core/utils.py +84 -2
  128. dataface/data/highlighting/face.json +1 -1
  129. dataface/data/schemas/board-resolved/board-resolved.schema.json +898 -2880
  130. {dataface-0.4.1.dev53.dist-info → dataface-0.4.1.dev134.dist-info}/METADATA +3 -3
  131. {dataface-0.4.1.dev53.dist-info → dataface-0.4.1.dev134.dist-info}/RECORD +136 -133
  132. {dataface-0.4.1.dev53.dist-info → dataface-0.4.1.dev134.dist-info}/WHEEL +1 -1
  133. mdsvg/parser.py +17 -0
  134. mdsvg/renderer.py +175 -12
  135. {dataface-0.4.1.dev53.dist-info → dataface-0.4.1.dev134.dist-info}/entry_points.txt +0 -0
  136. {dataface-0.4.1.dev53.dist-info → dataface-0.4.1.dev134.dist-info}/licenses/LICENSE +0 -0
@@ -307,9 +307,11 @@ style:
307
307
  text:
308
308
  align: left
309
309
  column:
310
- number: 2
310
+ max_number: 2
311
311
  gap: 24
312
- rule: "1px solid #cbd5e1"
312
+ rule:
313
+ width: 1
314
+ color: "#cbd5e1"
313
315
  ```
314
316
 
315
317
  CSS-only keys like `border-radius`, `border-left`, `margin-top` are not board-style fields and are rejected.
@@ -22,6 +22,7 @@ from pydantic import BaseModel, ConfigDict
22
22
 
23
23
  from dataface.agent_api._paths import resolve_face_or_error
24
24
  from dataface.core.compile import compile_file
25
+ from dataface.core.compile.models.face.resolved import ChartResolveFailure
25
26
  from dataface.core.compile.variables import normalize_multiselect_values
26
27
  from dataface.core.diagnostics import ERR_FILE_NOT_FOUND, Diagnostic
27
28
  from dataface.core.diagnostics.base import DatafaceError
@@ -133,14 +134,27 @@ def emit_board_artifact(
133
134
  set_filter_variables_context(
134
135
  frozenset(face.variable_registry) if face.variable_registry else frozenset()
135
136
  )
137
+ resolve_errors: dict[str, ChartResolveFailure] = {}
136
138
  try:
137
- resolved, _render_cache = build_resolved_face(face, executor, merged_variables)
139
+ resolved, _render_cache = build_resolved_face(
140
+ face, executor, merged_variables, resolve_errors=resolve_errors
141
+ )
138
142
  except DatafaceError as exc:
139
143
  return EmitBoardArtifactResult(success=False, errors=[exc.to_diagnostic()])
140
144
  finally:
141
145
  set_auto_link_context(False)
142
146
  set_filter_variables_context(frozenset())
143
147
 
148
+ # A chart-level resolve failure no longer raises — it leaves a hole in the
149
+ # face instead. Publishing that hole silently would be worse than the fatal
150
+ # it replaced, since a live render at least draws an error tile where an
151
+ # artifact just has nothing. Artifacts stay all-or-nothing.
152
+ if resolve_errors:
153
+ return EmitBoardArtifactResult(
154
+ success=False,
155
+ errors=[failure.diagnostic for failure in resolve_errors.values()],
156
+ )
157
+
144
158
  recording = record_board(resolved, executor, merged_variables)
145
159
 
146
160
  artifact_path.parent.mkdir(parents=True, exist_ok=True)
@@ -11,7 +11,7 @@ AuthoredFace (dataface) definition from YAML.
11
11
  | `tags` | list[str] | ✓ | Tags for categorization and search. |
12
12
  | `aliases` | list[str] | ✓ | Additional URLs that redirect to this face's canonical file-path URL. Each entry must be absolute (leading /). Requests to these URLs are redirected (302) to the face's real path, query string preserved. Valid on .yml, .yaml, .md, and folder index.* faces. |
13
13
  | `text` | str | ✓ | Markdown text content for text-only sections. |
14
- | `allow_html` | bool | ✓ | Render the face's body text as raw HTML via foreignObject instead of markdown. TRUSTED-CONTENT ONLY: the HTML (including any Jinja-interpolated values) is rendered as-authored — this is NOT a security sandbox. mdsvg strips <script>/event-handlers as a best-effort guard, not a guarantee. Enable only on first-party faces you fully control. |
14
+ | `html_policy` | enum: "none", "safe-subset", "trusted-raw" | ✓ | HTML rendering policy for the face's body text. One of: "none" (default) — HTML is escaped and rendered as plain markdown; "safe-subset" — reserved for a parser-checked allowlist (not yet enforced — currently renders as none); "trusted-raw" — raw HTML via foreignObject. TRUSTED-CONTENT ONLY: this is NOT a security sandbox. <script>/event-handlers are stripped as a best-effort guard, not a guarantee. Enable only on first-party faces you fully control. |
15
15
  | `source` | str | ✓ | Default source name for all queries in this face. Inheritable via meta.yaml cascade. |
16
16
  | `cache` | [Cache](#cache) | ✓ | Cache policy for every query in this dashboard, e.g. cache: 1h — queries inherit it and may refine it; cache: false opts the whole dashboard out. Inheritable via the meta.yaml cascade. |
17
17
  | `variables` | dict[str, [Variable](#variable) \| VariableRef] | ✓ | Variable definitions for dynamic filtering and UI controls. |
@@ -1898,9 +1898,9 @@ Authored overlay for TextColumnStyle. Author overrides for the column layout of
1898
1898
 
1899
1899
  | Field | Type | Optional | Description |
1900
1900
  |-------|------|:--------:|-------------|
1901
- | `number` | int | ✓ | Ceiling on the column count. The renderer may choose fewer when there is not enough text to fill them. None = no ceiling. |
1901
+ | `max_number` | int | ✓ | Ceiling on the column count. The renderer may choose fewer when there is not enough text to fill them. None = no ceiling. |
1902
1902
  | `gap` | float | ✓ | Gap between columns in pixels. None = 1.5 line boxes, so the gutter scales with the type it separates. |
1903
- | `rule` | str | ✓ | CSS column-rule shorthand, e.g. '1px solid #e5e7eb'. None = no rule. |
1903
+ | `rule` | [ColumnRuleStyle](#columnrulestyle) | ✓ | Vertical rule drawn between columns. None = no rule. |
1904
1904
  | `max_chars` | int | ✓ | Column width as a character count, overriding the shipped measure. The width is used exactly and the column count follows from it. |
1905
1905
 
1906
1906
  <a id="textcodestyle"></a>
@@ -2620,6 +2620,16 @@ Authored overlay for GeoshapeMarkStyle. Geoshape (choropleth) mark fill and boun
2620
2620
  | `fill` | str | ✓ | Neutral geoshape fill color. |
2621
2621
  | `stroke` | [StrokeStyle](#strokestyle) | ✓ | Geoshape boundary stroke style. |
2622
2622
 
2623
+ <a id="columnrulestyle"></a>
2624
+ ## ColumnRuleStyle
2625
+ Authored overlay for ColumnRuleStyle. Vertical rule drawn between prose columns, structured like BorderStyle.
2626
+
2627
+ | Field | Type | Optional | Description |
2628
+ |-------|------|:--------:|-------------|
2629
+ | `width` | float | ✓ | Rule line width in pixels. |
2630
+ | `color` | str | ✓ | Rule color as a CSS color string. |
2631
+ | `style` | enum: "solid", "dashed", "dotted" | ✓ | Rule line style. |
2632
+
2623
2633
  <a id="circlemarkstyle"></a>
2624
2634
  ## CircleMarkStyle
2625
2635
  Authored overlay for CircleMarkStyle. Circle mark opacity and stroke.
@@ -2847,7 +2857,7 @@ Authored overlay for SliceLabelsStyle. Pie labels: typography + positioning offs
2847
2857
  |-------|------|:--------:|-------------|
2848
2858
  | `offset` | float | ✓ | Radial offset of slice labels from the arc in pixels. |
2849
2859
  | `line_height` | float | ✓ | Line height for slice labels in pixels. Reserved vertical space above the disk is ``line_height × &lt;rendered lines&gt;`` per row, so the same value handles 1-line, 2-line, and multi-line templates. |
2850
- | `font` | [FontStyle](#fontstyle) | ✓ | Slice label font style overrides. Unset fields fall back to [`style.charts.font`](#chartsstyle). |
2860
+ | `font` | [FontStyle](#fontstyle) | ✓ | Slice label font style overrides. ``color`` only takes effect on single-series pies (no ``color:`` channel authored); multi-series pies always paint each label the dark companion of its own wedge color and ignore an authored ``color`` here. Unset fields fall back to [`style.charts.font`](#chartsstyle) (except `color`). |
2851
2861
  | `default_template` | [LabelsDefaultTemplate](#labelsdefaulttemplate) | ✓ | Default Jinja templates for per-slice labels when template is not authored. |
2852
2862
  | `template` | str | ✓ | Jinja2 label template. Overrides default_template when authored. |
2853
2863
  | `where` | str | ✓ | Jinja2 boolean filter — labels only render on rows where this is truthy. |
@@ -365,8 +365,11 @@ class ProjectSession:
365
365
  self,
366
366
  name: str,
367
367
  path: Path,
368
+ vars: dict[str, Any] | None = None,
368
369
  ) -> _query.FaceQueryLookupResult:
369
- return _query.lookup_face_query_sql(name=name, path=path, project=self.project)
370
+ return _query.lookup_face_query_sql(
371
+ name=name, path=path, project=self.project, vars=vars
372
+ )
370
373
 
371
374
  def query_face(
372
375
  self,
@@ -33,12 +33,17 @@ def lookup_face_query_sql(
33
33
  path: Path,
34
34
  *,
35
35
  project: Project,
36
+ vars: dict[str, Any] | None = None,
36
37
  ) -> FaceQueryLookupResult:
37
- """Compile a face file and extract the SQL + source for one named SQL query.
38
+ """Compile a face file, render the SQL template, and return it for offline use.
38
39
 
39
- Used by validate and describe paths that need the raw SQL text without
40
- executing it against a warehouse.
40
+ Expands ``{{ queries.X }}`` and substitutes variables so callers receive
41
+ valid SQL rather than a raw Jinja template. Used by validate and describe
42
+ paths that need the rendered SQL text without executing it against a
43
+ warehouse.
41
44
  """
45
+ from dataface.core.compile.parameterized import render_parameterized_with_queries
46
+
42
47
  try:
43
48
  file_path = resolve_face_path(path, project)
44
49
  except ValueError as e:
@@ -74,7 +79,18 @@ def lookup_face_query_sql(
74
79
  ],
75
80
  )
76
81
 
77
- return FaceQueryLookupResult(success=True, sql=query.sql, source=query.source)
82
+ merged_vars = {**face.variable_defaults, **(vars or {})}
83
+ try:
84
+ rendered = render_parameterized_with_queries(
85
+ query.sql,
86
+ merged_vars,
87
+ queries=face.queries,
88
+ strict=not query.lenient_variables,
89
+ )
90
+ except (ValueError, KeyError, TypeError) as exc:
91
+ return FaceQueryLookupResult(success=False, errors=[str(exc)])
92
+
93
+ return FaceQueryLookupResult(success=True, sql=rendered.sql, source=query.source)
78
94
 
79
95
 
80
96
  class ExecuteQueryArgs(BaseModel):
@@ -93,6 +109,14 @@ class ExecuteQueryArgs(BaseModel):
93
109
  False,
94
110
  description="When True, undefined Jinja variables degrade gracefully (useful for iterative chart editing where not all variables are set).",
95
111
  )
112
+ # Display only — never reaches execute_query(). Ad-hoc SQL is the one query
113
+ # surface with no author to describe it: a named face query carries the
114
+ # `description:` its YAML already declares, and this is the equivalent for
115
+ # a query that exists only for the length of one tool call.
116
+ description: str | None = Field(
117
+ None,
118
+ description="Short phrase naming what this query is for (e.g. 'Leads by industry'), shown to the user in place of the raw SQL.",
119
+ )
96
120
 
97
121
 
98
122
  class ExecuteQueryResult(BaseModel):
@@ -236,6 +260,9 @@ class QueryFaceResult(BaseModel):
236
260
  path: PurePosixPath
237
261
  query_type: str | None = None
238
262
  sql: str | None = None
263
+ #: The query's authored `description:`, when its YAML declares one. Lets a
264
+ #: caller label the run with what it is for rather than its identifier.
265
+ description: str | None = None
239
266
  columns: list[str] = []
240
267
  data: list[dict[str, Any]] = []
241
268
  row_count: int = 0
@@ -249,11 +276,19 @@ def _fail(
249
276
  path: PurePosixPath,
250
277
  errors: list[str],
251
278
  available: list[str] | None = None,
279
+ sql: str | None = None,
280
+ description: str | None = None,
252
281
  ) -> QueryFaceResult:
282
+ """A failure reports the query it was running whenever it got far enough to
283
+ know it: an undefined-variable error is unreadable without the SQL holding
284
+ the `{{ }}` that went unbound. Failures before the query is looked up
285
+ (unresolvable path, compile error, unknown name) have neither to report."""
253
286
  return QueryFaceResult(
254
287
  success=False,
255
288
  name=name,
256
289
  path=path,
290
+ sql=sql,
291
+ description=description,
257
292
  errors=errors,
258
293
  available_queries=available or [],
259
294
  )
@@ -298,6 +333,8 @@ def query_face(
298
333
 
299
334
  merged_vars = {**face.variable_defaults, **(vars or {})}
300
335
  query = face.queries[name].model_copy(update={"limit": limit + 1})
336
+ sql = query.sql if isinstance(query, SqlQuery) else None
337
+ described = query.description
301
338
 
302
339
  # Detect file sources before dispatching — the adapter path has no materializer
303
340
  # and would fall through to SqlAdapter with a cryptic dbt error. File-source
@@ -324,6 +361,8 @@ def query_face(
324
361
  "(dft render / dft serve). "
325
362
  "Use dft render to preview this dashboard."
326
363
  ],
364
+ sql=sql,
365
+ description=described,
327
366
  )
328
367
 
329
368
  try:
@@ -331,10 +370,16 @@ def query_face(
331
370
  query, variables=merged_vars or None, face=face, query_name=name
332
371
  )
333
372
  except Exception as e: # noqa: BLE001
334
- return _fail(name, resolved_display, [str(e)])
373
+ return _fail(name, resolved_display, [str(e)], sql=sql, description=described)
335
374
 
336
375
  if exec_result.error:
337
- return _fail(name, resolved_display, [exec_result.error])
376
+ return _fail(
377
+ name,
378
+ resolved_display,
379
+ [exec_result.error],
380
+ sql=sql,
381
+ description=described,
382
+ )
338
383
 
339
384
  data = normalize_data_for_json(exec_result.data)
340
385
  truncated = len(data) > limit
@@ -345,7 +390,8 @@ def query_face(
345
390
  name=name,
346
391
  path=resolved_display,
347
392
  query_type=query.query_type,
348
- sql=query.sql if isinstance(query, SqlQuery) else None,
393
+ sql=sql,
394
+ description=described,
349
395
  columns=list(data[0].keys()) if data else [],
350
396
  data=data,
351
397
  row_count=len(data),
@@ -260,12 +260,14 @@ Named presets (prefer these over raw D3 strings):
260
260
  | Preset | Example output | Notes |
261
261
  |--------|---------------|-------|
262
262
  | `integer` | `1,234` | |
263
+ | `year` | `2025` — no grouping, no SI scaling | an identifier, not a quantity |
263
264
  | `currency_whole` | `$1,234` | |
264
265
  | `currency_compact` | `$1.2M` | |
265
266
  | `percent` | `2.3%` — input is decimal fraction (0.023) | line/bar/area/scatter via `style.number_format` |
266
267
  | `percent_number` | `2.3%` — input is whole-number percent (2.3) | **KPI only** (`style.value.format`) |
267
268
  | `percent_delta` | `+2.3%` — input is decimal fraction | |
268
269
  | `percent_number_delta` | `+2.3%` — input is whole-number (2.3) | **KPI only** |
270
+ | `percentage_points_delta` | `+3.2 pts` — the difference of two percentages | for a point delta, not a ratio delta |
269
271
 
270
272
  **`percent_number` and `percent_number_delta` only work on `type: kpi`.** Using them on line/bar/area/scatter causes a render error. For those chart types use `percent` or `percent_delta` (decimal-fraction input) or omit formatting and accept axis defaults.
271
273
 
@@ -206,9 +206,16 @@ def _handle_get_skill(args: dict[str, Any], ctx: DatafaceAIContext) -> dict[str,
206
206
  )
207
207
  except _skills.SkillNotFound as exc:
208
208
  return {"success": False, "errors": [str(exc)]}
209
- return skill.model_dump(
210
- mode="json", exclude_none=True, exclude=_skills.SKILL_WIRE_EXCLUDE_FIELDS
211
- )
209
+ # `Skill` is a domain model, not a result envelope — it carries no verdict
210
+ # of its own, and it must not grow one (it nests inside `SkillList.skills`,
211
+ # where a per-item `success` means nothing). The envelope is added here, so
212
+ # this payload states its outcome like every other tool's.
213
+ return {
214
+ "success": True,
215
+ **skill.model_dump(
216
+ mode="json", exclude_none=True, exclude=_skills.SKILL_WIRE_EXCLUDE_FIELDS
217
+ ),
218
+ }
212
219
 
213
220
 
214
221
  def _handle_search_skills(
@@ -344,7 +344,7 @@ def query_command(
344
344
  if mode_is_named:
345
345
  assert query_name is not None
346
346
  assert face is not None
347
- lr = project_session.lookup_face_query_sql(query_name, face)
347
+ lr = project_session.lookup_face_query_sql(query_name, face, vars=vars)
348
348
  if not lr.success:
349
349
  _handle_lookup_failure(lr, query_name)
350
350
  sql_text = lr.sql
@@ -16,7 +16,7 @@ helpers) live there too.
16
16
  from __future__ import annotations
17
17
 
18
18
  from dataface.core.compile.merge import merge_onto_base
19
- from dataface.core.compile.models.style.resolved import ResolvedChartsStyle
19
+ from dataface.core.compile.models.style.context import ChartStyleContext
20
20
  from dataface.core.compile.models.style.theme import DataTableStyle
21
21
  from dataface.core.compile.style_cascade import resolved_axis_style
22
22
 
@@ -31,7 +31,7 @@ def row_height(style: DataTableStyle) -> float:
31
31
  return _ROW_HEIGHT_BASE + padding.vertical * 2.0
32
32
 
33
33
 
34
- def axis_offset(charts_style: ResolvedChartsStyle, style: DataTableStyle) -> float:
34
+ def axis_offset(chart_style_context: ChartStyleContext, style: DataTableStyle) -> float:
35
35
  """Pixel offset from plot bottom edge to the bottom of the x-axis block.
36
36
 
37
37
  Sums whichever axis components are actually visible: a chart with the
@@ -45,14 +45,14 @@ def axis_offset(charts_style: ResolvedChartsStyle, style: DataTableStyle) -> flo
45
45
 
46
46
  MUST use the fully-merged axis (resolved_axis_style) rather than reading
47
47
  charts_style.axis_x directly. The authored x_label visibility flip is
48
- stored on axis_overrides_x (Layer 6) by build_resolved_style; reading
48
+ stored on axis_overrides_x (Layer 6) by build_chart_style_context; reading
49
49
  the base axis_x (Layers 1+2) misses it, under-reserving title space and
50
50
  causing the strip to collide with the rendered axis title.
51
51
  """
52
52
  # Font metrics come from the base ResolvedAxisStyle (non-None guaranteed).
53
53
  # Visibility is read from the fully-merged cascade (includes Layer-6 authored-
54
54
  # label flips) so both label and title heights reflect the actual rendered state.
55
- merged = resolved_axis_style(charts_style, "axis_x", "band")
55
+ merged = resolved_axis_style(chart_style_context, "axis_x", "band")
56
56
  # labels.padding is always required by build_resolved_axis (the shared
57
57
  # ResolvedAxisElementStyle dataclass types it Optional only to let title
58
58
  # leave it unset — see AxisTitleStyle).
@@ -72,7 +72,7 @@ def axis_offset(charts_style: ResolvedChartsStyle, style: DataTableStyle) -> flo
72
72
 
73
73
 
74
74
  def resolve_effective_data_table_style(
75
- charts_style: ResolvedChartsStyle, chart_type: str
75
+ chart_style_context: ChartStyleContext, chart_type: str
76
76
  ) -> DataTableStyle:
77
77
  """Resolve the effective data_table style for a chart.
78
78
 
@@ -81,7 +81,7 @@ def resolve_effective_data_table_style(
81
81
  fields from charts.data_table, so this function only needs to apply the
82
82
  per-family theme values (tier-2) on top.
83
83
  """
84
- per_type = getattr(charts_style, chart_type, None)
84
+ per_type = getattr(chart_style_context, chart_type, None)
85
85
  if per_type is None or not hasattr(per_type, "data_table"):
86
- return charts_style.data_table
87
- return merge_onto_base(charts_style.data_table, per_type.data_table)
86
+ return chart_style_context.data_table
87
+ return merge_onto_base(chart_style_context.data_table, per_type.data_table)
@@ -13,6 +13,7 @@ from __future__ import annotations
13
13
  from typing import Any
14
14
 
15
15
  from dataface.core.compile.models.primitives import FormatConfig
16
+ from dataface.core.text.format_d3 import round_aware_format
16
17
 
17
18
 
18
19
  def resolve_format(
@@ -35,6 +36,15 @@ def resolve_format(
35
36
  rendering. That pass keys slots off field name, so it is not a guarantee
36
37
  for every value reaching this function.
37
38
 
39
+ The resolved spec also carries ``round_aware_format``'s ``~``-trim
40
+ injection (a no-op for a non-SI spec, a native-formatter key, or a
41
+ strftime directive). This is the single point nearly every consumer of a
42
+ resolved format string is baked from — axes and mark labels at compile,
43
+ KPI/table/tooltip at render — so every one of them, including a spec
44
+ handed straight to Vega-Lite (``axis.format``, a value label's
45
+ ``text.format``), agrees on digits by construction instead of each
46
+ render leaf re-applying the trim itself.
47
+
38
48
  Args:
39
49
  format_input: Format specification (string, FormatConfig, dict, or None)
40
50
  formats: The resolved format alias dict from the theme cascade
@@ -59,10 +69,10 @@ def resolve_format(
59
69
 
60
70
  # Theme/face alias lookup (case-sensitive, key-exact)
61
71
  if formats and format_str in formats:
62
- return formats[format_str]
72
+ return round_aware_format(formats[format_str])
63
73
 
64
74
  # Raw d3 passthrough — let d3 parse or raise
65
- return format_str
75
+ return round_aware_format(format_str)
66
76
 
67
77
 
68
78
  # d3's first SI prefix ("k") engages at 1000. Below it, ".2s" doesn't compact —
@@ -97,11 +97,16 @@ def _build_graph(
97
97
  slot_at: StylePath | None = None,
98
98
  slot_from: StylePath | None = None,
99
99
  skip_slots: bool = False,
100
+ slot_exclude: frozenset[str] = frozenset(),
100
101
  ) -> None:
101
102
  """Walk *model_cls* recursively and populate *graph* from Inherit/InheritSlot markers.
102
103
 
103
104
  ``skip_slots=True`` suppresses InheritSlot expansion for the entire subtree —
104
105
  used when a shared model's slot annotations must not apply at a particular path.
106
+
107
+ ``slot_exclude`` carries the active slot's ``InheritSlot.exclude`` leaf names
108
+ down through the recursion; a leaf whose field name is in this set gets no
109
+ graph entry, leaving it a genuine cascade-managed sentinel.
105
110
  """
106
111
  if model_cls in seen:
107
112
  return
@@ -160,6 +165,7 @@ def _build_graph(
160
165
  slot_at=slot_at if skip_marker.cascade else None,
161
166
  slot_from=slot_from if skip_marker.cascade else None,
162
167
  skip_slots=not skip_marker.cascade,
168
+ slot_exclude=slot_exclude if skip_marker.cascade else frozenset(),
163
169
  )
164
170
  elif slot_marker is not None and not skip_slots:
165
171
  if slot_at is not None:
@@ -176,6 +182,7 @@ def _build_graph(
176
182
  graph,
177
183
  slot_at=slot_at,
178
184
  slot_from=slot_from,
185
+ slot_exclude=slot_exclude,
179
186
  )
180
187
  else:
181
188
  from_path = slot_marker.from_path
@@ -184,6 +191,12 @@ def _build_graph(
184
191
  raise ValueError(
185
192
  f"InheritSlot from_path {from_path!r} does not match any path in the model"
186
193
  )
194
+ unknown_exclude = slot_marker.exclude - nested.model_fields.keys()
195
+ if unknown_exclude:
196
+ raise ValueError(
197
+ f"InheritSlot exclude {sorted(unknown_exclude)!r} on "
198
+ f"{_path_str(current)!r} not a field of {nested.__name__}"
199
+ )
187
200
  # Nullable InheritSlot (T | None): emit a container-level copy
188
201
  # link so apply_inherit can copy the whole parent object when
189
202
  # this field is None. apply_inherit._set() skips writes through
@@ -200,6 +213,7 @@ def _build_graph(
200
213
  graph,
201
214
  slot_at=current,
202
215
  slot_from=from_tuple,
216
+ slot_exclude=slot_marker.exclude,
203
217
  )
204
218
  else:
205
219
  _build_graph(
@@ -211,6 +225,7 @@ def _build_graph(
211
225
  slot_at=slot_at,
212
226
  slot_from=slot_from,
213
227
  skip_slots=skip_slots,
228
+ slot_exclude=slot_exclude,
214
229
  )
215
230
  else:
216
231
  # Leaf node.
@@ -226,7 +241,13 @@ def _build_graph(
226
241
  # slot (which authors may override independently).
227
242
  # 2. Explicit Inherit: fires at canonical (non-slot) positions only,
228
243
  # establishing the "ultimate fallback" for the entire slot family.
229
- if slot_at is not None and slot_from is not None and skip_marker is None:
244
+ #
245
+ # A leaf named in the active slot's exclude set gets no link at
246
+ # all: it stays a genuine cascade-managed sentinel (None unless
247
+ # some tier's raw YAML sets it) rather than being backfilled.
248
+ if field_name in slot_exclude:
249
+ pass
250
+ elif slot_at is not None and slot_from is not None and skip_marker is None:
230
251
  leaf_suffix: StylePath = current[len(slot_at) :]
231
252
  fallback_path = _path_str(slot_from + leaf_suffix)
232
253
  if fallback_path not in valid_paths:
@@ -4,7 +4,7 @@ apply_inherit fills None-sentinel leaves by walking the single-link
4
4
  parent chains declared via Inherit/InheritSlot markers on the compiled Style.
5
5
  It runs after merge_onto_base, replacing the former hard-coded push loop approach.
6
6
 
7
- Not in scope: _build_resolved_style, resolved_axis_style.
7
+ Not in scope: _build_chart_style_context, resolved_axis_style.
8
8
  """
9
9
 
10
10
  from __future__ import annotations
@@ -72,6 +72,9 @@ class SchemaField:
72
72
  )
73
73
  inherit_from: tuple[str, ...] = () # ordered fallback dot-paths (Inherit marker)
74
74
  inherit_slot: str | None = None # source subtree path (InheritSlot marker)
75
+ inherit_slot_exclude: frozenset[str] = (
76
+ frozenset()
77
+ ) # leaf names InheritSlot excludes
75
78
 
76
79
 
77
80
  @dataclasses.dataclass
@@ -411,6 +414,9 @@ def _build_schema_field(
411
414
  container_mapping_models=_container_mapping_models(annotation),
412
415
  inherit_from=(inherit.from_path,) if inherit is not None else (),
413
416
  inherit_slot=inherit_slot.from_path if inherit_slot is not None else None,
417
+ inherit_slot_exclude=(
418
+ inherit_slot.exclude if inherit_slot is not None else frozenset()
419
+ ),
414
420
  )
415
421
 
416
422
 
@@ -44,6 +44,13 @@ class CalloutChart(BaseModel):
44
44
  description="Always None — callout has no query. Present so build_resolved_face "
45
45
  "can access compiled.query_name directly without isinstance guards.",
46
46
  )
47
+ description: str = Field(
48
+ default="",
49
+ description="Always empty — callout has no description. Present so generic "
50
+ "chart-iteration code (ChartIdentity.from_normalized) can access "
51
+ "chart.description without isinstance guards, mirroring the same "
52
+ "structural-parity field on ResolvedCalloutChart.",
53
+ )
47
54
  query_is_inline: bool = Field(
48
55
  default=False,
49
56
  description="True when query is defined inline; False for named reference.",
@@ -30,6 +30,7 @@ from dataface.core.compile.models.style.resolved import (
30
30
  ResolvedLegendStyle,
31
31
  )
32
32
  from dataface.core.compile.models.style.theme.board import PaddingStyle, TitleStyle
33
+ from dataface.core.compile.models.style.theme.table import DataTableStyle
33
34
  from dataface.core.compile.models.vega_lite.contracts import Projection
34
35
 
35
36
 
@@ -189,6 +190,18 @@ class _CartesianResolvedChartFields(_SharedResolvedChartFields):
189
190
  )
190
191
  min_height: float | None = Field(default=None, description="Minimum chart height.")
191
192
  max_height: float | None = Field(default=None, description="Maximum chart height.")
193
+ # Baked at resolve time so render never re-runs the axis cascade to
194
+ # compute data_table strip geometry — see compile/data_table.py's
195
+ # axis_offset()/resolve_effective_data_table_style(), the per-chart
196
+ # cascade steps this projects. None when data_table is None.
197
+ data_table_axis_offset: float | None = Field(
198
+ default=None,
199
+ description="Pixel offset from plot bottom to the x-axis block bottom, for data_table strip placement.",
200
+ )
201
+ effective_data_table_style: DataTableStyle | None = Field(
202
+ default=None,
203
+ description="Final per-chart data_table style (board theme merged with the family override).",
204
+ )
192
205
 
193
206
 
194
207
  class _GeoResolvedChartFields(_SharedResolvedChartFields):
@@ -162,22 +162,6 @@ class ChartRenderingConfig(ConfigNode):
162
162
  footer_rule_gap_px: int
163
163
  footer_timestamp_gap_px: int
164
164
 
165
- class ProseConfig(ConfigNode):
166
- target_chars: int
167
- single_column_chars: int
168
- min_chars: int
169
- max_chars: int
170
- min_lines_per_column: int
171
- max_columns: int
172
- gutter_line_boxes: float
173
- orphan_penalty: float
174
- widow_penalty: float
175
- interior_break_penalty: float
176
- block_break_penalty: float
177
- balance_weight: float
178
- balance_scale: float
179
- balance_undershoot: float
180
-
181
165
  class DataTableConfig(ConfigNode):
182
166
  divider_gap: float
183
167
  chart_data_table_max_x_ticks: int
@@ -190,7 +174,6 @@ class ChartRenderingConfig(ConfigNode):
190
174
  bar: BarConfig
191
175
  type_inference: TypeInferenceConfig
192
176
  board: BoardConfig
193
- prose: ProseConfig
194
177
  data_table: DataTableConfig
195
178
  stroke: StrokeConfig
196
179
  axis: AxisConfig
@@ -441,14 +441,19 @@ class AuthoredFace(_FaceDesugarMixin):
441
441
  text: Annotated[str | None, Merge(Strategy.OVERRIDE)] = Field(
442
442
  default=None, description="Markdown text content for text-only sections."
443
443
  )
444
- allow_html: Annotated[bool, Merge(Strategy.OVERRIDE)] = Field(
445
- default=False,
444
+ html_policy: Annotated[
445
+ Literal["none", "safe-subset", "trusted-raw"], Merge(Strategy.OVERRIDE)
446
+ ] = Field(
447
+ default="none",
446
448
  description=(
447
- "Render the face's body text as raw HTML via foreignObject instead of "
448
- "markdown. TRUSTED-CONTENT ONLY: the HTML (including any Jinja-"
449
- "interpolated values) is rendered as-authored — this is NOT a security "
450
- "sandbox. mdsvg strips <script>/event-handlers as a best-effort guard, "
451
- "not a guarantee. Enable only on first-party faces you fully control."
449
+ "HTML rendering policy for the face's body text. One of: "
450
+ '"none" (default) — HTML is escaped and rendered as plain markdown; '
451
+ '"safe-subset" — reserved for a parser-checked allowlist (not yet '
452
+ "enforced — currently renders as none); "
453
+ '"trusted-raw" — raw HTML via foreignObject. TRUSTED-CONTENT ONLY: '
454
+ "this is NOT a security sandbox. <script>/event-handlers are stripped "
455
+ "as a best-effort guard, not a guarantee. Enable only on first-party "
456
+ "faces you fully control."
452
457
  ),
453
458
  )
454
459
 
@@ -12,6 +12,7 @@ from dataface.core.compile.models.chart.normalized import Chart
12
12
  from dataface.core.compile.models.face.authored import LayoutType
13
13
  from dataface.core.compile.models.query.normalized import AnyQuery
14
14
  from dataface.core.compile.models.style.authored import StylePatch
15
+ from dataface.core.compile.models.style.context import ChartStyleContext
15
16
  from dataface.core.compile.models.style.resolved import ResolvedStyle
16
17
  from dataface.core.compile.models.variable.authored import SingleRowBoolProbe, Variable
17
18
 
@@ -317,14 +318,11 @@ class Face(BaseModel):
317
318
  text: str = Field(
318
319
  default="", description="Markdown text content for text-only faces."
319
320
  )
320
- allow_html: bool = Field(
321
- default=False,
321
+ html_policy: Literal["none", "safe-subset", "trusted-raw"] = Field(
322
+ default="none",
322
323
  description=(
323
- "Render the face's body text as raw HTML via foreignObject instead of "
324
- "markdown. TRUSTED-CONTENT ONLY: the HTML (including any Jinja-"
325
- "interpolated values) is rendered as-authored — this is NOT a security "
326
- "sandbox. mdsvg strips <script>/event-handlers as a best-effort guard, "
327
- "not a guarantee. Enable only on first-party faces you fully control."
324
+ "HTML rendering policy for the face's body text. "
325
+ "See docs/contributing/markdown-html-policy.md for tier definitions."
328
326
  ),
329
327
  )
330
328
 
@@ -379,6 +377,15 @@ class Face(BaseModel):
379
377
  resolved_style: ResolvedStyle = Field(
380
378
  description="Board-scoped resolved style; produced at compile time. Render reads this instead of re-merging patches.",
381
379
  )
380
+ # Board-scoped chart style cascade context — compiler working state
381
+ # (sparse axis overlays, patch sentinels, palette/role token bindings, the
382
+ # pre-inherit tree), produced alongside resolved_style by the same
383
+ # cascade. Used only by runtime chart resolution (execute orchestration,
384
+ # sizing, the data_table-attachment axis-offset step) — never by
385
+ # ResolvedFace or any mechanical render API.
386
+ chart_style_context: ChartStyleContext = Field(
387
+ description="Board-scoped chart style cascade context for runtime chart resolution.",
388
+ )
382
389
 
383
390
  @property
384
391
  def visible_variables(self) -> "dict[str, Variable]":