dataface 0.4.1.dev5__py3-none-any.whl → 0.4.1.dev53__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 (86) hide show
  1. dataface/DATAFACE_SYNTAX.md +12 -0
  2. dataface/agent_api/diagnostics.py +25 -7
  3. dataface/agent_api/docs/yaml-reference.md +4 -9
  4. dataface/ai/skills/dashboard-pack-scaffolding/SKILL.md +1 -1
  5. dataface/cli/_error_format.py +13 -9
  6. dataface/cli/commands/render.py +25 -7
  7. dataface/cli/main.py +8 -8
  8. dataface/core/compile/chart_focus.py +13 -2
  9. dataface/core/compile/chart_update.py +23 -19
  10. dataface/core/compile/jinja.py +19 -3
  11. dataface/core/compile/migrations/versions/v0_4_0.py +5 -5
  12. dataface/core/compile/models/chart/authored/__init__.py +0 -2
  13. dataface/core/compile/models/chart/authored/_conditional_formatting.py +2 -18
  14. dataface/core/compile/models/config.py +17 -0
  15. dataface/core/compile/models/style/resolved/_base.py +0 -1
  16. dataface/core/compile/models/style/resolved/_cascade.py +0 -1
  17. dataface/core/compile/models/style/theme/__init__.py +0 -2
  18. dataface/core/compile/models/style/theme/axis.py +0 -11
  19. dataface/core/compile/models/style/theme/board.py +21 -67
  20. dataface/core/compile/models/style/theme/variables.py +0 -19
  21. dataface/core/compile/parameterized.py +59 -1
  22. dataface/core/compile/resolve/__init__.py +2 -4
  23. dataface/core/compile/resolve/_dispatch.py +2 -95
  24. dataface/core/compile/resolve/_domain.py +1 -68
  25. dataface/core/compile/resolve/area.py +5 -19
  26. dataface/core/compile/resolve/bar.py +0 -15
  27. dataface/core/compile/resolve/line.py +5 -20
  28. dataface/core/compile/resolve/pie.py +52 -53
  29. dataface/core/compile/resolve/pie_attachment.py +56 -1
  30. dataface/core/compile/resolve/scatter.py +18 -31
  31. dataface/core/compile/sizing.py +239 -5
  32. dataface/core/dashboard.py +1 -6
  33. dataface/core/defaults/default_config.yml +39 -0
  34. dataface/core/defaults/themes/_base.yaml +9 -17
  35. dataface/core/defaults/themes/cream.yaml +0 -3
  36. dataface/core/defaults/themes/neon.yaml +0 -1
  37. dataface/core/diagnostics/__init__.py +0 -2
  38. dataface/core/diagnostics/codes_compile.py +5 -0
  39. dataface/core/diagnostics/codes_execute.py +0 -23
  40. dataface/core/diagnostics/codes_render.py +1 -0
  41. dataface/core/diagnostics/registry.py +6 -0
  42. dataface/core/execute/chart_resolution.py +5 -199
  43. dataface/core/font_measure.py +20 -0
  44. dataface/core/render/__init__.py +0 -2
  45. dataface/core/render/chart/kpi.py +17 -13
  46. dataface/core/render/chart/rendering.py +7 -7
  47. dataface/core/render/chart/table.py +4 -2
  48. dataface/core/render/chart/table_support.py +3 -3
  49. dataface/core/render/column_packer.py +236 -0
  50. dataface/core/render/controls.py +26 -476
  51. dataface/core/render/converters/chart.py +20 -0
  52. dataface/core/render/converters/html.py +5 -8
  53. dataface/core/render/faces.py +61 -224
  54. dataface/core/render/layout_sizing.py +2 -19
  55. dataface/core/render/prose.py +295 -0
  56. dataface/core/render/render_result.py +0 -3
  57. dataface/core/render/renderer.py +7 -25
  58. dataface/core/render/sizing.py +56 -316
  59. dataface/core/render/svg_utils.py +25 -6
  60. dataface/core/render/templates/controls/_styles.css +50 -489
  61. dataface/core/render/templates/page.html +1 -2
  62. dataface/core/render/templates/scripts/variables.js +776 -1028
  63. dataface/core/render/templates/svg/styles.css +29 -6
  64. dataface/core/render/variables_resolve.py +59 -6
  65. dataface/core/render/variables_strip.py +119 -9
  66. dataface/core/render/warnings/layered_chart_shared_y_axis_scale_mismatch.py +2 -1
  67. dataface/core/utils.py +7 -3
  68. dataface/data/schemas/board-resolved/board-resolved.schema.json +5 -85
  69. {dataface-0.4.1.dev5.dist-info → dataface-0.4.1.dev53.dist-info}/METADATA +1 -2
  70. {dataface-0.4.1.dev5.dist-info → dataface-0.4.1.dev53.dist-info}/RECORD +76 -84
  71. mdsvg/fonts.py +28 -2
  72. mdsvg/renderer.py +132 -2
  73. mdsvg/style.py +6 -0
  74. dataface/core/render/templates/variable_controls/checkbox.html +0 -6
  75. dataface/core/render/templates/variable_controls/date.html +0 -6
  76. dataface/core/render/templates/variable_controls/daterange.html +0 -16
  77. dataface/core/render/templates/variable_controls/layer.html +0 -28
  78. dataface/core/render/templates/variable_controls/multiselect.html +0 -19
  79. dataface/core/render/templates/variable_controls/number.html +0 -6
  80. dataface/core/render/templates/variable_controls/readonly.html +0 -4
  81. dataface/core/render/templates/variable_controls/select.html +0 -13
  82. dataface/core/render/templates/variable_controls/slider.html +0 -9
  83. dataface/core/render/templates/variable_controls/text.html +0 -6
  84. {dataface-0.4.1.dev5.dist-info → dataface-0.4.1.dev53.dist-info}/WHEEL +0 -0
  85. {dataface-0.4.1.dev5.dist-info → dataface-0.4.1.dev53.dist-info}/entry_points.txt +0 -0
  86. {dataface-0.4.1.dev5.dist-info → dataface-0.4.1.dev53.dist-info}/licenses/LICENSE +0 -0
