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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. dataface/__init__.py +70 -23
  2. dataface/agent_api/__init__.py +162 -68
  3. dataface/agent_api/_paths.py +9 -3
  4. dataface/agent_api/docs/yaml-reference.md +240 -106
  5. dataface/agent_api/init.py +1 -1
  6. dataface/agent_api/project_session.py +2 -1
  7. dataface/agent_api/serve.py +115 -0
  8. dataface/ai/agent.py +28 -2
  9. dataface/cli/_project.py +100 -1
  10. dataface/cli/commands/board_artifact.py +5 -2
  11. dataface/cli/commands/describe.py +7 -3
  12. dataface/cli/commands/docs.py +12 -3
  13. dataface/cli/commands/init.py +7 -14
  14. dataface/cli/commands/inspect.py +6 -1
  15. dataface/cli/commands/mcp_init.py +23 -43
  16. dataface/cli/commands/migrate.py +8 -3
  17. dataface/cli/commands/query.py +11 -8
  18. dataface/cli/commands/render.py +20 -9
  19. dataface/cli/commands/search.py +8 -2
  20. dataface/cli/commands/serve.py +26 -76
  21. dataface/cli/commands/skills_init.py +2 -19
  22. dataface/cli/commands/validate.py +7 -2
  23. dataface/cli/filesystem_project.py +5 -4
  24. dataface/cli/main.py +2 -19
  25. dataface/core/__init__.py +73 -28
  26. dataface/core/{compile/colors.py → colors.py} +20 -5
  27. dataface/core/compile/compiler.py +97 -34
  28. dataface/core/compile/data_table.py +7 -2
  29. dataface/core/compile/enrich.py +18 -44
  30. dataface/core/compile/face_warnings.py +46 -8
  31. dataface/core/compile/format.py +47 -1
  32. dataface/core/compile/inherit_graph.py +3 -3
  33. dataface/core/compile/inherit_registry.yaml +1 -1
  34. dataface/core/compile/migrations.py +112 -9
  35. dataface/core/compile/models/chart/normalized/kpi.py +0 -4
  36. dataface/core/compile/models/chart/resolved/_base.py +28 -10
  37. dataface/core/compile/models/chart/resolved/callout.py +2 -3
  38. dataface/core/compile/models/chart/resolved/kpi.py +7 -3
  39. dataface/core/compile/models/chart/resolved/table.py +16 -2
  40. dataface/core/compile/models/markers.py +1 -1
  41. dataface/core/compile/models/primitives.py +175 -2
  42. dataface/core/compile/models/source.py +5 -5
  43. dataface/core/compile/models/style/authored/__init__.py +32 -8
  44. dataface/core/compile/models/style/authored/_base.py +126 -69
  45. dataface/core/compile/models/style/authored/table.py +61 -3
  46. dataface/core/compile/models/style/resolved/__init__.py +12 -2
  47. dataface/core/compile/models/style/resolved/_base.py +142 -57
  48. dataface/core/compile/models/style/resolved/_cartesian.py +6 -11
  49. dataface/core/compile/models/style/resolved/_cascade.py +141 -54
  50. dataface/core/compile/models/style/resolved/area.py +1 -1
  51. dataface/core/compile/models/style/resolved/bar.py +1 -1
  52. dataface/core/compile/models/style/resolved/line.py +1 -1
  53. dataface/core/compile/models/style/resolved/scatter.py +3 -2
  54. dataface/core/compile/models/style/theme/__init__.py +36 -14
  55. dataface/core/compile/models/style/theme/_chart_base.py +17 -9
  56. dataface/core/compile/models/style/theme/axis.py +432 -312
  57. dataface/core/compile/models/style/theme/charts.py +10 -6
  58. dataface/core/compile/models/style/theme/kpi.py +2 -1
  59. dataface/core/compile/models/style/theme/page.py +5 -2
  60. dataface/core/compile/models/style/theme/table.py +2 -2
  61. dataface/core/compile/models/vega_lite/config.py +1 -1
  62. dataface/core/compile/normalize_charts.py +6 -2
  63. dataface/core/compile/normalize_queries.py +21 -7
  64. dataface/core/compile/resolve/__init__.py +2 -0
  65. dataface/core/compile/resolve/_axes.py +147 -44
  66. dataface/core/compile/resolve/_channels.py +1 -1
  67. dataface/core/compile/resolve/_dispatch.py +1 -1
  68. dataface/core/compile/resolve/_domain.py +49 -32
  69. dataface/core/compile/resolve/_kwargs.py +39 -12
  70. dataface/core/compile/resolve/_marks.py +8 -3
  71. dataface/core/compile/resolve/_palette.py +7 -3
  72. dataface/core/compile/resolve/_table.py +124 -33
  73. dataface/core/compile/resolve/area.py +48 -18
  74. dataface/core/compile/resolve/bar.py +245 -64
  75. dataface/core/compile/resolve/geo.py +10 -3
  76. dataface/core/compile/resolve/heatmap.py +16 -8
  77. dataface/core/compile/resolve/line.py +35 -12
  78. dataface/core/compile/resolve/pie.py +4 -1
  79. dataface/core/compile/resolve/pie_attachment.py +1 -1
  80. dataface/core/compile/resolve/scatter.py +25 -13
  81. dataface/core/compile/resolve/simple.py +25 -2
  82. dataface/core/compile/schema_renderers/prompt.py +1 -1
  83. dataface/core/compile/schema_renderers/textmate_grammar.py +8 -1
  84. dataface/core/compile/source_map.py +211 -28
  85. dataface/core/compile/style_cascade.py +126 -55
  86. dataface/core/compile/yaml_error_formatter.py +1 -1
  87. dataface/core/connections.py +10 -3
  88. dataface/core/dashboard.py +24 -18
  89. dataface/core/defaults/themes/_base.yaml +49 -39
  90. dataface/core/defaults/themes/cream.yaml +4 -4
  91. dataface/core/defaults/themes/diagnostics-title-baseline.yaml +1 -1
  92. dataface/core/defaults/themes/diagnostics-title-color-extreme.yaml +1 -1
  93. dataface/core/defaults/themes/diagnostics-title-font-extreme.yaml +1 -1
  94. dataface/core/defaults/themes/diagnostics-title-size-extreme.yaml +1 -1
  95. dataface/core/defaults/themes/diagnostics-title-weight-extreme.yaml +1 -1
  96. dataface/core/defaults/themes/editorial.yaml +6 -6
  97. dataface/core/defaults/themes/neon.yaml +15 -8
  98. dataface/core/defaults/themes/plain.yaml +6 -4
  99. dataface/core/defaults/themes/stark.yaml +16 -7
  100. dataface/core/defaults/themes/vivid.yaml +15 -8
  101. dataface/core/diagnostics/__init__.py +2 -2
  102. dataface/core/diagnostics/codes_compile.py +13 -31
  103. dataface/core/diagnostics/codes_execute.py +79 -0
  104. dataface/core/diagnostics/codes_render.py +87 -28
  105. dataface/core/diagnostics/diagnostic.py +30 -0
  106. dataface/core/diagnostics/hints.py +19 -0
  107. dataface/core/dialects/base.py +17 -0
  108. dataface/core/dialects/postgres.py +14 -0
  109. dataface/core/dialects/redshift.py +5 -0
  110. dataface/core/execute/__init__.py +59 -14
  111. dataface/core/execute/adapters/dbt_adapter.py +4 -1
  112. dataface/core/execute/adapters/dbt_adapter_factory.py +19 -0
  113. dataface/core/execute/adapters/dbt_utils.py +84 -20
  114. dataface/core/execute/adapters/postgres_connection_manager.py +48 -0
  115. dataface/core/execute/adapters/sql_adapter.py +72 -4
  116. dataface/core/execute/dbt_jinja.py +65 -10
  117. dataface/core/execute/executor.py +69 -16
  118. dataface/core/font_measure.py +28 -0
  119. dataface/core/fonts.py +8 -5
  120. dataface/core/inspect/__init__.py +46 -6
  121. dataface/core/project.py +13 -1
  122. dataface/core/render/chart/data_table_attachment.py +14 -12
  123. dataface/core/render/chart/emitters/_cartesian.py +21 -18
  124. dataface/core/render/chart/emitters/_channels.py +56 -8
  125. dataface/core/render/chart/emitters/_label_overlap.py +18 -15
  126. dataface/core/render/chart/emitters/_measured_label_padding.py +4 -4
  127. dataface/core/render/chart/emitters/area.py +3 -3
  128. dataface/core/render/chart/emitters/bar.py +60 -35
  129. dataface/core/render/chart/emitters/geo.py +8 -13
  130. dataface/core/render/chart/emitters/heatmap.py +20 -26
  131. dataface/core/render/chart/emitters/line.py +2 -2
  132. dataface/core/render/chart/emitters/scatter.py +7 -30
  133. dataface/core/render/chart/features/baseline.py +22 -9
  134. dataface/core/render/chart/features/endpoint_labels.py +55 -32
  135. dataface/core/render/chart/features/mirror_axis.py +12 -9
  136. dataface/core/render/chart/features/value_labels.py +1 -1
  137. dataface/core/render/chart/kpi.py +11 -90
  138. dataface/core/render/chart/presentation.py +6 -8
  139. dataface/core/render/chart/rendering.py +3 -12
  140. dataface/core/render/chart/session.py +10 -0
  141. dataface/core/render/chart/spec.py +3 -0
  142. dataface/core/render/chart/spec_builders.py +18 -18
  143. dataface/core/render/chart/table.py +81 -36
  144. dataface/core/render/chart/table_support.py +2 -53
  145. dataface/core/render/chart/time_unit_detect.py +3 -2
  146. dataface/core/render/chart/translate.py +82 -44
  147. dataface/core/render/chart/type_inference.py +45 -25
  148. dataface/core/render/chart/validation.py +91 -0
  149. dataface/core/render/chart/vega_lite.py +30 -11
  150. dataface/core/render/chart/vl_field_maps.py +111 -76
  151. dataface/core/render/converters/chart.py +16 -6
  152. dataface/core/render/faces.py +3 -2
  153. dataface/core/render/fonts/DFTSerifOldstyleProportional-Regular.woff2 +0 -0
  154. dataface/core/render/fonts/DFTSerifOldstyleTabular-Regular.woff2 +0 -0
  155. dataface/core/render/fonts/SOURCE_CODE_PRO_LICENSE.txt +93 -93
  156. dataface/core/render/fonts/_font_face.css +10 -0
  157. dataface/core/render/layout_sizing.py +267 -238
  158. dataface/core/render/numeral_expr.py +80 -0
  159. dataface/core/render/warnings/bar_band_width_too_narrow.py +64 -6
  160. dataface/core/render/warnings/layered_chart_shared_y_axis_scale_mismatch.py +25 -3
  161. dataface/core/render/warnings/likely_currency_or_percent_missing_formatter.py +12 -6
  162. dataface/core/render/warnings/palette_unsupported.py +6 -0
  163. dataface/core/render/warnings/pie_dominant_segment.py +1 -0
  164. dataface/core/render/warnings/pie_too_many_segments.py +1 -0
  165. dataface/core/render/warnings/point_map_out_of_projection.py +1 -0
  166. dataface/core/render/warnings/query_returned_zero_rows.py +1 -0
  167. dataface/core/render/warnings/redundant_encoding.py +20 -1
  168. dataface/core/render/warnings/registry.py +7 -3
  169. dataface/core/render/warnings/temporal_single_point.py +1 -0
  170. dataface/core/render/warnings/too_many_color_categories.py +1 -0
  171. dataface/core/render/warnings/too_many_x_categories.py +18 -2
  172. dataface/core/render/warnings/value_labels_crowd_width.py +9 -0
  173. dataface/core/render/warnings/y_encoding_mostly_null.py +1 -0
  174. dataface/core/render_format.py +18 -0
  175. dataface/core/text/format_d3.py +56 -0
  176. dataface/core/text/numeral_scale.py +218 -0
  177. dataface/core/utils.py +103 -10
  178. dataface/data/schemas/board-resolved/board-resolved.schema.json +9487 -7978
  179. dataface/data/schemas/yaml/0.3.2.json +1 -0
  180. dataface/data/schemas/yaml/manifest.json +7 -0
  181. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/METADATA +1 -1
  182. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/RECORD +185 -177
  183. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/WHEEL +0 -0
  184. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/entry_points.txt +0 -0
  185. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/licenses/LICENSE +0 -0
