dataface 0.4.1.dev5__py3-none-any.whl → 0.4.1.dev32__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 (58) hide show
  1. dataface/DATAFACE_SYNTAX.md +12 -0
  2. dataface/agent_api/docs/yaml-reference.md +0 -4
  3. dataface/ai/skills/dashboard-pack-scaffolding/SKILL.md +1 -1
  4. dataface/cli/_error_format.py +13 -9
  5. dataface/cli/commands/render.py +25 -7
  6. dataface/cli/main.py +8 -8
  7. dataface/core/compile/chart_focus.py +13 -2
  8. dataface/core/compile/jinja.py +19 -3
  9. dataface/core/compile/models/chart/authored/__init__.py +0 -2
  10. dataface/core/compile/models/chart/authored/_conditional_formatting.py +2 -18
  11. dataface/core/compile/models/style/theme/variables.py +0 -19
  12. dataface/core/compile/parameterized.py +59 -1
  13. dataface/core/compile/sizing.py +239 -5
  14. dataface/core/dashboard.py +1 -6
  15. dataface/core/defaults/themes/_base.yaml +0 -7
  16. dataface/core/defaults/themes/cream.yaml +0 -3
  17. dataface/core/defaults/themes/neon.yaml +0 -1
  18. dataface/core/diagnostics/codes_compile.py +5 -0
  19. dataface/core/diagnostics/codes_render.py +1 -0
  20. dataface/core/diagnostics/registry.py +6 -0
  21. dataface/core/render/__init__.py +0 -2
  22. dataface/core/render/chart/kpi.py +17 -13
  23. dataface/core/render/chart/rendering.py +7 -7
  24. dataface/core/render/chart/table.py +4 -2
  25. dataface/core/render/chart/table_support.py +3 -3
  26. dataface/core/render/controls.py +26 -476
  27. dataface/core/render/converters/chart.py +20 -0
  28. dataface/core/render/converters/html.py +5 -8
  29. dataface/core/render/faces.py +45 -35
  30. dataface/core/render/layout_sizing.py +2 -19
  31. dataface/core/render/render_result.py +0 -3
  32. dataface/core/render/renderer.py +7 -25
  33. dataface/core/render/sizing.py +21 -152
  34. dataface/core/render/svg_utils.py +19 -6
  35. dataface/core/render/templates/controls/_styles.css +40 -658
  36. dataface/core/render/templates/page.html +1 -2
  37. dataface/core/render/templates/scripts/variables.js +582 -1129
  38. dataface/core/render/templates/svg/styles.css +29 -6
  39. dataface/core/render/variables_resolve.py +59 -6
  40. dataface/core/render/variables_strip.py +113 -9
  41. dataface/core/render/warnings/layered_chart_shared_y_axis_scale_mismatch.py +2 -1
  42. dataface/core/utils.py +7 -3
  43. dataface/data/schemas/board-resolved/board-resolved.schema.json +1 -31
  44. {dataface-0.4.1.dev5.dist-info → dataface-0.4.1.dev32.dist-info}/METADATA +1 -2
  45. {dataface-0.4.1.dev5.dist-info → dataface-0.4.1.dev32.dist-info}/RECORD +48 -58
  46. dataface/core/render/templates/variable_controls/checkbox.html +0 -6
  47. dataface/core/render/templates/variable_controls/date.html +0 -6
  48. dataface/core/render/templates/variable_controls/daterange.html +0 -16
  49. dataface/core/render/templates/variable_controls/layer.html +0 -28
  50. dataface/core/render/templates/variable_controls/multiselect.html +0 -19
  51. dataface/core/render/templates/variable_controls/number.html +0 -6
  52. dataface/core/render/templates/variable_controls/readonly.html +0 -4
  53. dataface/core/render/templates/variable_controls/select.html +0 -13
  54. dataface/core/render/templates/variable_controls/slider.html +0 -9
  55. dataface/core/render/templates/variable_controls/text.html +0 -6
  56. {dataface-0.4.1.dev5.dist-info → dataface-0.4.1.dev32.dist-info}/WHEEL +0 -0
  57. {dataface-0.4.1.dev5.dist-info → dataface-0.4.1.dev32.dist-info}/entry_points.txt +0 -0
  58. {dataface-0.4.1.dev5.dist-info → dataface-0.4.1.dev32.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: [...]`.
@@ -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
@@ -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
@@ -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
@@ -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
 
@@ -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
@@ -40,10 +40,6 @@ class VariablesValueStyle(BaseModel):
40
40
  default_factory=FontStyle,
41
41
  description="Variable value font style overrides.",
42
42
  )
43
- # OpenType numeric feature for slider, number, and date-range value displays.
44
- numeric_variant: Literal["normal", "tabular-nums"] = Field(
45
- description="OpenType numeric feature for value displays ('normal' or 'tabular-nums')."
46
- )
47
43
 
48
44
 
49
45
  class VariablesPlaceholderStyle(BaseModel):
@@ -107,26 +103,11 @@ class VariablesStyle(BaseModel):
107
103
  placeholder: VariablesPlaceholderStyle = Field(
108
104
  description="Style for unselected/hint text in variable inputs."
109
105
  )
110
- padding: float = Field(
111
- description="Outer padding around the variables panel in pixels."
112
- )
113
106
  container_height: float = Field(
114
107
  description="Height of the variables panel container in pixels."
115
108
  )
116
- container_padding: float = Field(
117
- description="Inner padding of the variables panel container in pixels."
118
- )
119
109
  border: BorderStyle = Field(description="Variables panel border style.")
120
110
  control_gap: float = Field(
121
111
  description="Gap between label and input within a single control in pixels."
122
112
  )
123
113
  input: InputStyle = Field(description="Input control style.")
124
- popover_rail_background: str = Field(
125
- description=(
126
- "Background color for the preset rail (left column) inside the "
127
- "daterange chip's popover. Typically the theme's `surface-subtle` "
128
- "step — gray-50 on white-canvas themes, a cream-toned light on "
129
- "warm-canvas themes. Distinct from the popover card itself, which "
130
- "is always white-equivalent."
131
- )
132
- )
@@ -209,13 +209,27 @@ class _NullValue:
209
209
  return not self.__eq__(other)
210
210
 
211
211
 
212
+ class _LiteralCollector:
213
+ """Passthrough collector for _ParameterizedValue on the Phase-2 composed-query path.
214
+
215
+ Variables on this path inline as literal SQL (the reference-path contract): {{ n }}
216
+ becomes the raw value, not a $N parameter. Returning "NULL" for None matches
217
+ the _NullValue behavior on the direct parameterized path.
218
+ """
219
+
220
+ def get_param(self, name: str, value: Any) -> str:
221
+ return "NULL" if value is None else str(value)
222
+
223
+
212
224
  class _ParameterizedValue:
213
225
  """Wrapper for parameterized variable values.
214
226
 
215
227
  When rendered by Jinja, returns the parameter placeholder.
216
228
  """
217
229
 
218
- def __init__(self, name: str, value: Any, collector: _ParameterCollector):
230
+ def __init__(
231
+ self, name: str, value: Any, collector: _ParameterCollector | _LiteralCollector
232
+ ):
219
233
  self._name = name
220
234
  self._value = value
221
235
  self._collector = collector
@@ -247,6 +261,50 @@ class _ParameterizedValue:
247
261
  return self._value[key]
248
262
  raise TypeError(f"'{type(self._value).__name__}' is not subscriptable")
249
263
 
264
+ def __int__(self) -> int:
265
+ # Jinja's | int filter calls int(value) and catches TypeError, returning
266
+ # 0 on failure — so without this override the filter silently emits 0.
267
+ # Raising here bypasses that swallowing and surfaces the error.
268
+ if isinstance(self._collector, _LiteralCollector):
269
+ raise JinjaError(
270
+ f"Variable '{self._name}': the '| int' filter cannot coerce a face "
271
+ f"variable in a composed query — it would silently return 0. "
272
+ f"Use SQL arithmetic directly: {{{{ {self._name} }}}} - 1 inlines "
273
+ f"the value minus 1."
274
+ )
275
+ raise JinjaError(
276
+ f"Variable '{self._name}': the '| int' filter cannot coerce a parameterized "
277
+ f"variable — it would silently return 0. Use SQL arithmetic on the variable "
278
+ f"directly instead: {{{{ {self._name} }}}} - 1 emits $N - 1 where $N is "
279
+ f"the bound parameter (e.g. INTERVAL -({{{{ {self._name} }}}} - 1) MONTH)."
280
+ )
281
+
282
+ def __index__(self) -> int:
283
+ if isinstance(self._collector, _LiteralCollector):
284
+ raise JinjaError(
285
+ f"Variable '{self._name}': integer index conversion is not supported "
286
+ f"on face variables in a composed query."
287
+ )
288
+ raise JinjaError(
289
+ f"Variable '{self._name}': integer index conversion is not supported "
290
+ f"on parameterized variables. Use SQL arithmetic on the variable "
291
+ f"directly instead: {{{{ {self._name} }}}} - 1 emits the bound parameter "
292
+ f"minus 1."
293
+ )
294
+
295
+ def __float__(self) -> float:
296
+ if isinstance(self._collector, _LiteralCollector):
297
+ raise JinjaError(
298
+ f"Variable '{self._name}': the '| float' filter cannot coerce a face "
299
+ f"variable in a composed query — it would silently return 0.0. "
300
+ f"Use SQL arithmetic directly: {{{{ {self._name} }}}} * 1.0 or cast in SQL."
301
+ )
302
+ raise JinjaError(
303
+ f"Variable '{self._name}': the '| float' filter cannot coerce a parameterized "
304
+ f"variable — it would silently return 0.0. Use SQL arithmetic on the variable "
305
+ f"directly instead: {{{{ {self._name} }}}} * 1.0 or cast in SQL."
306
+ )
307
+
250
308
 
251
309
  def _compute_template_hash(template: str) -> str:
252
310
  """Compute hash of template for caching.