@@ -957,6 +957,18 @@ WHERE plan IN ({{ plans | map('tojson') | join(', ') }})
957
957
  WHERE {{ filter('plan', plans) }}
958
958
  ```
959
959
 
960
+ **Numeric arithmetic antipattern** — `{{ n | int }}` and `{{ n | float }}` raise an error on parameterized variables. Jinja's `| int` filter calls `int(value)` internally; on a parameterized variable this would silently return 0, so Dataface rejects it instead. Write SQL arithmetic directly on the variable:
961
+
962
+ ```sql
963
+ -- WRONG: raises ERR-JINJA-ERROR at render time
964
+ INTERVAL -({{ months | int - 1 }}) MONTH
965
+
966
+ -- CORRECT: {{ months }} emits the bound parameter; the database handles arithmetic
967
+ INTERVAL -({{ months }} - 1) MONTH
968
+ ```
969
+
970
+ In a direct query (no `{{ queries.X }}` references), `{{ n }}` emits a bound parameter (`$1`, `%s`, `?` depending on dialect). In a composed query (one using `{{ queries.X }}`), variables are resolved as literals before the parameterization pass — `{{ n }}` with `n: 3` becomes the literal `3`. In both cases, `{{ n | int }}` and `{{ n | float }}` raise — so adding or removing a `{{ queries.X }}` reference cannot change the value a filter expression produces. Arithmetic, casting, and type coercion belong in the SQL expression around the variable, not in a Jinja filter.
971
+
960
972
  Common mistakes (the validator rejects these — listed here so authors don't hit them):
961
973
 
962
974
  - `options.values: [...]` → use `options.static: [...]`.
@@ -36,9 +36,10 @@ __all__ = [
36
36
  "DiagnosticCodeDetail",
37
37
  "DiagnosticCodesListResult",
38
38
  "DiagnosticCodeDetailResult",
39
+ "DiagnosticDisplay",
39
40
  "list_diagnostic_codes",
40
41
  "get_diagnostic_code",
41
- "doc_url_map",
42
+ "display_map",
42
43
  ]
43
44
 
44
45
  # ----- Args types ------------------------------------------------------------
@@ -77,6 +78,20 @@ class DiagnosticCodeSummary(BaseModel):
77
78
  model_config = ConfigDict(extra="forbid", frozen=True)
78
79
 
79
80
 
81
+ class DiagnosticDisplay(BaseModel):
82
+ """Registry-derived presentation data for one code, shipped per page.
83
+
84
+ Both fields are derived DiagnosticCode properties rather than serialized
85
+ Diagnostic fields, so a display surface resolves them from the code it
86
+ already has instead of receiving them on every payload.
87
+ """
88
+
89
+ title: str
90
+ doc_url: str
91
+
92
+ model_config = ConfigDict(extra="forbid", frozen=True)
93
+
94
+
80
95
  class DiagnosticCodeDetail(BaseModel):
81
96
  """Full details for a single diagnostic code."""
82
97
 
@@ -207,11 +222,14 @@ def get_diagnostic_code(code: str) -> DiagnosticCodeDetailResult:
207
222
  )
208
223
 
209
224
 
210
- def doc_url_map() -> dict[str, str]:
211
- """The registry's code -> doc_url map, built once for embedding alongside a page.
225
+ def display_map() -> dict[str, DiagnosticDisplay]:
226
+ """The registry's code -> display map, built once for embedding alongside a page.
212
227
 
213
- doc_url is a derived DiagnosticCode property, never an authored/serialized
214
- field — Cloud and Playground each ship this map once rather than
215
- re-deriving or hand-formatting a URL per diagnostic.
228
+ `title` and `doc_url` are derived DiagnosticCode properties, never
229
+ authored/serialized Diagnostic fields — Cloud and Playground each ship this
230
+ map once rather than re-deriving a URL or restating a title per diagnostic.
216
231
  """
217
- return {dc.code: dc.doc_url for dc in REGISTRY.all()}
232
+ return {
233
+ dc.code: DiagnosticDisplay(title=dc.title, doc_url=dc.doc_url)
234
+ for dc in REGISTRY.all()
235
+ }
@@ -1177,13 +1177,10 @@ Authored overlay for VariablesStyle. Variable controls chrome styling.
1177
1177
  | `label` | [VariablesLabelStyle](#variableslabelstyle) | ✓ | Variable label typography. |
1178
1178
  | `value` | [VariablesValueStyle](#variablesvaluestyle) | ✓ | Variable value typography. |
1179
1179
  | `placeholder` | [VariablesPlaceholderStyle](#variablesplaceholderstyle) | ✓ | Style for unselected/hint text in variable inputs. |
1180
- | `padding` | float | ✓ | Outer padding around the variables panel in pixels. |
1181
1180
  | `container_height` | float | ✓ | Height of the variables panel container in pixels. |
1182
- | `container_padding` | float | ✓ | Inner padding of the variables panel container in pixels. |
1183
1181
  | `border` | [BorderStyle](#borderstyle) | ✓ | Variables panel border style. |
1184
1182
  | `control_gap` | float | ✓ | Gap between label and input within a single control in pixels. |
1185
1183
  | `input` | [InputStyle](#inputstyle) | ✓ | Input control style. |
1186
- | `popover_rail_background` | str | ✓ | Background color for the preset rail (left column) inside the daterange chip's popover. Typically the theme's `surface-subtle` step — gray-50 on white-canvas themes, a cream-toned light on warm-canvas themes. Distinct from the popover card itself, which is always white-equivalent. |
1187
1184
 
1188
1185
  <a id="pagestyle"></a>
1189
1186
  ## PageStyle
@@ -1897,14 +1894,14 @@ Authored overlay for BlockMarginStyle. Top and bottom margin for a prose block r
1897
1894
 
1898
1895
  <a id="textcolumnstyle"></a>
1899
1896
  ## TextColumnStyle
1900
- Authored overlay for TextColumnStyle. Multi-column layout for face body text (CSS column-count/gap/rule/max_chars).
1897
+ Authored overlay for TextColumnStyle. Author overrides for the column layout of face body text.
1901
1898
 
1902
1899
  | Field | Type | Optional | Description |
1903
1900
  |-------|------|:--------:|-------------|
1904
- | `number` | int | ✓ | Fixed column count. None = single column. |
1905
- | `gap` | float | ✓ | Gap between columns in pixels. None = use layout gap token. |
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. |
1902
+ | `gap` | float | ✓ | Gap between columns in pixels. None = 1.5 line boxes, so the gutter scales with the type it separates. |
1906
1903
  | `rule` | str | ✓ | CSS column-rule shorthand, e.g. '1px solid #e5e7eb'. None = no rule. |
1907
- | `max_chars` | int | ✓ | Target column width as a character count. The pixel width is derived at render time via fonttools OS/2.xAvgCharWidth × max_chars; column count auto-derives as ceil(available / col_width_px). |
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. |
1908
1905
 
1909
1906
  <a id="textcodestyle"></a>
1910
1907
  ## TextCodeStyle
@@ -2076,7 +2073,6 @@ Authored overlay for VariablesValueStyle. Per-value font substyle for variable c
2076
2073
  | Field | Type | Optional | Description |
2077
2074
  |-------|------|:--------:|-------------|
2078
2075
  | `font` | [FontStyle](#fontstyle) | ✓ | Variable value font style overrides. Unset fields fall back to [`style.variables.font`](#variablesstyle). |
2079
- | `numeric_variant` | enum: "normal", "tabular-nums" | ✓ | OpenType numeric feature for value displays ('normal' or 'tabular-nums'). |
2080
2076
 
2081
2077
  <a id="variablesplaceholderstyle"></a>
2082
2078
  ## VariablesPlaceholderStyle
@@ -2339,7 +2335,6 @@ Authored overlay for ScaleContinuousStyle. Continuous-scale-only config: type, d
2339
2335
  | `zero` | bool \| enum: "auto" | ✓ | Force scale zero-baseline: True/False pins it; "auto" runs the Dataface smart-zero heuristic; None passes through to Vega-Lite. |
2340
2336
  | `type` | enum: "linear", "log", "pow", "sqrt", "symlog", "temporal" | ✓ | Scale type override. 'linear'/'log'/'pow'/'sqrt'/'symlog' pass straight through to Vega-Lite's quantitative scale.type. 'temporal' is a Dataface escape hatch for a cartesian x-axis: it forces a continuous temporal scale instead of the auto-inferred ordinal/nominal bucketed type, which is required before an authored ``domain`` of ISO dates can extend the visible range past the data extent. None lets Dataface/Vega-Lite infer from the field type. |
2341
2337
  | `domain` | list[Any] | ✓ | Explicit [low, high] scale domain; None lets Vega-Lite auto-determine from data. Must have exactly 2 elements — Dataface only wires a continuous range override through to Vega-Lite, not an explicit category enumeration. When ``type: temporal`` is set, both elements must be ISO-8601 date/datetime strings. |
2342
- | `domain_strategy` | enum: "stable", "auto" | ✓ | How the numeric domain responds to filter-variable changes when no explicit `domain` is authored. 'stable' (the default when None) pins the domain to the chart query's full unfiltered extent — the same value lands at the same pixel across variable changes. 'auto' rescales the domain to the current filtered subset on every variable change. An explicit `domain` always wins over either strategy. |
2343
2338
  | `log` | [ScaleLogStyle](#scalelogstyle) | ✓ | Log-scale param (base); only meaningful with type: log. |
2344
2339
  | `pow` | [ScalePowStyle](#scalepowstyle) | ✓ | Power-scale param (exponent); only meaningful with type: pow. |
2345
2340
  | `symlog` | [ScaleSymlogStyle](#scalesymlogstyle) | ✓ | Symlog-scale param (constant); only meaningful with type: symlog. |
@@ -153,7 +153,7 @@ in queries.
153
153
  dft serve
154
154
  ```