@@ -14,6 +14,21 @@ from dataface.core.compile.models.markers import (
14
14
  Strategy,
15
15
  )
16
16
 
17
+ _TIME_UNIT_LITERAL = Literal[
18
+ "auto",
19
+ "year",
20
+ "yearquarter",
21
+ "yearmonth",
22
+ "yearweek",
23
+ "yearmonthdate",
24
+ "monthofyear",
25
+ "dayofweek",
26
+ "dayofmonth",
27
+ "dayofyear",
28
+ "hourofday",
29
+ "none",
30
+ ]
31
+
17
32
 
18
33
  class AxisLabelOverlapConfig(BaseModel):
19
34
  """Two-bool overlap strategy enablement for x-axis labels.
@@ -59,7 +74,7 @@ from dataface.core.compile.models.style.theme.board import (
59
74
  class AxisGridZeroStyle(BaseModel):
60
75
  """Zero-baseline gridline color and width overrides.
61
76
 
62
- Nested sub-object under AxisGridStyle.zero. Quantitative axes only —
77
+ Nested sub-object under BaseAxisGridStyle.zero. Quantitative axes only —
63
78
  band axes never tick at value=0.
64
79
  """
65
80
 
@@ -76,13 +91,18 @@ class AxisGridZeroStyle(BaseModel):
76
91
  )
77
92
 
78
93
 
79
- class AxisGridStyle(BaseModel):
94
+ class BaseAxisGridStyle(BaseModel):
95
+ """Grid line style for all axis variants.
96
+
97
+ No zero field — that lives only on MeasureGridStyle (AxisYStyle.grid). A
98
+ zero-baseline gridline only makes sense on the measure (y) axis; putting it
99
+ on the shared base would allow authoring it on x-axes where it is silent.
100
+ """
101
+
80
102
  model_config = ConfigDict(extra="forbid", frozen=True)
81
103
 
82
- # All fields are nullable in AxisGridStyle so the cascade can fill
83
- # missing values from parent `axis` into `axis_x` / `axis_y` /
84
- # `axis_quantitative`. The base `axis` slot must have all fields non-None;
85
- # `_build_resolved_axis` validates this at resolve time.
104
+ # All fields are nullable so the cascade can fill missing values from parent
105
+ # `axis` into `axis_x` / `axis_y` / `axis_quantitative`.
86
106
  visible: bool | None = Field(
87
107
  default=None, description="Show grid lines; None inherits from parent axis."
88
108
  )
@@ -103,17 +123,32 @@ class AxisGridStyle(BaseModel):
103
123
  default=None,
104
124
  description="Dash pattern for grid lines; None renders a solid line.",
105
125
  )
106
- # Zero-baseline sub-block. None on child axes means "no override at this level";
107
- # the cascade fills individual zero.* fields from the parent axis.
108
- # SkipInheritSlots(cascade=True): apply_inherit copies the entire zero object when
109
- # the child axis has zero=None; apply_inherit copies the zero object from the parent axis slot (cascade=True).
126
+
127
+
128
+ class MeasureGridStyle(BaseAxisGridStyle):
129
+ """BaseAxisGridStyle + zero-baseline override for the measure (y) axis.
130
+
131
+ Used only by AxisYStyle.grid. The zero sub-block is structurally absent on
132
+ all other axis variants so the model rejects it at validation time.
133
+ """
134
+
135
+ model_config = ConfigDict(extra="forbid", frozen=True)
136
+
137
+ # Zero-baseline sub-block. None means "no override at this level"; the
138
+ # cascade fills individual zero.* fields from the parent axis.
139
+ # SkipInheritSlots(cascade=True): apply_inherit copies the entire zero object
140
+ # when the child axis has zero=None; fills individual None fields when partial.
110
141
  zero: Annotated[AxisGridZeroStyle | None, SkipInheritSlots(cascade=True)] = Field(
111
142
  default=None,
112
143
  description="Zero-baseline gridline style; None inherits from parent axis.",
113
144
  )
114
145
 
115
146
 
116
- class AxisDomainStyle(BaseModel):
147
+ class AxisLineStyle(BaseModel):
148
+ """Axis domain/baseline line style. Renamed from ``AxisDomainStyle`` —
149
+ same 3 fields, no shape change; ``domain`` collided too easily with
150
+ scale-domain terminology."""
151
+
117
152
  model_config = ConfigDict(extra="forbid", frozen=True)
118
153
 
119
154
  # Nullable so the cascade can fill missing values from parent `axis`.
@@ -130,56 +165,11 @@ class AxisDomainStyle(BaseModel):
130
165
  )
131
166
 
132
167
 
133
- class AxisTicksCadenceValidationMixin(BaseModel):
134
- """Authored-input validator for step-anchored tick cadence.
135
-
136
- Applied only to ``AxisTicksStylePatch`` (the authored overlay), not the
137
- compiled ``AxisTicksStyle`` — the compiled type is the cascade-merged
138
- result across theme/chart-type/chart-local layers, and a legitimate
139
- theme default (e.g. editorial's ``axis_quantitative.ticks.count: 6``)
140
- can land in the same merged object as an unrelated authored ``interval``
141
- on a different axis slot without either single authored layer actually
142
- conflicting. Validating only the as-authored patch catches the real
143
- mistake (both fields set together in the same authored block) without
144
- that cross-layer false positive. ``build_patch_model_ext`` only carries
145
- over validators that live on the patch model's base class (mirrors
146
- ``ScaleDomainValidationMixin`` above).
147
-
148
- Enforces: ``step`` requires ``interval`` (step alone doesn't name a
149
- cadence unit), and ``interval``/``count`` are mutually exclusive (two
150
- different ways to express tick cadence). Both fields absent, or
151
- ``interval`` alone, are always valid.
152
- """
153
-
154
- model_config = ConfigDict(extra="forbid")
155
-
156
- @model_validator(mode="before")
157
- @classmethod
158
- def _validate_cadence(cls, data: object) -> object:
159
- if not isinstance(data, dict):
160
- return data
161
- step = data.get("step")
162
- interval = data.get("interval")
163
- count = data.get("count")
164
- if step is not None and interval is None:
165
- raise ValueError(
166
- "ticks.step is set without ticks.interval — step alone "
167
- "doesn't name a cadence unit (year/quarter/month). Author "
168
- "both (e.g. interval: year, step: 5), or use ticks.count for "
169
- "VL's advisory tick-count hint instead."
170
- )
171
- if interval is not None and count is not None:
172
- raise ValueError(
173
- "ticks.interval and ticks.count cannot both be set — they "
174
- "are two different ways to express tick cadence. Use "
175
- "ticks.interval/step for a step-anchored cadence (every N "
176
- "years/quarters/months), or ticks.count for VL's advisory "
177
- "tick-count hint, not both."
178
- )
179
- return data
180
-
181
-
182
168
  class AxisTicksStyle(BaseModel):
169
+ """Universal tick config. ``step``-anchored cadence needs a named time
170
+ unit to mean anything — that unit field lives on ``DimensionTicksStyle``
171
+ (axis_x only), not here; ``step`` alone on this base class is inert."""
172
+
183
173
  model_config = ConfigDict(extra="forbid", frozen=True)
184
174
 
185
175
  # Nullable so the cascade can fill `visible` from parent `axis`.
@@ -189,7 +179,7 @@ class AxisTicksStyle(BaseModel):
189
179
  color: str | None = Field(
190
180
  default=None, description="Tick color; None uses Vega-Lite's default."
191
181
  )
192
- size: float | None = Field(
182
+ length: float | None = Field(
193
183
  default=None,
194
184
  description="Tick length in pixels; None uses Vega-Lite's default.",
195
185
  )
@@ -197,6 +187,10 @@ class AxisTicksStyle(BaseModel):
197
187
  default=None,
198
188
  description="Tick stroke width in pixels; None uses Vega-Lite's default.",
199
189
  )
190
+ offset: float | None = Field(
191
+ default=None,
192
+ description="Pixel offset of ticks from their default position; None means no offset.",
193
+ )
200
194
  # SkipInheritSlots: count is not inherited — axis family variants each set their own count.
201
195
  count: Annotated[int | None, SkipInheritSlots()] = Field(
202
196
  default=None,
@@ -208,31 +202,99 @@ class AxisTicksStyle(BaseModel):
208
202
  "which VL's time-scale tick logic honours as a target."
209
203
  ),
210
204
  )
211
- # SkipInheritSlots: interval/step are not inherited — axis family variants each set their own.
212
- interval: Annotated[
213
- Literal["year", "quarter", "month"] | None, SkipInheritSlots()
214
- ] = Field(
205
+ # SkipInheritSlots: step is not inherited — axis family variants each set their own.
206
+ step: Annotated[int | None, SkipInheritSlots()] = Field(
215
207
  default=None,
216
208
  description=(
217
- "Step-anchored tick cadence unit — 'ticks every N <interval>, "
218
- "anchored on multiples of N'. Mutually exclusive with count. "
219
- "Meaningful on a continuous-temporal axis_x only — raises at "
220
- "compile on axis_y, and at render if the x-axis resolves to a "
221
- "non-temporal (ordinal) scale."
209
+ "Step multiple for a step-anchored tick cadence (e.g. on "
210
+ "DimensionTicksStyle: time_unit: year, step: 5 -> ticks every 5 "
211
+ "years). Inert on this base class alone — a channel that wants "
212
+ "step-anchored cadence adds the unit field (DimensionTicksStyle.time_unit)."
222
213
  ),
223
214
  )
224
- step: Annotated[int | None, SkipInheritSlots()] = Field(
215
+
216
+
217
+ class DimensionTicksStyle(AxisTicksStyle):
218
+ """axis_x-only: step-anchored tick cadence needs a named time unit.
219
+
220
+ Distinct from ``DimensionLabelStyle.time_unit`` (label-cadence override)
221
+ and ``AxisXStyle.time_unit`` (bucketing grain) — three different
222
+ time_unit fields, three different meanings.
223
+ """
224
+
225
+ model_config = ConfigDict(extra="forbid", frozen=True)
226
+
227
+ time_unit: _TIME_UNIT_LITERAL | None = Field(
228
+ default=None,
229
+ description="Step-anchored tick cadence unit; None disables step-anchored ticks.",
230
+ )
231
+
232
+ @model_validator(mode="after")
233
+ def _validate_cadence(self) -> DimensionTicksStyle:
234
+ if self.step is not None and self.time_unit is None:
235
+ raise ValueError(
236
+ "ticks.step is set without ticks.time_unit — step alone "
237
+ "doesn't name a cadence unit (year/quarter/month/...). "
238
+ "Author both (e.g. time_unit: year, step: 5), or use "
239
+ "ticks.count for VL's advisory tick-count hint instead."
240
+ )
241
+ if self.time_unit is not None and self.count is not None:
242
+ raise ValueError(
243
+ "ticks.time_unit and ticks.count cannot both be set — they "
244
+ "are two different ways to express tick cadence. Use "
245
+ "ticks.time_unit/step for a step-anchored cadence (every N "
246
+ "years/quarters/months), or ticks.count for VL's advisory "
247
+ "tick-count hint, not both."
248
+ )
249
+ return self
250
+
251
+
252
+ class AxisTitleStyle(BaseModel):
253
+ """Axis title typography — deliberately thin. A title is one short static
254
+ string, not a per-tick label stream, so it doesn't need the label-overlap
255
+ strategy machinery or VL passthrough knobs (bound/flush/offset/line_height)
256
+ that only make sense for a repeated per-tick label. padding is kept
257
+ (unlike those) since the theme authors a real, non-VL-default value."""
258
+
259
+ model_config = ConfigDict(extra="forbid", frozen=True)
260
+
261
+ font: Annotated[FontStyle, InheritSlot(from_path="Style.charts.font")] = Field(
262
+ default_factory=FontStyle, description="Axis title font style overrides."
263
+ )
264
+ padding: float | None = Field(
265
+ default=None,
266
+ description="Padding between axis title and labels in pixels; None uses Vega-Lite's default.",
267
+ )
268
+ angle: float | None = Field(
269
+ default=None,
270
+ description="Title rotation angle in degrees; None uses Vega-Lite's default.",
271
+ )
272
+ # Same left/right/center/inward/outward vocabulary as AxisLabelStyle.align
273
+ # — resolved through the identical _resolve_own_side_align machinery.
274
+ align: Literal["left", "right", "center", "inward", "outward"] | None = Field(
225
275
  default=None,
226
276
  description=(
227
- "Step multiple for the interval cadence (e.g. interval: year, "
228
- "step: 5 -> ticks every 5 years). Requires interval to also be "
229
- "set — step alone doesn't name a unit."
277
+ "Horizontal text alignment of the title. 'left'/'right'/'center' "
278
+ "are absolute. 'inward'/'outward' resolve against the axis's own "
279
+ "left/right edge, same as label.align; a no-op on an axis with no "
280
+ "left/right edge. None uses Vega-Lite's default."
230
281
  ),
231
282
  )
283
+ visible: bool | None = Field(
284
+ default=None,
285
+ description="Show the axis title; None uses Vega-Lite's default (title shown).",
286
+ )
232
287
 
233
288
 
234
- class AxisElementStyle(BaseModel):
235
- """Axis label or title: font + padding + VL-passthrough."""
289
+ class AxisLabelStyle(BaseModel):
290
+ """Axis label: font + padding + VL-passthrough.
291
+
292
+ This is the universal base — fields that are meaningful on both dimension
293
+ and measure axes. Dimension-only fields (tilt_increments, values,
294
+ time_unit) live on DimensionLabelStyle, which is used for AxisXStyle.labels.
295
+ expr stays here: it is universal and read by time_unit_detect.py on both
296
+ axes.
297
+ """
236
298
 
237
299
  model_config = ConfigDict(extra="forbid", frozen=True)
238
300
 
@@ -268,10 +330,6 @@ class AxisElementStyle(BaseModel):
268
330
  "default."
269
331
  ),
270
332
  )
271
- baseline: str | None = Field(
272
- default=None,
273
- description="Vertical text baseline of labels; None uses Vega-Lite's default.",
274
- )
275
333
  # Two-bool struct enabling/disabling each overlap strategy.
276
334
  # None = not authored — inherit the parent axis's setting from the cascade.
277
335
  # SkipInheritSlots(cascade=True): if overlap is None on the child axis,
@@ -290,38 +348,18 @@ class AxisElementStyle(BaseModel):
290
348
  "to a coarser calendar period without changing the axis or label time unit."
291
349
  ),
292
350
  )
293
- separation: float | None = Field(
351
+ min_gap: float | None = Field(
294
352
  default=None,
295
- description="Minimum pixel separation between labels; None uses Vega-Lite's default (0px).",
353
+ description="Minimum pixel gap between labels; None uses Vega-Lite's default (0px).",
296
354
  )
297
355
  visible: bool | None = Field(
298
356
  default=None,
299
357
  description="Show axis labels; None uses Vega-Lite's default (labels shown).",
300
358
  )
301
- # Optional label cadence for temporal axes. None inherits from the parent
302
- # axis time_unit; render-time layout may promote sub-month auto labels.
303
- time_unit: (
304
- Literal[
305
- "auto",
306
- "year",
307
- "yearquarter",
308
- "yearmonth",
309
- "yearweek",
310
- "yearmonthdate",
311
- "monthofyear",
312
- "dayofweek",
313
- "dayofmonth",
314
- "dayofyear",
315
- "hourofday",
316
- "none",
317
- ]
318
- | None
319
- ) = Field(
320
- default=None,
321
- description="Label cadence for temporal axes; None inherits from the parent axis time_unit.",
322
- )
323
359
  # When None and the parent axis has a temporal time_unit, the render layer
324
360
  # fills in a smart conditional labelExpr — see render/chart/time_unit_detect.py.
361
+ # Kept on the base (not just DimensionLabelStyle) — both axes may use custom
362
+ # label expressions, e.g. for axis_y mirror overrides via AxisMirrorStyle.expr.
325
363
  expr: str | None = Field(
326
364
  default=None,
327
365
  description="Custom Vega expression for label text; None uses smart temporal defaults when applicable.",
@@ -339,11 +377,27 @@ class AxisElementStyle(BaseModel):
339
377
  default=None,
340
378
  description="Pixel offset of the label from its tick anchor; None uses Vega-Lite's default.",
341
379
  )
342
- line_height: float | None = Field(
380
+ format: str | None = Field(
381
+ default=None, description="Tick value format string; None uses auto-format."
382
+ )
383
+
384
+
385
+ class DimensionLabelStyle(AxisLabelStyle):
386
+ """AxisLabelStyle + dimension-axis-only label fields.
387
+
388
+ Used only by AxisXStyle.labels. Fields here are structurally absent on
389
+ AxisYStyle.labels so the model rejects them at validation time rather than
390
+ requiring a scattered runtime check.
391
+ """
392
+
393
+ model_config = ConfigDict(extra="forbid", frozen=True)
394
+
395
+ # Optional label cadence for temporal axes. None inherits from the parent
396
+ # axis time_unit, except yearweek defaults to month labels.
397
+ time_unit: _TIME_UNIT_LITERAL | None = Field(
343
398
  default=None,
344
- description="Line height for multi-line labels in pixels; None uses Vega-Lite's default.",
399
+ description="Label cadence for temporal axes; None inherits from the parent axis time_unit.",
345
400
  )
346
- anchor: Literal["start", "middle", "end"] | None = Field(default=None, description="Label anchor position; None uses Vega-Lite's default.") # fmt: skip
347
401
  # Descending tilt ladder consulted by the "tilt" strategy on discrete x-axes.
348
402
  # The picker walks this list and returns the first angle whose label widths
349
403
  # fit at the chart's drawable width; fall-through uses the last entry (steepest).
@@ -356,15 +410,17 @@ class AxisElementStyle(BaseModel):
356
410
  # Decouples LABEL density from tick/grid density on temporal x-axes. This
357
411
  # is a filter, not a tick list: it does not add or remove ticks. Ticks and
358
412
  # gridlines are produced exactly as they would be without this field —
359
- # from ``AxisStyle.values`` if authored, else the auto-fill default. Each
360
- # of those ticks then gets its label text kept only if its date appears
361
- # in this list; every other tick keeps its position/gridline but its
362
- # label is blanked. A date here that doesn't match any actual tick is a
363
- # silent no-op (nothing exists to unblank). To change tick/grid density
364
- # itself, author ``axis_x.values``. None labels every tick per the
413
+ # from ``axis_x.scale.values`` if authored, else the auto-fill default.
414
+ # Each of those ticks then gets its label text kept only if its date
415
+ # appears in this list; every other tick keeps its position/gridline but
416
+ # its label is blanked. A date here that doesn't match any actual tick is
417
+ # a silent no-op (nothing exists to unblank). To change tick/grid density
418
+ # itself, author ``axis_x.scale.values``. None labels every tick per the
365
419
  # existing smart cadence/format. Temporal x-axes only — the render layer
366
- # raises when authored on a non-temporal axis or on axis_y (the measure
367
- # axis is never temporal in Dataface's model).
420
+ # raises when authored on a non-temporal axis, on axis_y (the measure
421
+ # axis is never temporal in Dataface's model), or on a bar chart whose
422
+ # categorical axis renders horizontal (that axis is always nominal,
423
+ # regardless of orientation being forced or auto-inferred).
368
424
  values: Annotated[list[Any] | None, Merge(Strategy.OVERRIDE)] = Field(
369
425
  default=None,
370
426
  min_length=1,
@@ -375,7 +431,8 @@ class AxisElementStyle(BaseModel):
375
431
  "add or remove ticks. Every tick not in this list keeps its "
376
432
  "position and gridline but has its label blanked. Set "
377
433
  "axis_x.values separately to change tick/grid density itself. "
378
- "Temporal x-axes only; None labels every tick as usual."
434
+ "Temporal x-axes only; not supported on a horizontal bar's "
435
+ "categorical axis. None labels every tick as usual."
379
436
  ),
380
437
  )
381
438
 
@@ -407,7 +464,7 @@ def _is_iso_date_string(value: str) -> bool:
407
464
  class ScaleDomainValidationMixin(BaseModel):
408
465
  """Shared authored-input validator for scale domain shape/consistency.