155
155
 
156
- Visit a landing page (e.g. `http://localhost:8080/zendesk/`) and click one of
156
+ Visit a landing page (e.g. `http://localhost:8001/zendesk/`) and click one of
157
157
  the data links. It should redirect to the authored face. If it serves the
158
158
  generic system view instead, the alias is not set — check the face file.
159
159
 
@@ -15,6 +15,7 @@ not from a caller-supplied flag.
15
15
  from __future__ import annotations
16
16
 
17
17
  import json
18
+ import sys
18
19
  from pathlib import Path
19
20
 
20
21
  from rich.markup import escape
@@ -50,25 +51,28 @@ def print_error(message: str) -> None:
50
51
  console.print(f"[bold red]Error:[/] {message}")
51
52
 
52
53
 
54
+ def emit_diagnostics_jsonl(diags: list[Diagnostic]) -> None:
55
+ """Write diagnostics as JSON Lines to stderr — one compact JSON object per line.
56
+
57
+ Callers use this for the ``--diagnostics-json`` path: every diagnostic
58
+ (error or warning) is emitted to stderr so stdout stays the render payload,
59
+ and line-delimited JSON survives a truncated read with whole objects intact.
60
+ """
61
+ for d in diags:
62
+ print(json.dumps(d.model_dump(exclude_none=True)), file=sys.stderr)
63
+
64
+
53
65
  def print_diagnostics(
54
66
  diags: list[Diagnostic],
55
67
  *,
56
- json_output: bool = False,
57
68
  path: Path | None = None,
58
69
  ) -> None:
59
- """Print Diagnostics as Rich panels (stderr) or JSON (stdout).
70
+ """Print Diagnostics as Rich panels to stderr.
60
71
 
61
72
  `path` is a fallback "At:" location used only when a diagnostic has no
62
73
  `range` of its own (e.g. compile-time warnings that don't carry position
63
74
  data yet).
64
75
  """
65
- if json_output:
66
- print(
67
- json.dumps(
68
- {"errors": [d.model_dump(exclude_none=True) for d in diags]}, indent=2
69
- )
70
- )
71
- return
72
76
  console = dft_console(stderr=True)
73
77
  for d in diags:
74
78
  style = "red" if d.level == "error" else "yellow"
@@ -10,7 +10,7 @@ from typing import TYPE_CHECKING
10
10
  import typer
11
11
 
12
12
  from dataface.agent_api import Diagnostic, RenderFormat
13
- from dataface.cli._error_format import print_diagnostics
13
+ from dataface.cli._error_format import emit_diagnostics_jsonl, print_diagnostics
14
14
  from dataface.cli._project import with_project
15
15
  from dataface.cli.filesystem_project import FilesystemProject
16
16
 
@@ -119,6 +119,7 @@ def _emit_result(
119
119
  no_warnings: bool,
120
120
  fail_on_chart_errors: bool,
121
121
  print0: bool,
122
+ diagnostics_json: bool = False,
122
123
  ) -> str | None:
123
124
  """Turn a completed render into terminal/file output, or raise RenderFailed.
124
125
 