409
466
 
410
- Inherited by both ``ScaleStyle`` and ``ScaleStylePatch`` so chart-local
467
+ Inherited by both ``BaseScaleStyle`` and ``BaseScaleStylePatch`` so chart-local
411
468
  authored patches enforce the same domain rules as theme-stage input —
412
469
  ``build_patch_model_ext`` only carries over validators that live on the
413
470
  patch model's base class (mirrors ``_FaceDesugarMixin``).
@@ -447,86 +504,63 @@ class ScaleDomainValidationMixin(BaseModel):
447
504
  return data
448
505
 
449
506
 
450
- class ScaleBandStyle(BaseModel):
451
- """Band mark size constraints (min/max pixel width for band-scale marks)."""
507
+ class ScaleLogStyle(BaseModel):
508
+ """Log-scale-only param."""
452
509
 
453
510
  model_config = ConfigDict(extra="forbid", frozen=True)
454
511
 
455
- min_size: float | None = Field( # None = no min constraint; authored overlay
456
- default=None,
457
- description="Minimum band mark width in pixels; None uses Vega-Lite's default.",
458
- )
459
- max_size: float | None = Field( # None = no max constraint; authored overlay
512
+ base: float | None = Field(
460
513
  default=None,
461
- description="Maximum band mark width in pixels; None uses Vega-Lite's default.",
514
+ description="Log base; only meaningful with type: log. None lets Vega-Lite apply its own default (10).",
462
515
  )
463
516
 
464
517
 
465
- class ScaleStyle(ScaleDomainValidationMixin):
466
- """Scale configuration primitive.
467
-
468
- Used both at chart-level (``style.scale`` — applies to both x and y) and
469
- per-axis (``style.axis_x.scale``, ``style.axis_y.scale`` — overrides on
470
- that channel only). Per-axis values win on conflict.
471
- """
518
+ class ScalePowStyle(BaseModel):
519
+ """Power-scale-only param."""
472
520
 
473
521
  model_config = ConfigDict(extra="forbid", frozen=True)
474
522
 
475
- band_padding_inner: float | None = Field(
476
- default=None,
477
- description="Inner padding fraction for band scales; None uses Vega-Lite's default.",
478
- )
479
- band_padding_outer: float | None = Field(
480
- default=None,
481
- description="Outer padding fraction for band scales; None uses Vega-Lite's default.",
482
- )
483
- point_padding: float | None = Field(
484
- default=None,
485
- description="Padding fraction for point scales; None uses Vega-Lite's default.",
486
- )
487
- rect_band_padding_inner: float | None = Field(
523
+ exponent: float | None = Field(
488
524
  default=None,
489
- description="Inner padding fraction for rect band scales; None uses Vega-Lite's default.",
525
+ description="Power exponent; only meaningful with type: pow. None uses Vega-Lite's default.",
490
526
  )
491
- round: bool | None = Field(
527
+
528
+
529
+ class ScaleSymlogStyle(BaseModel):
530
+ """Symlog-scale-only param."""
531
+
532
+ model_config = ConfigDict(extra="forbid", frozen=True)
533
+
534
+ constant: float | None = Field(
492
535
  default=None,
493
- description="Round scale outputs to nearest integer; None uses Vega-Lite's default (no rounding).",
536
+ description="Symlog constant; only meaningful with type: symlog. None uses Vega-Lite's default.",
494
537
  )
495
- zero: bool | Literal["auto"] | None = Field( # None = VL passthrough
538
+
539
+
540
+ class ScaleContinuousStyle(ScaleDomainValidationMixin):
541
+ """Continuous-scale-only config: type, domain, zero-baseline, log/pow/symlog params.
542
+
543
+ Carries two validators moved from the old flat ``BaseScaleStyle``:
544
+ - ScaleDomainValidationMixin: the domain-shape check (applies to both this
545
+ class and its generated Patch via build_patch_model_ext).
546
+ - _validate_log_pow_symlog_params: defined DIRECTLY here, NOT on a mixin —
547
+ this validator fires on the fully-merged result; it must NOT carry into
548
+ the generated Patch since a theme layer may set type: log and a
549
+ chart-local patch layer may set only base. The cross-field check fires
550
+ once, on the merged result.
551
+
552
+ All fields optional — see test_scale_groups_have_no_required_fields.
553
+ """
554
+
555
+ model_config = ConfigDict(extra="forbid", frozen=True)
556
+
557
+ zero: bool | Literal["auto"] | None = Field(
496
558
  default=None,
497
559
  description=(
498
560
  'Force scale zero-baseline: True/False pins it; "auto" runs the '
499
561
  "Dataface smart-zero heuristic; None passes through to Vega-Lite."
500
562
  ),
501
563
  )
502
- clamp: bool | None = Field(
503
- default=None,
504
- description="Clamp values to scale domain; None uses Vega-Lite's default (no clamping).",
505
- )
506
- continuous_padding: float | None = Field(
507
- default=None,
508
- description="Padding in pixels for continuous scales; None uses Vega-Lite's default.",
509
- )
510
- band: ScaleBandStyle | None = Field( # None = no band size constraint authored
511
- default=None,
512
- description="Band mark size constraints; None uses Vega-Lite's defaults.",
513
- )
514
- quantile_count: int | None = Field(
515
- default=None,
516
- description="Number of quantile buckets; None uses Vega-Lite's default.",
517
- )
518
- quantize_count: int | None = Field(
519
- default=None,
520
- description="Number of quantize buckets; None uses Vega-Lite's default.",
521
- )
522
- use_unaggregated_domain: bool | None = Field(
523
- default=None,
524
- description="Use unaggregated domain for scale extent; None uses Vega-Lite's default.",
525
- )
526
- x_reverse: bool | None = Field(
527
- default=None,
528
- description="Reverse the x-axis scale direction; None means no reversal.",
529
- )
530
564
  type: Literal["linear", "log", "pow", "sqrt", "symlog", "temporal"] | None = Field(
531
565
  default=None,
532
566
  description=(
@@ -551,21 +585,6 @@ class ScaleStyle(ScaleDomainValidationMixin):
551
585
  "date/datetime strings."
552
586
  ),
553
587
  )
554
- base: float | None = Field(
555
- default=None,
556
- description=(
557
- "Log base; only meaningful with type: log. None lets Vega-Lite "
558
- "apply its own default (10)."
559
- ),
560
- )
561
- exponent: float | None = Field(
562
- default=None,
563
- description="Power exponent; only meaningful with type: pow. None uses Vega-Lite's default.",
564
- )
565
- constant: float | None = Field(
566
- default=None,
567
- description="Symlog constant; only meaningful with type: symlog. None uses Vega-Lite's default.",
568
- )
569
588
  domain_strategy: Literal["stable", "auto"] | None = Field(
570
589
  default=None,
571
590
  description=(
@@ -574,10 +593,82 @@ class ScaleStyle(ScaleDomainValidationMixin):
574
593
  "the domain to the chart query's full unfiltered extent — the same "
575
594
  "value lands at the same pixel across variable changes. 'auto' "
576
595
  "rescales the domain to the current filtered subset on every variable "
577
- "change. An explicit `domain` always wins over "
578
- "either strategy."
596
+ "change. An explicit `domain` always wins over either strategy."
579
597
  ),
580
598
  )
599
+ log: ScaleLogStyle | None = Field(
600
+ default=None,
601
+ description="Log-scale param (base); only meaningful with type: log.",
602
+ )
603
+ pow: ScalePowStyle | None = Field(
604
+ default=None,
605
+ description="Power-scale param (exponent); only meaningful with type: pow.",
606
+ )
607
+ symlog: ScaleSymlogStyle | None = Field(
608
+ default=None,
609
+ description="Symlog-scale param (constant); only meaningful with type: symlog.",
610
+ )
611
+
612
+ # NOT on ScaleDomainValidationMixin: unlike domain shape, this pairing can
613
+ # legitimately span two cascade layers (a theme sets type: log, a chart
614
+ # overlay sets only log.base) — merge_onto_base re-validates the fully-merged
615
+ # ScaleContinuousStyle via model_validate, so this fires on the merged
616
+ # result, not on an isolated authored patch fragment (the generated Patch
617
+ # does not inherit it — it is defined directly here, not on a mixin).
618
+ @model_validator(mode="after")
619
+ def _validate_log_pow_symlog_params(self) -> ScaleContinuousStyle:
620
+ if self.log is not None and self.log.base is not None and self.type != "log":
621
+ raise ValueError(
622
+ "scale.continuous.log.base is only meaningful with type: log — "
623
+ "set axis_y.scale.continuous.type: log, or remove log.base."
624
+ )
625
+ if (
626
+ self.pow is not None
627
+ and self.pow.exponent is not None
628
+ and self.type != "pow"
629
+ ):
630
+ raise ValueError(
631
+ "scale.continuous.pow.exponent is only meaningful with type: pow "
632
+ "— set axis_y.scale.continuous.type: pow, or remove pow.exponent."
633
+ )
634
+ if (
635
+ self.symlog is not None
636
+ and self.symlog.constant is not None
637
+ and self.type != "symlog"
638
+ ):
639
+ raise ValueError(
640
+ "scale.continuous.symlog.constant is only meaningful with type: "
641
+ "symlog — set axis_y.scale.continuous.type: symlog, or remove "
642
+ "symlog.constant."
643
+ )
644
+ if self.type == "log" and self.zero is True:
645
+ raise ValueError(
646
+ "scale.continuous.type: log is incompatible with zero: true — a log "
647
+ "domain cannot include 0. Remove zero or drop the log scale."
648
+ )
649
+ return self
650
+
651
+
652
+ class BaseScaleStyle(BaseModel):
653
+ """Universal + continuous-only scale config.
654
+
655
+ Channel-agnostic — used at chart-level ``scale:`` (applies to both x and y)
656
+ and at ``axis_y.scale``. band/point/quantize/mark_size scale config was
657
+ deleted (2026-08 trim): no chart currently needs it, and it more than
658
+ doubled the authoring surface for zero real use. Rely on Vega-Lite's own
659
+ defaults until a real need for those knobs shows up.
660
+ """
661
+
662
+ model_config = ConfigDict(extra="forbid", frozen=True)
663
+
664
+ round: bool | None = Field(
665
+ default=None,
666
+ description="Round scale outputs to nearest integer; None uses Vega-Lite's default (no rounding).",
667
+ )
668
+ clamp: bool | None = Field(
669
+ default=None,
670
+ description="Clamp values to scale domain; None uses Vega-Lite's default (no clamping).",
671
+ )
581
672
  nice: bool | None = Field(
582
673
  default=None,
583
674
  description="Round scale domain to nice values; None uses Vega-Lite's default.",
@@ -604,36 +695,34 @@ class ScaleStyle(ScaleDomainValidationMixin):
604
695
  "Ignored with an explicit `domain` or `stack: normalize`."
605
696
  ),
606
697
  )
698
+ # Explicit tick values for the scale (not to be confused with
699
+ # labels.values, which is a dimension-only label density filter on
700
+ # DimensionLabelStyle). Moved in from the old BaseAxisStyle.values.
701
+ values: Annotated[list[Any] | None, Merge(Strategy.OVERRIDE)] = Field(
702
+ default=None,
703
+ description="Explicit tick values; None uses Vega-Lite's auto tick values.",
704
+ )
705
+ continuous: ScaleContinuousStyle | None = Field(
706
+ default=None,
707
+ description="Continuous-scale overrides (type, domain, zero, log/pow/symlog params); None means no override.",
708
+ )
607
709
 
608
- # NOT on ScaleDomainValidationMixin: unlike domain shape, this pairing can
609
- # legitimately span two cascade layers (a theme sets type: log, a chart
610
- # overlay sets only base) — merge_onto_base re-validates the fully-merged
611
- # ScaleStyle via model_validate, so this fires on the merged result, not
612
- # on an isolated authored patch fragment (ScaleStylePatch deliberately
613
- # does not inherit it).
614
- @model_validator(mode="after")
615
- def _validate_log_pow_symlog_params(self) -> ScaleStyle:
616
- if self.base is not None and self.type != "log":
617
- raise ValueError(
618
- "scale.base is only meaningful with type: log — set "
619
- "axis_y.scale.type: log, or remove base."
620
- )
621
- if self.exponent is not None and self.type != "pow":
622
- raise ValueError(
623
- "scale.exponent is only meaningful with type: pow — set "
624
- "axis_y.scale.type: pow, or remove exponent."
625
- )
626
- if self.constant is not None and self.type != "symlog":
627
- raise ValueError(
628
- "scale.constant is only meaningful with type: symlog — set "
629
- "axis_y.scale.type: symlog, or remove constant."
630
- )
631
- if self.type == "log" and self.zero is True:
632
- raise ValueError(
633
- "scale.type: log is incompatible with zero: true — a log "
634
- "domain cannot include 0. Remove zero or drop the log scale."
635
- )
636
- return self
710
+
711
+ class XScaleStyle(BaseScaleStyle):
712
+ """Scale config for axis_x.scale only.
713
+
714
+ x_reverse is structurally absent from BaseScaleStyle so the model rejects
715
+ it on axis_y.scale and chart-level scale: at validation time, closing the
716
+ silent-leak bug where x_reverse on a chart-level scale reached VL's y-scale
717
+ via the old flat SCALE_FIELD_MAP.
718
+ """
719
+
720
+ model_config = ConfigDict(extra="forbid", frozen=True)
721
+
722
+ x_reverse: bool | None = Field(
723
+ default=None,
724
+ description="Reverse the x-axis scale direction; None means no reversal.",
725
+ )
637
726
 
638
727
 
639
728
  class TooltipSlotStyle(BaseModel):
@@ -784,107 +873,77 @@ class AxisMirrorStyle(BaseModel):
784
873
  return self
785
874
 
786
875
 
787
- class AxisStyle(BaseModel):
876
+ class BaseAxisStyle(BaseModel):
877
+ """Universal axis surface — grid/line/ticks/labels/title/scale.
878
+
879
+ Channel-agnostic: used for the theme's ``axis:`` slot (applies to all axes)
880
+ and for AxisXStyle/AxisYStyle/QuantitativeAxisStyle/BandAxisStyle subclasses.
881
+ Dimension-only and measure-only fields live on the appropriate subclass so
882
+ the model rejects them at validation time rather than requiring scattered
883
+ runtime checks. No ``position`` here — AxisXStyle/AxisYStyle each declare
884
+ their own narrowed literal; axis_quantitative/axis_band have no position
885
+ concept of their own.
886
+ """
887
+
788
888
  model_config = ConfigDict(extra="forbid", frozen=True)
789
889
 
790
- grid: AxisGridStyle = Field(
791
- default_factory=AxisGridStyle, description="Grid line style for this axis."
890
+ grid: BaseAxisGridStyle = Field(
891
+ default_factory=BaseAxisGridStyle,
892
+ description="Grid line style for this axis.",
792
893
  )
793
- domain: AxisDomainStyle = Field(
794
- default_factory=AxisDomainStyle, description="Domain line style for this axis."
894
+ line: AxisLineStyle = Field(
895
+ default_factory=AxisLineStyle, description="Domain line style for this axis."
795
896
  )
796
897
  ticks: AxisTicksStyle = Field(
797
898
  default_factory=AxisTicksStyle, description="Tick mark style for this axis."
798
899
  )
799
- label: AxisElementStyle = Field(
800
- default_factory=AxisElementStyle, description="Axis label style."
900
+ labels: AxisLabelStyle = Field(
901
+ default_factory=AxisLabelStyle, description="Axis label style."
801
902
  )
802
- title: AxisElementStyle = Field(
803
- default_factory=AxisElementStyle, description="Axis title style."
903
+ title: AxisTitleStyle = Field(
904
+ default_factory=AxisTitleStyle, description="Axis title style."
804
905
  )
805
- # ── Fields excluded from slot expansion (not inherited across axis variants) ───────
806
- # These fields are per-axis-variant values; child axes do not inherit them
807
- # from the canonical axis. SkipInheritSlots prevents InheritSlot expansion
808
- # via the axis_x/y/quantitative slot from adding incorrect graph entries.
809
-
810
- # "auto" → "right" by default; flips to "left" when chart has right-edge
811
- # series labels (e.g. line endpoint labels). VL passthrough for explicit values.
812
- position: Annotated[
813
- Literal["left", "right", "top", "bottom", "auto"] | None, SkipInheritSlots()
814
- ] = Field(
815
- default=None,
816
- description="Axis position; auto flips y-axis when endpoint labels are present.",
817
- )
818
- # Only populated on axis_y in theme YAML; None on shared/x/quantitative/band
819
- # axes means "skip this VL property" matching the VL config emit layer.
820
- categorical_orient: Annotated[str | None, SkipInheritSlots()] = Field(
821
- default=None,
822
- description="Orientation for categorical y-axis (e.g. 'left'); None skips this VL property.",
906
+ # SkipInheritSlots: scale is not inherited — axis family variants each own their scale config.
907
+ scale: Annotated[BaseScaleStyle | None, SkipInheritSlots()] = Field(
908
+ default=None, description="Per-axis scale overrides; None means no override."
823
909
  )
824
- # Only meaningful on the y (measure) axis; the renderer consumes it only for
825
- # axis_y (mirroring x doesn't make sense). None/False = single edge. An
826
- # AxisMirrorStyle object still turns mirroring on (truthy) and additionally
827
- # relabels the mirrored edge; see AxisMirrorStyle.
828
- mirror: Annotated[bool | AxisMirrorStyle | None, SkipInheritSlots()] = Field(
829
- default=None,
830
- description="Draw the y-scale on both left and right edges (wide charts). "
831
- "true mirrors the primary axis's label verbatim; an object (format/expr) "
832
- "relabels only the mirrored edge — e.g. a percent-of-total right axis "
833
- "next to an absolute-value left axis — while ticks stay aligned to the "
834
- "single shared scale. Only meaningful on axis_y.",
910
+
911
+
912
+ class AxisXStyle(BaseAxisStyle):
913
+ """Dimension/category-time axis style. Theme slot: axis_x.
914
+
915
+ Adds dimension-only fields: fill (required — theme supplies via axis_x.fill),
916
+ time_unit (bucketing), type (ordinal/temporal discriminant), and overrides
917
+ ticks/labels with DimensionTicksStyle/DimensionLabelStyle.
918
+ """
919
+
920
+ model_config = ConfigDict(extra="forbid", frozen=True)
921
+
922
+ # Override base's ticks type to add step-anchored cadence's unit field.
923
+ ticks: DimensionTicksStyle = Field(
924
+ default_factory=DimensionTicksStyle, description="Dimension axis tick style."
835
925
  )
836
- band_position: Annotated[float | None, SkipInheritSlots()] = Field(
837
- default=None,
838
- description="Band position within the step (0–1); None uses Vega-Lite's default.",
926
+ # Override base's labels type to add dimension-only label fields.
927
+ labels: DimensionLabelStyle = Field(
928
+ default_factory=DimensionLabelStyle, description="Dimension axis label style."
839
929
  )
840
- offset: Annotated[float | None, SkipInheritSlots()] = Field(
930
+ position: Annotated[Literal["top", "bottom"] | None, SkipInheritSlots()] = Field(
841
931
  default=None,
842
- description="Pixel offset of the axis from its default position; None means no offset.",
843
- )
844
- # Encoding-level passthrough: applied to encoding.y.axis, not config.axis
845
- format: Annotated[str | None, SkipInheritSlots()] = Field(
846
- default=None, description="Tick value format string; None uses auto-format."
932
+ description="X-axis position; None uses Vega-Lite's default (bottom).",
847
933
  )
848
- values: Annotated[
849
- list[Any] | None, SkipInheritSlots(), Merge(Strategy.OVERRIDE)
850
- ] = Field(
934
+ # Override base's scale type to XScaleStyle so x_reverse can be authored here.
935
+ scale: Annotated[XScaleStyle | None, SkipInheritSlots()] = Field(
851
936
  default=None,
852
- description="Explicit tick values; None uses Vega-Lite's auto tick values.",
937
+ description="X-axis scale overrides including x_reverse; None means no override.",
853
938
  )
854
939
  # Time-unit bucketing for temporal x axes (auto-detected when None or "auto").
855
940
  # "none" disables bucketing (continuous temporal). VL timeUnit values otherwise.
856
- time_unit: Annotated[
857
- Literal[
858
- "auto",
859
- "year",
860
- "yearquarter",
861
- "yearmonth",
862
- "yearweek",
863
- "yearmonthdate",
864
- "monthofyear",
865
- "dayofweek",
866
- "dayofmonth",
867
- "dayofyear",
868
- "hourofday",
869
- "none",
870
- ]
871
- | None,
872
- SkipInheritSlots(),
873
- ] = Field(
941
+ time_unit: Annotated[_TIME_UNIT_LITERAL | None, SkipInheritSlots()] = Field(
874
942
  default=None,
875
943
  description="Time-unit bucketing for temporal x-axes; None or 'auto' auto-detects from data.",
876
944
  )
877
- # Per-axis scale overrides — applied to encoding.{x,y}.scale, win over
878
- # the chart-level ``style.scale`` when both are set.
879
- # SkipInheritSlots: scale is not inherited — axis family variants each own their scale config.
880
- scale: Annotated[ScaleStyle | None, SkipInheritSlots()] = Field(
881
- default=None, description="Per-axis scale overrides; None means no override."
882
- )
883
-
884
- # Authored opt-in scale type for bucketed-time x-axes (axis_x only).
885
- # None/"auto" = ordinal when time_unit resolves to a bucketed-calendar
886
- # grain, temporal for continuous date. "temporal" forces the temporal
887
- # escape-hatch path. "ordinal" forces ordinal. Author always wins.
945
+ # Authored opt-in scale type for bucketed-time x-axes.
946
+ # None/"auto" = ordinal when time_unit resolves to a bucketed-calendar grain.
888
947
  type: Annotated[
889
948
  Literal["auto", "ordinal", "temporal"] | None, SkipInheritSlots()
890
949
  ] = Field(
@@ -892,11 +951,8 @@ class AxisStyle(BaseModel):
892
951
  description="Scale type for bucketed-time x-axes; None/'auto' infers from time_unit grain.",
893
952
  )
894
953
  # Fill mode for missing time buckets on ordinal bucketed-time x-axes.
895
- # "null" → synthesized rows carry null measures; line/area breaks at gap.
896
- # "zero" → synthesized rows carry 0 measures; event-count charts opt in.
897
- # linear / step-* / curve → fill interior synthetic buckets (Looker-aligned names).
898
954
  # Required; theme YAML supplies the default ("null") via axis_x.fill.
899
- # SkipInheritSlots: fill is per-axis-variant; always non-None (validator converts None→"null").
955
+ # SkipInheritSlots: fill is x-axis-only; always non-None (validator converts None→"null").
900
956
  fill: Annotated[
901
957
  Literal[
902
958
  "null",
@@ -919,11 +975,11 @@ class AxisStyle(BaseModel):
919
975
  # 1 (January) is the calendar-quarter convention; any other value shifts
920
976
  # both the bucket boundaries (_enumerate_buckets) and the Q1..Q4 numbering
921
977
  # / year-boundary detection (is_label_opener, opens_label_period,
922
- # _quarter_label) by the same offset. Required (like fill) since the model
923
- # enforces it on every axis variant; semantically only active on axis_x
924
- # for the year/yearquarter grains — yearmonth/yearweek/yearmonthdate
925
- # bucket boundaries are fixed regardless of this value.
926
- # SkipInheritSlots: per-axis-variant, matches fill/time_unit/type.
978
+ # _quarter_label) by the same offset. Required (like fill) — theme YAML
979
+ # supplies the default (1) via axis_x.fiscal_year_start_month. Only
980
+ # meaningful for the year/yearquarter grains — yearmonth/yearweek/
981
+ # yearmonthdate bucket boundaries are fixed regardless of this value.
982
+ # SkipInheritSlots: matches fill/time_unit/type (x-axis-only fields).
927
983
  fiscal_year_start_month: Annotated[int, SkipInheritSlots()] = Field(
928
984
  description=(
929
985
  "Calendar month (1=Jan..12=Dec) that opens a fiscal year/quarter "
@@ -952,3 +1008,67 @@ class AxisStyle(BaseModel):
952
1008
  if value is None:
953
1009
  return "null"
954
1010
  return value
1011
+
1012
+
1013
+ class AxisYStyle(BaseAxisStyle):
1014
+ """Measure axis style. Theme slot: axis_y.
1015
+
1016
+ Adds measure-only fields: mirror; overrides grid with MeasureGridStyle
1017
+ (adds grid.zero). x-axis-only fields (fill, time_unit, type,
1018
+ tilt_increments, labels.values) are structurally absent so the model
1019
+ rejects them at validation time.
1020
+
1021
+ No ``categorical_orient`` (deleted 2026-08 trim) — ``position`` (below)
1022
+ now serves the "which side does this axis's orient read as" role for
1023
+ every consumer, dimension-swap-into-VL-y included.
1024
+ """
1025
+
1026
+ model_config = ConfigDict(extra="forbid", frozen=True)
1027
+
1028
+ # Override base's grid type to add grid.zero (measure axis only).
1029
+ grid: MeasureGridStyle = Field(
1030
+ default_factory=MeasureGridStyle,
1031
+ description="Measure axis grid style (includes zero-baseline override).",
1032
+ )
1033
+ position: Annotated[Literal["left", "right", "auto"] | None, SkipInheritSlots()] = (
1034
+ Field(
1035
+ default=None,
1036
+ description="Y-axis position; auto flips when endpoint labels are on the right.",
1037
+ )
1038
+ )
1039
+ # y-axis only: draw the y-scale on both edges.
1040
+ mirror: Annotated[bool | AxisMirrorStyle | None, SkipInheritSlots()] = Field(
1041
+ default=None,
1042
+ description=(
1043
+ "Draw the y-scale on both left and right edges (wide charts). "
1044
+ "true mirrors the primary axis's label verbatim; an object (format/expr) "
1045
+ "relabels only the mirrored edge — e.g. a percent-of-total right axis "
1046
+ "next to an absolute-value left axis — while ticks stay aligned to the "
1047
+ "single shared scale. Only meaningful on axis_y."
1048
+ ),
1049
+ )
1050
+
1051
+
1052
+ class QuantitativeAxisStyle(BaseAxisStyle):
1053
+ """Scale-type overlay for quantitative axes. Theme slot: axis_quantitative.
1054
+
1055
+ Thin today (no extra fields beyond BaseAxisStyle). Subclassed to give
1056
+ quantitative-axis patches a distinct type so AxisOverrides can be precisely
1057
+ typed per slot.
1058
+ """
1059
+
1060
+ model_config = ConfigDict(extra="forbid", frozen=True)
1061
+
1062
+
1063
+ class BandAxisStyle(BaseAxisStyle):
1064
+ """Scale-type overlay for band (categorical) axes. Theme slot: axis_band.
1065
+
1066
+ Adds band_position — structurally absent from all other variants.
1067
+ """
1068
+
1069
+ model_config = ConfigDict(extra="forbid", frozen=True)
1070
+
1071
+ band_position: Annotated[float | None, SkipInheritSlots()] = Field(
1072
+ default=None,
1073
+ description="Band position within the step (0–1); None uses Vega-Lite's default.",
1074
+ )