@@ -136,7 +137,11 @@ def _emit_result(
136
137
  if fail_on_chart_errors and result.chart_errors:
137
138
  raise RenderFailed(result.chart_errors)
138
139
 
139
- if not no_warnings:
140
+ if diagnostics_json:
141
+ all_diags = list(result.warnings) + list(result.chart_errors)
142
+ if all_diags:
143
+ emit_diagnostics_jsonl(all_diags)
144
+ elif not no_warnings:
140
145
  _print_warnings(result.warnings)
141
146
 
142
147
  if format in ("json", "data"):
@@ -173,6 +178,7 @@ def render_command(
173
178
  max_workers: int | None = None,
174
179
  print0: bool = False,
175
180
  chart: str | None = None,
181
+ diagnostics_json: bool = False,
176
182
  *,
177
183
  project: FilesystemProject,
178
184
  ) -> str | None:
@@ -258,6 +264,7 @@ def render_command(
258
264
  no_warnings=no_warnings,
259
265
  fail_on_chart_errors=fail_on_chart_errors,
260
266
  print0=print0,
267
+ diagnostics_json=diagnostics_json,
261
268
  )
262
269
 
263
270
 
@@ -269,7 +276,7 @@ def render_command_from_yaml(
269
276
  variables: dict[str, str] | None = None,
270
277
  use_cache: bool = True,
271
278
  cache_path: Path | None = None,
272
- json_errors: bool = False,
279
+ diagnostics_json: bool = False,
273
280
  no_warnings: bool = False,
274
281
  ignore_codes: set[str] | None = None,
275
282
  fail_on_chart_errors: bool = True,
@@ -296,7 +303,8 @@ def render_command_from_yaml(
296
303
  if absent), overriding project config. None (the default) falls
297
304
  through to the project's dataface.yml cache: block (path: null
298
305
  there means the zero-config in-memory default).
299
- json_errors: Emit errors as JSON to stdout instead of Rich panels to stderr
306
+ diagnostics_json: Emit all diagnostics (errors and warnings) as JSON Lines
307
+ to stderr. Stdout stays the render payload on success.
300
308
  no_warnings: Suppress stderr warning output (warnings still appear in JSON)
301
309
  ignore_codes: Warning codes to suppress via the partition seam
302
310
  fail_on_chart_errors: Exit 1 when any per-chart runtime error exists
@@ -354,9 +362,14 @@ def render_command_from_yaml(
354
362
  no_warnings=no_warnings,
355
363
  fail_on_chart_errors=fail_on_chart_errors,
356
364
  print0=print0,
365
+ diagnostics_json=diagnostics_json,
357
366
  )
358
367
  except RenderFailed as exc:
359
- print_diagnostics(exc.errors, json_output=json_errors)
368
+ if exc.errors:
369
+ if diagnostics_json:
370
+ emit_diagnostics_jsonl(exc.errors)
371
+ else:
372
+ print_diagnostics(exc.errors)
360
373
  raise typer.Exit(1) from exc
361
374
 
362
375
 
@@ -436,7 +449,7 @@ def render_commands(
436
449
  variables: dict[str, str] | None = None,
437
450
  use_cache: bool = True,
438
451
  cache_path: Path | None = None,
439
- json_errors: bool = False,
452
+ diagnostics_json: bool = False,
440
453
  no_warnings: bool = False,
441
454
  ignore_codes: set[str] | None = None,
442
455
  fail_on_chart_errors: bool = True,
@@ -491,9 +504,14 @@ def render_commands(
491
504
  max_workers=max_workers,
492
505
  print0=print0,
493
506
  chart=chart,
507
+ diagnostics_json=diagnostics_json,
494
508
  )
495
509
  except RenderFailed as exc:
496
- print_diagnostics(exc.errors, json_output=json_errors)
510
+ if exc.errors:
511
+ if diagnostics_json:
512
+ emit_diagnostics_jsonl(exc.errors)
513
+ else:
514
+ print_diagnostics(exc.errors)
497
515
  has_failure = True
498
516
  if fail_fast:
499
517
  raise typer.Exit(1) from exc
dataface/cli/main.py CHANGED
@@ -718,14 +718,14 @@ def render(
718
718
  ),
719
719
  ),
720
720
  ] = None,
721
- json_errors: Annotated[
721
+ diagnostics_json: Annotated[
722
722
  bool,
723
723
  typer.Option(
724
- "--json-errors",
724
+ "--diagnostics-json",
725
725
  help=(
726
- "Emit errors as JSON to stdout instead of formatted panels. "
727
- "Only affects the error path; the success-path output is "
728
- "still controlled by --format."
726
+ "Emit all diagnostics (errors and warnings) as JSON Lines to stderr. "
727
+ "Stdout stays the render payload on success; stderr carries "
728
+ "one compact JSON object per diagnostic, line-delimited."
729
729
  ),
730
730
  ),
731
731
  ] = False,
@@ -830,7 +830,7 @@ def render(
830
830
  dft render faces/sales.yml --format terminal
831
831
  dft render faces/sales.yml --no-cache
832
832
  dft render faces/sales.yml --cache renders.duckdb
833
- dft render faces/sales.yml --json-errors
833
+ dft render faces/sales.yml --diagnostics-json
834
834
  dft render faces/sales.yml --no-warnings
835
835
  dft render faces/sales.yml --ignore-warning WARN-REDUNDANT-ENCODING
836
836
  dft render faces/sales.yml --allow-chart-errors
@@ -891,7 +891,7 @@ def render(
891
891
  variables=variables or None,
892
892
  use_cache=use_cache,
893
893
  cache_path=cache,
894
- json_errors=json_errors,
894
+ diagnostics_json=diagnostics_json,
895
895
  no_warnings=no_warnings,
896
896
  ignore_codes=ignore_codes,
897
897
  fail_on_chart_errors=fail_on_chart_errors,
@@ -909,7 +909,7 @@ def render(
909
909
  variables=variables or None,
910
910
  use_cache=use_cache,
911
911
  cache_path=cache,
912
- json_errors=json_errors,
912
+ diagnostics_json=diagnostics_json,
913
913
  no_warnings=no_warnings,
914
914
  ignore_codes=ignore_codes,
915
915
  fail_on_chart_errors=fail_on_chart_errors,
@@ -42,6 +42,7 @@ from dataface.core.compile.models.face.normalized import (
42
42
  Layout,
43
43
  LayoutItem,
44
44
  )
45
+ from dataface.core.compile.sizing import chart_slot_width
45
46
 
46
47
 
47
48
  def focus_on_chart(face: Face, chart_id: str) -> Face:
@@ -50,6 +51,9 @@ def focus_on_chart(face: Face, chart_id: str) -> Face:
50
51
  Creates a new face with:
51
52
  - Only the specified chart in the layout
52
53
  - Only the variables that chart depends on
54
+ - The chart pinned at the slot width the dashboard layout assigned it,
55
+ so the focused render is geometry-identical to the dashboard (WYSIWYG);
56
+ a chart not placed in the layout keeps its preferred width
53
57
  - All sources and queries unchanged (they're lazy-loaded anyway)
54
58
 
55
59
  This is called automatically when `chart_focus` is set in the YAML.
@@ -88,8 +92,15 @@ def focus_on_chart(face: Face, chart_id: str) -> Face:
88
92
  if name in chart.variable_dependencies
89
93
  }
90
94
 
91
- # Create a simple layout with just this chart
92
- chart_item = LayoutItem(type="chart", chart=chart)
95
+ # Pin the dashboard slot width so the focused chart keeps the exact
96
+ # geometry it had on the board (band widths, axis-label fit). The board
97
+ # container hugs the pinned width the same way it hugs preferred widths.
98
+ slot_width = chart_slot_width(face, chart_id)
99
+ chart_item = LayoutItem(
100
+ type="chart",
101
+ chart=chart,
102
+ user_width=None if slot_width is None else f"{slot_width}px",
103
+ )
93
104
  simple_layout = Layout(type=LayoutType.ROWS, items=[chart_item])
94
105
 
95
106
  # Create new face with focused content
@@ -36,13 +36,10 @@ Usage:
36
36
  )
37
37
  """
38
38
 
39
- import logging
40
39
  from typing import Any
41
40
 
42
41
  import yaml
43
42
 
44
- logger = logging.getLogger(__name__)
45
-
46
43
 
47
44
  def update_chart_in_yaml(
48
45
  yaml_content: str,
@@ -147,6 +144,25 @@ def update_chart_in_yaml(
147
144
  if field in edits and edits[field]:
148
145
  chart_def[field] = edits[field]
149
146
 
147
+ # Validate rewritability before any mutation.
148
+ # A rows/cols source_path must be exactly two segments (layout_key.N) where N
149
+ # is a digit — that is the only shape _update_layout_reference can rewrite.
150
+ # Deeper paths (rows.0.cols.1) name a chart inside a nested face; the nested
151
+ # face's own file must name the chart instead.
152
+ if source_path and source_path.split(".")[0] in ("rows", "cols"):
153
+ _segs = source_path.split(".")
154
+ if len(_segs) != 2:
155
+ raise ValueError(
156
+ f"Cannot promote inline chart at '{source_path}': only top-level "
157
+ "layout positions (rows.N / cols.N) are supported; name the chart "
158
+ "in the nested face's own file instead"
159
+ )
160
+ if not _segs[1].isdigit():
161
+ raise ValueError(
162
+ f"Cannot promote inline chart at '{source_path}': "
163
+ f"'{_segs[1]}' is not a valid integer index"
164
+ )
165
+
150
166
  # Update the charts section
151
167
  # If chart already exists, merge with existing values
152
168
  if chart_id in data["charts"]:
@@ -171,31 +187,19 @@ def update_chart_in_yaml(
171
187
  def _update_layout_reference(
172
188
  data: dict[str, Any], source_path: str, chart_id: str
173
189
  ) -> None:
174
- """Update a layout position to reference a named chart instead of inline.
190
+ """Replace an inline chart at a top-level layout position with a named reference.
175
191
 
176
192
  Args:
177
193
  data: Parsed YAML data (modified in place)
178
- source_path: Path like "rows.0" or "rows.0.cols.1"
194
+ source_path: Validated path of the form "rows.N" or "cols.N".
195
+ Caller guarantees exactly two segments and a digit index.
179
196
  chart_id: ID of the chart to reference
180
197
  """
181
- # Only a top-level position is rewritable here: this replaces an entry in
182
- # `data[layout_key]`, and a deeper path (`rows.0.cols.1`) names an entry in a
183
- # nested face's own list, which this function never walks into.
184
- segments = source_path.split(".")
185
- if len(segments) != 2 or segments[0] not in ("rows", "cols"):
186
- logger.debug(f"Unrecognized source_path format: {source_path}")
187
- return
188
- layout_key, raw_index = segments
189
- if not raw_index.isdigit():
190
- logger.warning(f"Failed to parse index from source_path: {source_path}")
191
- return
198
+ layout_key, raw_index = source_path.split(".")
192
199
  index = int(raw_index)
193
-
194
- # Update the layout
195
200
  if (
196
201
  layout_key in data
197
202
  and isinstance(data[layout_key], list)
198
203
  and 0 <= index < len(data[layout_key])
199
204
  ):
200
- # Replace inline chart with named reference
201
205
  data[layout_key][index] = chart_id
@@ -48,6 +48,7 @@ from jinja2 import (
48
48
 
49
49
  from dataface.core.compile._jinja_helpers import _LenientUndefined, _QueryNamespace
50
50
  from dataface.core.compile.errors import CompilationError, JinjaError
51
+ from dataface.core.compile.parameterized import _LiteralCollector, _ParameterizedValue
51
52
 
52
53
  logger = logging.getLogger(__name__)
53
54
 
@@ -554,10 +555,25 @@ def resolve_query_sql_with_dependencies(
554
555
  # Phase 2: resolve variables via Jinja. filter()/filter_date_range() calls
555
556
  # are deferred (left verbatim) so they reach the SQL adapter's parameterized
556
557
  # pass instead of the raising compile-time stub — see resolve_jinja_template.
558
+ #
559
+ # Variables are wrapped in _ParameterizedValue with a _LiteralCollector so
560
+ # that {{ n }} still inlines as a literal (the reference-path contract) while
561
+ # {{ n | int }} / {{ n | float }} raise — matching the direct-path behavior
562
+ # and preventing silent value changes when {{ queries.X }} refs are added or
563
+ # removed. Using the same wrapper class as the direct path ensures equality,
564
+ # membership, and iteration operators delegate correctly to the raw value.
557
565
  if target_sql and ("{{" in target_sql or "{%" in target_sql):
558
- target_sql = resolve_jinja_template(
559
- target_sql, variables=variables, defer_filters=True
560
- )
566
+ _collector = _LiteralCollector()
567
+ guarded_vars = {
568
+ name: _ParameterizedValue(name, val, _collector)
569
+ for name, val in (variables.items() if variables else ())
570
+ }
571
+ try:
572
+ target_sql = resolve_jinja_template(
573
+ target_sql, variables=guarded_vars, defer_filters=True
574
+ )
575
+ except (TypeError, KeyError) as e:
576
+ raise JinjaError(f"Template error: {e}", target_sql) from e
561
577
 
562
578
  return target_sql
563
579
 
@@ -8,10 +8,11 @@ leaf move creates its destination container on demand, and the later
8
8
  container move must find that destination still empty (or absent) to avoid
9
9
  a spurious MigrationConflictError against an unrelated field that also sets
10
10
  the old container. See dataface/AGENTS.md's "Two validation boundaries" —
11
- these are structural moves; categorical_orient (deleted), ticks.interval
12
- (reinterpreted vocabulary, not renamed), and the band/point/quantize scale
13
- groups (deleted) are semantic changes with no lossless move and are left to
14
- raise UnsupportedSchemaError for manual migration.
11
+ these are structural moves; categorical_orient (deleted), the stable-axis
12
+ domain-strategy key (deleted), ticks.interval (reinterpreted vocabulary, not
13
+ renamed), and the band/point/quantize scale groups (deleted) are semantic
14
+ changes with no lossless move and are left to raise UnsupportedSchemaError for
15
+ manual migration.
15
16
  """
16
17
 
17
18
  from __future__ import annotations
@@ -29,7 +30,6 @@ RENAMES: tuple[tuple[YamlKeyPath, YamlKeyPath], ...] = (
29
30
  (("scale", "continuous", "domain"), ("scale", "domain")),
30
31
  (("scale", "continuous", "zero"), ("scale", "zero")),
31
32
  (("scale", "continuous", "type"), ("scale", "type")),
32
- (("scale", "continuous", "domain_strategy"), ("scale", "domain_strategy")),
33
33
  (("scale", "continuous", "log", "base"), ("scale", "base")),
34
34
  (("scale", "continuous", "pow", "exponent"), ("scale", "exponent")),
35
35
  (("scale", "continuous", "symlog", "constant"), ("scale", "constant")),
@@ -64,7 +64,6 @@ from ._conditional_formatting import (
64
64
  ConditionalRule,
65
65
  FieldConditionalFormatting,
66
66
  _PredicateBase,
67
- coerce_numeric,
68
67
  match_predicate,
69
68
  )
70
69
  from ._data_table import (
@@ -211,7 +210,6 @@ __all__ = [
211
210
  "FieldConditionalFormatting",
212
211
  "_PREDICATE_OPS",
213
212
  "_PredicateBase",
214
- "coerce_numeric",
215
213
  "match_predicate",
216
214
  # data_table
217
215
  "CHART_DATA_TABLE_SUPPORTED_TYPES",
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import math
6
5
  from typing import Any, Literal
7
6
 
8
7
  from pydantic import (
@@ -20,6 +19,7 @@ from dataface.core.compile.models.style.theme import (
20
19
  VALID_FONT_WEIGHTS,
21
20
  font_weight_as_css,
22
21
  )
22
+ from dataface.core.utils import coerce_numeric_cell
23
23
 
24
24
 
25
25
  class _PredicateBase(BaseModel):
@@ -120,22 +120,6 @@ def _validate_exactly_one_predicate(obj: Any) -> None:
120
120
  )
121
121
 
122
122
 
123
- def coerce_numeric(v: Any) -> float | None:
124
- """Return float(v) for finite numeric-compatible values; None otherwise."""
125
- if v is None or isinstance(v, bool):
126
- return None
127
- if isinstance(v, (int, float)):
128
- result = float(v)
129
- return result if math.isfinite(result) else None
130
- if isinstance(v, str):
131
- try:
132
- result = float(v)
133
- return result if math.isfinite(result) else None
134
- except (ValueError, TypeError):
135
- return None
136
- return None
137
-
138
-
139
123
  def _in_value_matches(candidate: Any, value: Any) -> bool:
140
124
  """Equality check for a single ``in`` candidate, with the bool/int guard."""
141
125
  if isinstance(value, bool) != isinstance(candidate, bool):
@@ -159,7 +143,7 @@ def match_predicate(rule: _PredicateBase, value: Any) -> bool:
159
143
  return value != rule.ne
160
144
  if rule.in_ is not None:
161
145
  return any(_in_value_matches(candidate, value) for candidate in rule.in_)
162
- v_coerced = coerce_numeric(value)
146
+ v_coerced = coerce_numeric_cell(value)
163
147
  if v_coerced is None:
164
148
  return False
165
149
  v = v_coerced
@@ -162,6 +162,22 @@ 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
+
165
181
  class DataTableConfig(ConfigNode):
166
182
  divider_gap: float
167
183
  chart_data_table_max_x_ticks: int
@@ -174,6 +190,7 @@ class ChartRenderingConfig(ConfigNode):
174
190
  bar: BarConfig
175
191
  type_inference: TypeInferenceConfig
176
192
  board: BoardConfig
193
+ prose: ProseConfig
177
194
  data_table: DataTableConfig
178
195
  stroke: StrokeConfig
179
196
  axis: AxisConfig
@@ -201,7 +201,6 @@ class ResolvedScaleContinuousStyle(BaseModel):
201
201
  zero: bool | Literal["auto"] | None = None
202
202
  type: Literal["linear", "log", "pow", "sqrt", "symlog", "temporal"] | None = None
203
203
  domain: list[Any] | None = None
204
- domain_strategy: Literal["stable", "auto"] | None = None
205
204
  log: ResolvedScaleLogStyle | None = None
206
205
  pow: ResolvedScalePowStyle | None = None
207
206
  symlog: ResolvedScaleSymlogStyle | None = None
@@ -572,7 +572,6 @@ def build_resolved_scale(
572
572
  zero=scale.continuous.zero,
573
573
  type=scale.continuous.type,
574
574
  domain=scale.continuous.domain,
575
- domain_strategy=scale.continuous.domain_strategy,
576
575
  log=(
577
576
  ResolvedScaleLogStyle(base=scale.continuous.log.base)
578
577
  if scale.continuous.log is not None
@@ -104,7 +104,6 @@ from dataface.core.compile.models.style.theme.board import (
104
104
  TitleWidthOffsetsStyle,
105
105
  _normalize_overflow_value,
106
106
  coerce_gap,
107
- compute_text_column_count,
108
107
  font_weight_as_css,
109
108
  )
110
109
  from dataface.core.compile.models.style.theme.callout import (
@@ -414,6 +413,5 @@ __all__ = [
414
413
  "_RadialChartStyle",
415
414
  "_normalize_overflow_value",
416
415
  "coerce_gap",
417
- "compute_text_column_count",
418
416
  "font_weight_as_css",
419
417
  ]
@@ -585,17 +585,6 @@ class ScaleContinuousStyle(ScaleDomainValidationMixin):
585
585
  "date/datetime strings."
586
586
  ),
587
587
  )
588
- domain_strategy: Literal["stable", "auto"] | None = Field(
589
- default=None,
590
- description=(
591
- "How the numeric domain responds to filter-variable changes when no "
592
- "explicit `domain` is authored. 'stable' (the default when None) pins "
593
- "the domain to the chart query's full unfiltered extent — the same "
594
- "value lands at the same pixel across variable changes. 'auto' "
595
- "rescales the domain to the current filtered subset on every variable "
596
- "change. An explicit `domain` always wins over either strategy."
597
- ),
598
- )
599
588
  log: ScaleLogStyle | None = Field(
600
589
  default=None,
601
590
  description="Log-scale param (base); only meaningful with type: log.",