dataface 0.1.2__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 (455) hide show
  1. d3_format/__init__.py +14 -0
  2. d3_format/errors.py +19 -0
  3. d3_format/format.py +551 -0
  4. d3_format/spec.py +159 -0
  5. dataface/DATAFACE_SYNTAX.md +1135 -0
  6. dataface/__init__.py +93 -0
  7. dataface/_docs_site.py +20 -0
  8. dataface/_install_hint.py +26 -0
  9. dataface/agent_api/__init__.py +79 -0
  10. dataface/agent_api/_init_templates/__init__.py +0 -0
  11. dataface/agent_api/_init_templates/agents_dft_snippet.md +26 -0
  12. dataface/agent_api/_init_templates/dataface.yml +15 -0
  13. dataface/agent_api/_init_templates/faces-dataface.yml +144 -0
  14. dataface/agent_api/_init_templates/index.md +24 -0
  15. dataface/agent_api/_paths.py +118 -0
  16. dataface/agent_api/_project_agents_md.py +43 -0
  17. dataface/agent_api/_session_store.py +486 -0
  18. dataface/agent_api/_state.py +28 -0
  19. dataface/agent_api/chat.py +221 -0
  20. dataface/agent_api/dashboards.py +257 -0
  21. dataface/agent_api/describe.py +366 -0
  22. dataface/agent_api/describe_query.py +120 -0
  23. dataface/agent_api/docs/__init__.py +25 -0
  24. dataface/agent_api/docs/_loader.py +292 -0
  25. dataface/agent_api/docs/yaml-reference.md +2757 -0
  26. dataface/agent_api/file_refs.py +118 -0
  27. dataface/agent_api/init.py +126 -0
  28. dataface/agent_api/inspect.py +128 -0
  29. dataface/agent_api/mcp_install.py +170 -0
  30. dataface/agent_api/query.py +274 -0
  31. dataface/agent_api/schema.py +658 -0
  32. dataface/agent_api/schema_search.py +284 -0
  33. dataface/agent_api/search.py +270 -0
  34. dataface/agent_api/skill_install.py +141 -0
  35. dataface/agent_api/skill_render.py +90 -0
  36. dataface/agent_api/skills.py +293 -0
  37. dataface/agent_api/surface_aliases.yaml +128 -0
  38. dataface/agent_api/validate.py +175 -0
  39. dataface/agent_api/validate_query.py +84 -0
  40. dataface/ai/__init__.py +39 -0
  41. dataface/ai/agent.py +139 -0
  42. dataface/ai/context.py +45 -0
  43. dataface/ai/events.py +62 -0
  44. dataface/ai/external_mcp.py +610 -0
  45. dataface/ai/generate_sql.py +96 -0
  46. dataface/ai/llm.py +403 -0
  47. dataface/ai/mcp/__init__.py +51 -0
  48. dataface/ai/mcp/server.py +289 -0
  49. dataface/ai/memories.py +85 -0
  50. dataface/ai/prompts.py +177 -0
  51. dataface/ai/schema_context.py +138 -0
  52. dataface/ai/skills/before-after-comparison/SKILL.md +102 -0
  53. dataface/ai/skills/before-after-comparison/examples/before-after-comparison.yml +24 -0
  54. dataface/ai/skills/dashboard-build/SKILL.md +212 -0
  55. dataface/ai/skills/dashboard-build/examples/_smoke.yml +15 -0
  56. dataface/ai/skills/dashboard-design/SKILL.md +182 -0
  57. dataface/ai/skills/dashboard-review/SKILL.md +113 -0
  58. dataface/ai/skills/dashboard-structural-review/SKILL.md +173 -0
  59. dataface/ai/skills/dashboard-visual-review/SKILL.md +139 -0
  60. dataface/ai/skills/dataface-mcp-setup/SKILL.md +177 -0
  61. dataface/ai/skills/dataface-troubleshooting/SKILL.md +225 -0
  62. dataface/ai/skills/drill-down-link/SKILL.md +112 -0
  63. dataface/ai/skills/drill-down-link/examples/drill-down-link.yml +27 -0
  64. dataface/ai/skills/faceted-small-multiples/SKILL.md +116 -0
  65. dataface/ai/skills/faceted-small-multiples/examples/faceted-small-multiples.yml +33 -0
  66. dataface/ai/skills/filter-bar-with-variables/SKILL.md +105 -0
  67. dataface/ai/skills/filter-bar-with-variables/examples/filter-bar-with-variables.yml +49 -0
  68. dataface/ai/skills/kpi-row/SKILL.md +101 -0
  69. dataface/ai/skills/kpi-row/examples/kpi-row.yml +55 -0
  70. dataface/ai/skills/report-design/SKILL.md +184 -0
  71. dataface/ai/skills/single-metric-bignum/SKILL.md +90 -0
  72. dataface/ai/skills/single-metric-bignum/examples/single-metric-bignum.yml +27 -0
  73. dataface/ai/skills/table-heavy-ops-dashboard/SKILL.md +114 -0
  74. dataface/ai/skills/table-heavy-ops-dashboard/examples/table-heavy-ops-dashboard.yml +48 -0
  75. dataface/ai/skills/time-series-trend/SKILL.md +93 -0
  76. dataface/ai/skills/time-series-trend/examples/time-series-trend.yml +26 -0
  77. dataface/ai/skills/top-n-with-detail/SKILL.md +98 -0
  78. dataface/ai/skills/top-n-with-detail/examples/top-n-with-detail.yml +45 -0
  79. dataface/ai/skills/two-by-two-grid-overview/SKILL.md +78 -0
  80. dataface/ai/skills/two-by-two-grid-overview/examples/two-by-two-grid-overview.yml +64 -0
  81. dataface/ai/tool_schemas.py +132 -0
  82. dataface/ai/tools/__init__.py +312 -0
  83. dataface/ai/yaml_utils.py +57 -0
  84. dataface/cli/__init__.py +3 -0
  85. dataface/cli/_console.py +48 -0
  86. dataface/cli/_error_format.py +83 -0
  87. dataface/cli/_extras.py +190 -0
  88. dataface/cli/_json_output.py +8 -0
  89. dataface/cli/_parsing.py +17 -0
  90. dataface/cli/_version_info.py +56 -0
  91. dataface/cli/commands/__init__.py +3 -0
  92. dataface/cli/commands/_agent_input.py +205 -0
  93. dataface/cli/commands/_agent_server.py +115 -0
  94. dataface/cli/commands/chat.py +645 -0
  95. dataface/cli/commands/describe.py +107 -0
  96. dataface/cli/commands/docs.py +131 -0
  97. dataface/cli/commands/extension.py +179 -0
  98. dataface/cli/commands/init.py +240 -0
  99. dataface/cli/commands/inspect.py +94 -0
  100. dataface/cli/commands/mcp_init.py +167 -0
  101. dataface/cli/commands/query.py +386 -0
  102. dataface/cli/commands/render.py +291 -0
  103. dataface/cli/commands/schema.py +411 -0
  104. dataface/cli/commands/search.py +49 -0
  105. dataface/cli/commands/serve.py +114 -0
  106. dataface/cli/commands/skills.py +133 -0
  107. dataface/cli/commands/skills_init.py +161 -0
  108. dataface/cli/commands/validate.py +63 -0
  109. dataface/cli/main.py +1501 -0
  110. dataface/core/__init__.py +75 -0
  111. dataface/core/compile/__init__.py +244 -0
  112. dataface/core/compile/_jinja_helpers.py +78 -0
  113. dataface/core/compile/channel.py +222 -0
  114. dataface/core/compile/chart_focus.py +101 -0
  115. dataface/core/compile/chart_resolved.py +169 -0
  116. dataface/core/compile/chart_type_detection.py +489 -0
  117. dataface/core/compile/chart_update.py +261 -0
  118. dataface/core/compile/colors.py +64 -0
  119. dataface/core/compile/compiler.py +904 -0
  120. dataface/core/compile/config.py +823 -0
  121. dataface/core/compile/custom_chart_types.py +208 -0
  122. dataface/core/compile/data_table_attachment.py +1287 -0
  123. dataface/core/compile/detect.py +110 -0
  124. dataface/core/compile/errors.py +302 -0
  125. dataface/core/compile/filter_injection.py +319 -0
  126. dataface/core/compile/introspection.py +527 -0
  127. dataface/core/compile/jinja.py +511 -0
  128. dataface/core/compile/labels_env.py +52 -0
  129. dataface/core/compile/markdown.py +154 -0
  130. dataface/core/compile/meta.py +388 -0
  131. dataface/core/compile/models/__init__.py +0 -0
  132. dataface/core/compile/models/chart/__init__.py +0 -0
  133. dataface/core/compile/models/chart/authored.py +2137 -0
  134. dataface/core/compile/models/chart/compiled.py +398 -0
  135. dataface/core/compile/models/config.py +347 -0
  136. dataface/core/compile/models/face/__init__.py +0 -0
  137. dataface/core/compile/models/face/authored.py +659 -0
  138. dataface/core/compile/models/face/compiled.py +522 -0
  139. dataface/core/compile/models/factories.py +201 -0
  140. dataface/core/compile/models/markers.py +40 -0
  141. dataface/core/compile/models/palette.py +36 -0
  142. dataface/core/compile/models/primitives.py +415 -0
  143. dataface/core/compile/models/query/__init__.py +0 -0
  144. dataface/core/compile/models/query/authored.py +246 -0
  145. dataface/core/compile/models/query/compiled.py +710 -0
  146. dataface/core/compile/models/refs.py +137 -0
  147. dataface/core/compile/models/source.py +611 -0
  148. dataface/core/compile/models/style/__init__.py +0 -0
  149. dataface/core/compile/models/style/authored.py +481 -0
  150. dataface/core/compile/models/style/compiled.py +3399 -0
  151. dataface/core/compile/models/style/merged.py +1682 -0
  152. dataface/core/compile/models/theme.py +362 -0
  153. dataface/core/compile/models/variable/__init__.py +0 -0
  154. dataface/core/compile/models/variable/authored.py +254 -0
  155. dataface/core/compile/models/vega_lite/__init__.py +0 -0
  156. dataface/core/compile/models/vega_lite/config.py +510 -0
  157. dataface/core/compile/models/vega_lite/contracts.py +171 -0
  158. dataface/core/compile/normalize_charts.py +494 -0
  159. dataface/core/compile/normalize_layout.py +1000 -0
  160. dataface/core/compile/normalize_queries.py +297 -0
  161. dataface/core/compile/normalize_variables.py +489 -0
  162. dataface/core/compile/normalizer.py +543 -0
  163. dataface/core/compile/palette.py +1100 -0
  164. dataface/core/compile/parameterized.py +658 -0
  165. dataface/core/compile/parser.py +228 -0
  166. dataface/core/compile/schema.py +20 -0
  167. dataface/core/compile/schema_renderers/__init__.py +0 -0
  168. dataface/core/compile/schema_renderers/json_schema.py +163 -0
  169. dataface/core/compile/schema_renderers/prompt.py +152 -0
  170. dataface/core/compile/schema_renderers/vscode_schema.py +301 -0
  171. dataface/core/compile/sizing.py +2126 -0
  172. dataface/core/compile/sources.py +518 -0
  173. dataface/core/compile/sql_authoring_lint.py +56 -0
  174. dataface/core/compile/style_cascade.py +471 -0
  175. dataface/core/compile/typography.py +299 -0
  176. dataface/core/compile/validator.py +301 -0
  177. dataface/core/compile/variables.py +53 -0
  178. dataface/core/compile/vega_config.py +98 -0
  179. dataface/core/compile/vega_lite/__init__.py +6 -0
  180. dataface/core/compile/vega_lite/validation.py +95 -0
  181. dataface/core/compile/yaml_error_formatter.py +838 -0
  182. dataface/core/connections.py +38 -0
  183. dataface/core/dashboard.py +358 -0
  184. dataface/core/defaults/default_config.yml +101 -0
  185. dataface/core/defaults/palettes/categorical/category-10-dark.yml +32 -0
  186. dataface/core/defaults/palettes/categorical/category-10-light.yml +43 -0
  187. dataface/core/defaults/palettes/categorical/category-10.yml +31 -0
  188. dataface/core/defaults/palettes/categorical/category-6-tonal-blue.yml +22 -0
  189. dataface/core/defaults/palettes/categorical/category-6-tonal-brown.yml +29 -0
  190. dataface/core/defaults/palettes/categorical/category-6-tonal-green.yml +20 -0
  191. dataface/core/defaults/palettes/categorical/category-6-tonal-orange.yml +21 -0
  192. dataface/core/defaults/palettes/categorical/category-6-tonal-purple.yml +20 -0
  193. dataface/core/defaults/palettes/categorical/editorial-10-dark.yml +32 -0
  194. dataface/core/defaults/palettes/categorical/editorial-10.yml +40 -0
  195. dataface/core/defaults/palettes/categorical/hero-6.yml +17 -0
  196. dataface/core/defaults/palettes/categorical/single-blue.yml +11 -0
  197. dataface/core/defaults/palettes/categorical/tableau.yml +20 -0
  198. dataface/core/defaults/palettes/data/xkcd_colors.json +3803 -0
  199. dataface/core/defaults/palettes/diverging/blue-red.yml +25 -0
  200. dataface/core/defaults/palettes/diverging/coolwarm.yml +24 -0
  201. dataface/core/defaults/palettes/diverging/crimson-green.yml +23 -0
  202. dataface/core/defaults/palettes/diverging/orange-teal.yml +23 -0
  203. dataface/core/defaults/palettes/diverging/sunset.yml +24 -0
  204. dataface/core/defaults/palettes/scaffold/dft-creams.yml +38 -0
  205. dataface/core/defaults/palettes/scaffold/dft-grays.yml +53 -0
  206. dataface/core/defaults/palettes/sequential/amber.yml +22 -0
  207. dataface/core/defaults/palettes/sequential/blue.yml +22 -0
  208. dataface/core/defaults/palettes/sequential/brown.yml +22 -0
  209. dataface/core/defaults/palettes/sequential/gray.yml +22 -0
  210. dataface/core/defaults/palettes/sequential/green.yml +22 -0
  211. dataface/core/defaults/palettes/sequential/purple.yml +22 -0
  212. dataface/core/defaults/palettes/sequential/rust.yml +22 -0
  213. dataface/core/defaults/palettes/sequential/teal.yml +22 -0
  214. dataface/core/defaults/palettes/tone/negative.yml +32 -0
  215. dataface/core/defaults/palettes/tone/positive.yml +22 -0
  216. dataface/core/defaults/palettes/tone/warning.yml +22 -0
  217. dataface/core/defaults/themes/_base.yaml +786 -0
  218. dataface/core/defaults/themes/bi.yaml +16 -0
  219. dataface/core/defaults/themes/carbong100.yaml +41 -0
  220. dataface/core/defaults/themes/cream.yaml +122 -0
  221. dataface/core/defaults/themes/dark.yaml +40 -0
  222. dataface/core/defaults/themes/diagnostics-title-angle-extreme.yaml +9 -0
  223. dataface/core/defaults/themes/diagnostics-title-baseline-extreme.yaml +9 -0
  224. dataface/core/defaults/themes/diagnostics-title-baseline.yaml +24 -0
  225. dataface/core/defaults/themes/diagnostics-title-center.yaml +8 -0
  226. dataface/core/defaults/themes/diagnostics-title-color-extreme.yaml +24 -0
  227. dataface/core/defaults/themes/diagnostics-title-font-extreme.yaml +25 -0
  228. dataface/core/defaults/themes/diagnostics-title-left.yaml +8 -0
  229. dataface/core/defaults/themes/diagnostics-title-offset-extreme.yaml +9 -0
  230. dataface/core/defaults/themes/diagnostics-title-size-extreme.yaml +24 -0
  231. dataface/core/defaults/themes/diagnostics-title-weight-extreme.yaml +24 -0
  232. dataface/core/defaults/themes/editorial.yaml +147 -0
  233. dataface/core/defaults/themes/light.yaml +30 -0
  234. dataface/core/defaults/themes/looker.yaml +17 -0
  235. dataface/core/defaults/themes/stark.yaml +134 -0
  236. dataface/core/errors/__init__.py +67 -0
  237. dataface/core/errors/codes_compile.py +56 -0
  238. dataface/core/errors/codes_execute.py +177 -0
  239. dataface/core/errors/codes_render.py +106 -0
  240. dataface/core/errors/codes_unknown.py +15 -0
  241. dataface/core/errors/hints.py +74 -0
  242. dataface/core/errors/registry.py +42 -0
  243. dataface/core/errors/structured.py +92 -0
  244. dataface/core/execute/__init__.py +91 -0
  245. dataface/core/execute/adapters/__init__.py +49 -0
  246. dataface/core/execute/adapters/adapter_registry.py +400 -0
  247. dataface/core/execute/adapters/base.py +245 -0
  248. dataface/core/execute/adapters/csv_adapter.py +239 -0
  249. dataface/core/execute/adapters/dbt_adapter.py +283 -0
  250. dataface/core/execute/adapters/dbt_adapter_factory.py +212 -0
  251. dataface/core/execute/adapters/dbt_macro_loader.py +95 -0
  252. dataface/core/execute/adapters/dbt_utils.py +150 -0
  253. dataface/core/execute/adapters/http_adapter.py +224 -0
  254. dataface/core/execute/adapters/metricflow_adapter.py +94 -0
  255. dataface/core/execute/adapters/schema_resolver_adapter.py +144 -0
  256. dataface/core/execute/adapters/sql_adapter.py +710 -0
  257. dataface/core/execute/adapters/values_adapter.py +58 -0
  258. dataface/core/execute/batch.py +744 -0
  259. dataface/core/execute/cache_backend.py +135 -0
  260. dataface/core/execute/cache_keys.py +66 -0
  261. dataface/core/execute/dbt_jinja.py +21 -0
  262. dataface/core/execute/dialects/__init__.py +121 -0
  263. dataface/core/execute/dialects/athena.py +75 -0
  264. dataface/core/execute/dialects/base.py +302 -0
  265. dataface/core/execute/dialects/bigquery.py +38 -0
  266. dataface/core/execute/dialects/databricks.py +68 -0
  267. dataface/core/execute/dialects/duckdb.py +35 -0
  268. dataface/core/execute/dialects/mysql.py +68 -0
  269. dataface/core/execute/dialects/postgres.py +39 -0
  270. dataface/core/execute/dialects/redshift.py +12 -0
  271. dataface/core/execute/dialects/snowflake.py +51 -0
  272. dataface/core/execute/dialects/sqlserver.py +92 -0
  273. dataface/core/execute/duckdb_cache.py +712 -0
  274. dataface/core/execute/duckdb_config.py +26 -0
  275. dataface/core/execute/errors.py +213 -0
  276. dataface/core/execute/executor.py +1249 -0
  277. dataface/core/execute/parallel.py +162 -0
  278. dataface/core/execute/setup_sql.py +58 -0
  279. dataface/core/execute/source_registry.py +72 -0
  280. dataface/core/execute/source_resolver.py +255 -0
  281. dataface/core/execute/sql_guard.py +387 -0
  282. dataface/core/execute/sql_literals.py +199 -0
  283. dataface/core/fonts.py +52 -0
  284. dataface/core/inspect/__init__.py +32 -0
  285. dataface/core/inspect/cache_factory.py +98 -0
  286. dataface/core/inspect/db_types.py +162 -0
  287. dataface/core/inspect/dbt_schema.py +96 -0
  288. dataface/core/inspect/defaults.yml +37 -0
  289. dataface/core/inspect/fanout_risk.py +109 -0
  290. dataface/core/inspect/manifest_utils.py +77 -0
  291. dataface/core/inspect/partials/categorical.yml +40 -0
  292. dataface/core/inspect/partials/date.yml +40 -0
  293. dataface/core/inspect/partials/numeric.yml +55 -0
  294. dataface/core/inspect/partition_types.py +38 -0
  295. dataface/core/inspect/query_validator.py +975 -0
  296. dataface/core/inspect/renderer.py +354 -0
  297. dataface/core/inspect/resolver.py +808 -0
  298. dataface/core/inspect/search.py +461 -0
  299. dataface/core/inspect/sources/__init__.py +32 -0
  300. dataface/core/inspect/sources/dbt.py +738 -0
  301. dataface/core/inspect/sources/duckdb_utils.py +66 -0
  302. dataface/core/inspect/templates/__init__.py +1 -0
  303. dataface/core/inspect/templates/categorical_column.yml +196 -0
  304. dataface/core/inspect/templates/charts.yml +109 -0
  305. dataface/core/inspect/templates/date_column.yml +248 -0
  306. dataface/core/inspect/templates/model.yml +138 -0
  307. dataface/core/inspect/templates/numeric_column.yml +261 -0
  308. dataface/core/inspect/templates/quality.yml +80 -0
  309. dataface/core/inspect/templates/string_column.yml +263 -0
  310. dataface/core/project_roots.py +165 -0
  311. dataface/core/render/__init__.py +87 -0
  312. dataface/core/render/board_links.py +176 -0
  313. dataface/core/render/chart/__init__.py +27 -0
  314. dataface/core/render/chart/arc_attached_table.py +251 -0
  315. dataface/core/render/chart/artifacts.py +16 -0
  316. dataface/core/render/chart/callout.py +225 -0
  317. dataface/core/render/chart/decisions.py +358 -0
  318. dataface/core/render/chart/geo.py +700 -0
  319. dataface/core/render/chart/kpi.py +916 -0
  320. dataface/core/render/chart/labels.py +76 -0
  321. dataface/core/render/chart/pipeline.py +818 -0
  322. dataface/core/render/chart/presentation.py +36 -0
  323. dataface/core/render/chart/profile.py +3438 -0
  324. dataface/core/render/chart/render_single.py +347 -0
  325. dataface/core/render/chart/renderers.py +193 -0
  326. dataface/core/render/chart/rendering.py +565 -0
  327. dataface/core/render/chart/serialization.py +90 -0
  328. dataface/core/render/chart/spark.py +496 -0
  329. dataface/core/render/chart/spark_bar.py +370 -0
  330. dataface/core/render/chart/spec_builders.py +154 -0
  331. dataface/core/render/chart/standard_renderer.py +2645 -0
  332. dataface/core/render/chart/table.py +2957 -0
  333. dataface/core/render/chart/table_support.py +1452 -0
  334. dataface/core/render/chart/tick_values.py +66 -0
  335. dataface/core/render/chart/time_unit_detect.py +809 -0
  336. dataface/core/render/chart/title_overflow.py +157 -0
  337. dataface/core/render/chart/type_inference.py +122 -0
  338. dataface/core/render/chart/validation.py +99 -0
  339. dataface/core/render/chart/vega_lite.py +125 -0
  340. dataface/core/render/chart/vega_lite_types.py +268 -0
  341. dataface/core/render/chart/vl_field_maps.py +346 -0
  342. dataface/core/render/chart_interactivity.py +24 -0
  343. dataface/core/render/control_registry.py +287 -0
  344. dataface/core/render/converters/__init__.py +24 -0
  345. dataface/core/render/converters/chart.py +276 -0
  346. dataface/core/render/converters/html.py +98 -0
  347. dataface/core/render/converters/pdf.py +40 -0
  348. dataface/core/render/converters/png.py +41 -0
  349. dataface/core/render/errors.py +144 -0
  350. dataface/core/render/face_api.py +160 -0
  351. dataface/core/render/faces.py +1194 -0
  352. dataface/core/render/font_measurement.py +48 -0
  353. dataface/core/render/font_support.py +197 -0
  354. dataface/core/render/fonts/DFTSansTabular-Regular.ttf +0 -0
  355. dataface/core/render/fonts/DFTSansTabular-Regular.woff2 +0 -0
  356. dataface/core/render/fonts/DFTSerifOldstyleProportional-Regular.ttf +0 -0
  357. dataface/core/render/fonts/DFTSerifOldstyleTabular-Regular.ttf +0 -0
  358. dataface/core/render/fonts/InterVariable.ttf +0 -0
  359. dataface/core/render/fonts/InterVariable.woff2 +0 -0
  360. dataface/core/render/fonts/NOTO_COLOR_EMOJI_LICENSE.txt +93 -0
  361. dataface/core/render/fonts/NOTO_EMOJI_LICENSE.txt +93 -0
  362. dataface/core/render/fonts/NotoColorEmoji-Regular.ttf +0 -0
  363. dataface/core/render/fonts/NotoColorEmoji-Regular.woff2 +0 -0
  364. dataface/core/render/fonts/NotoEmoji-Regular.ttf +0 -0
  365. dataface/core/render/fonts/NotoEmoji-Regular.woff2 +0 -0
  366. dataface/core/render/fonts/SOURCE_CODE_PRO_LICENSE.txt +93 -0
  367. dataface/core/render/fonts/SOURCE_SERIF_4_LICENSE.txt +98 -0
  368. dataface/core/render/fonts/SourceCodePro-Regular.ttf +0 -0
  369. dataface/core/render/fonts/SourceSerif4-Regular.ttf +0 -0
  370. dataface/core/render/fonts/_emoji_font_face.css +43 -0
  371. dataface/core/render/fonts/source-serif-4-variable-latin.woff2 +0 -0
  372. dataface/core/render/format_utils.py +329 -0
  373. dataface/core/render/geo_defaults.yml +28 -0
  374. dataface/core/render/json_format.py +146 -0
  375. dataface/core/render/layout_sizing.py +865 -0
  376. dataface/core/render/layouts.py +541 -0
  377. dataface/core/render/markdown_defaults.yml +16 -0
  378. dataface/core/render/missing_vars_prompt.py +79 -0
  379. dataface/core/render/placeholder.py +389 -0
  380. dataface/core/render/render_result.py +14 -0
  381. dataface/core/render/renderer.py +467 -0
  382. dataface/core/render/script_embedding.py +16 -0
  383. dataface/core/render/svg_utils.py +212 -0
  384. dataface/core/render/template_loader.py +69 -0
  385. dataface/core/render/templates/controls/_styles.css +606 -0
  386. dataface/core/render/templates/controls/checkbox.html +16 -0
  387. dataface/core/render/templates/controls/date.html +16 -0
  388. dataface/core/render/templates/controls/number.html +19 -0
  389. dataface/core/render/templates/controls/readonly.html +9 -0
  390. dataface/core/render/templates/controls/select.html +21 -0
  391. dataface/core/render/templates/controls/slider.html +22 -0
  392. dataface/core/render/templates/controls/text.html +16 -0
  393. dataface/core/render/templates/scripts/chart_interactivity.js +191 -0
  394. dataface/core/render/templates/scripts/variables.js +976 -0
  395. dataface/core/render/templates/svg/grid_pattern.svg +3 -0
  396. dataface/core/render/templates/svg/styles.css +51 -0
  397. dataface/core/render/terminal.py +311 -0
  398. dataface/core/render/terminal_charts.py +563 -0
  399. dataface/core/render/terminal_defaults.yml +2 -0
  400. dataface/core/render/terminal_layouts.py +299 -0
  401. dataface/core/render/terminal_text.py +31 -0
  402. dataface/core/render/text/__init__.py +1 -0
  403. dataface/core/render/text/case.py +113 -0
  404. dataface/core/render/text_format.py +129 -0
  405. dataface/core/render/utils.py +106 -0
  406. dataface/core/render/variable_controls.py +946 -0
  407. dataface/core/render/variable_input_refinement.py +140 -0
  408. dataface/core/render/warnings/__init__.py +15 -0
  409. dataface/core/render/warnings/bar_color_1_to_1_with_x.py +80 -0
  410. dataface/core/render/warnings/base.py +44 -0
  411. dataface/core/render/warnings/fanout_risk.py +15 -0
  412. dataface/core/render/warnings/from_query_diagnostic.py +56 -0
  413. dataface/core/render/warnings/missing_join_predicate.py +13 -0
  414. dataface/core/render/warnings/query_parse_error.py +14 -0
  415. dataface/core/render/warnings/query_returned_zero_rows.py +42 -0
  416. dataface/core/render/warnings/reaggregation.py +14 -0
  417. dataface/core/render/warnings/registry.py +45 -0
  418. dataface/core/render/warnings/suppression.py +46 -0
  419. dataface/core/render/warnings/temporal_single_point.py +63 -0
  420. dataface/core/render/warnings/unreferenced_chart.py +15 -0
  421. dataface/core/render/warnings/y_encoding_mostly_null.py +76 -0
  422. dataface/core/render/yaml_format.py +167 -0
  423. dataface/core/resolve_face.py +195 -0
  424. dataface/core/schema/__init__.py +0 -0
  425. dataface/core/schema/guidance.py +151 -0
  426. dataface/core/scoped_paths.py +59 -0
  427. dataface/core/serve/__init__.py +14 -0
  428. dataface/core/serve/bootstrap.py +39 -0
  429. dataface/core/serve/embedded.py +57 -0
  430. dataface/core/serve/port.py +129 -0
  431. dataface/core/serve/server.py +938 -0
  432. dataface/core/serve/templates/__init__.py +0 -0
  433. dataface/core/serve/templates/directory.yml +6 -0
  434. dataface/core/serve/templates/error.html.j2 +217 -0
  435. dataface/core/utils.py +121 -0
  436. dataface/core/validate.py +64 -0
  437. dataface/integrations/__init__.py +0 -0
  438. dataface/integrations/highlighting.py +351 -0
  439. dataface/integrations/markdown.py +537 -0
  440. dataface/py.typed +0 -0
  441. dataface-0.1.2.dist-info/METADATA +375 -0
  442. dataface-0.1.2.dist-info/RECORD +455 -0
  443. dataface-0.1.2.dist-info/WHEEL +4 -0
  444. dataface-0.1.2.dist-info/entry_points.txt +2 -0
  445. dataface-0.1.2.dist-info/licenses/LICENSE +202 -0
  446. mdsvg/__init__.py +168 -0
  447. mdsvg/fonts.py +656 -0
  448. mdsvg/images.py +299 -0
  449. mdsvg/parser.py +629 -0
  450. mdsvg/playground.py +284 -0
  451. mdsvg/py.typed +2 -0
  452. mdsvg/renderer.py +1623 -0
  453. mdsvg/style.py +355 -0
  454. mdsvg/types.py +200 -0
  455. mdsvg/utils.py +86 -0
@@ -0,0 +1,1682 @@
1
+ """Style cascade-merge models — frozen dataclasses and merge logic.
2
+
3
+ MergedXxx are frozen dataclasses (post-cascade, all fields required).
4
+ resolve_style() merges a compiled Style with optional *Patch overlays and
5
+ returns a MergedStyle for consumption by render/.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ import dataclasses
12
+ import re
13
+ import types
14
+ from typing import Any, Literal, get_args, get_origin
15
+
16
+ from pydantic import BaseModel
17
+
18
+ from dataface.core.compile.models.primitives import (
19
+ BorderStyle,
20
+ FontStyle,
21
+ MergedFontStyle,
22
+ SpacingValues,
23
+ StrokeStyle,
24
+ )
25
+ from dataface.core.compile.models.style.compiled import (
26
+ AreaChartStyle,
27
+ AutosizeStyle,
28
+ AxisStyle,
29
+ AxisStylePatch,
30
+ BarChartStyle,
31
+ BoardStyle,
32
+ BoxplotChartStyle,
33
+ CalloutChartStyle,
34
+ ChartsStyle,
35
+ DataTableStyle,
36
+ ErrorbandChartStyle,
37
+ ErrorbarChartStyle,
38
+ GeoshapeChartStyle,
39
+ GlobalMarksStyle,
40
+ HeatmapChartStyle,
41
+ HistogramChartStyle,
42
+ InferenceStyle,
43
+ KpiChartStyle,
44
+ LayoutStyle,
45
+ LegendStyle,
46
+ LineChartStyle,
47
+ PaddingStyle,
48
+ PageStyle,
49
+ PaginationConfig,
50
+ PieChartStyle,
51
+ PlaceholderStyle,
52
+ PointMapChartStyle,
53
+ ScaleStyle,
54
+ ScatterChartStyle,
55
+ SeriesLabelStyle,
56
+ SparkBarChartStyle,
57
+ SparkStyle,
58
+ Style,
59
+ TableChartStyle,
60
+ TextStyle,
61
+ TitleStyle,
62
+ TooltipStyle,
63
+ VariablesStyle,
64
+ ViewStyle,
65
+ )
66
+ from dataface.core.fonts import NOTO_COLOR_EMOJI_FONT_FAMILY, NOTO_EMOJI_FONT_FAMILY
67
+
68
+ _DEFAULT_ROOT_FONT = FontStyle(
69
+ family="Inter",
70
+ color="#222222",
71
+ size=14.0,
72
+ weight="400",
73
+ style="normal",
74
+ decoration="none",
75
+ case="none",
76
+ )
77
+
78
+
79
+ @dataclasses.dataclass(frozen=True)
80
+ class MergedAxisElementStyle:
81
+ """Merged axis label or title — all fields required, no defaults.
82
+
83
+ Chart-local Patch fields (label.angle, label.padding, etc.) flow through
84
+ the typed ``axis_overrides_*`` sentinels on ``MergedChartsStyle`` and
85
+ are merged at emit time by ``resolved_axis_style``. They never land on a
86
+ Merged axis directly.
87
+ """
88
+
89
+ font: MergedFontStyle
90
+ padding: float
91
+ # Optional VL-passthrough fields — carried from theme through resolve boundary.
92
+ # None means "use VL default". Filled by _fill_axis cascade; passed through
93
+ # _build_resolved_axis. Mirrors AxisElementStyle passthrough fields.
94
+ max_width: float | None = None
95
+ angle: float | None = None
96
+ align: str | None = None
97
+ baseline: str | None = None
98
+ overlap: str | None = None
99
+ separation: float | None = None
100
+ visible: bool | None = None # None = cascade-inherit from parent axis slot
101
+ time_unit: str | None = None
102
+ expr: str | None = None
103
+ bound: bool | float | None = None
104
+ flush: bool | float | None = None
105
+ offset: float | None = None
106
+ line_height: float | None = None
107
+ anchor: str | None = None
108
+ tilt_increments: list[float] | None = None
109
+
110
+
111
+ @dataclasses.dataclass(frozen=True)
112
+ class MergedAxisGridZeroStyle:
113
+ color: str
114
+ width: float
115
+
116
+
117
+ @dataclasses.dataclass(frozen=True)
118
+ class MergedAxisGridStyle:
119
+ """No defaults — must be explicitly constructed via resolve_style().
120
+
121
+ Same scope as MergedAxisElementStyle: chart-local grid overrides
122
+ (e.g. ``style.axis_y.grid.dash``) flow through ``axis_overrides_*``
123
+ and merge at emit time.
124
+ """
125
+
126
+ visible: bool
127
+ opacity: float
128
+ width: float
129
+ color: str
130
+ zero: MergedAxisGridZeroStyle
131
+
132
+
133
+ @dataclasses.dataclass(frozen=True)
134
+ class MergedAxisDomainStyle:
135
+ visible: bool
136
+ width: float
137
+ color: str
138
+
139
+
140
+ @dataclasses.dataclass(frozen=True)
141
+ class MergedAxisTicksStyle:
142
+ visible: bool
143
+ color: str
144
+ # None means "use VL default" — kept distinct from a numeric override.
145
+ width: float | None
146
+ size: float | None
147
+ # Target tick count for quantitative axes. None → VL picks automatically.
148
+ count: int | None
149
+
150
+
151
+ @dataclasses.dataclass(frozen=True)
152
+ class MergedAxisStyle:
153
+ """Merged per-axis style — theme-only.
154
+
155
+ All fields come through resolve_style(). Chart-local axis overrides
156
+ (style.axis_x, style.axis_y, etc.) are NOT merged onto these fields —
157
+ they flow through the typed ``axis_overrides_*`` sentinels on
158
+ ``MergedChartsStyle`` and merge at emit time via ``resolved_axis_style``.
159
+ Storing chart-local fields here would force the renderer to discriminate
160
+ authored vs theme values, which the cascade rename eliminates.
161
+ """
162
+
163
+ grid: MergedAxisGridStyle
164
+ domain: MergedAxisDomainStyle
165
+ ticks: MergedAxisTicksStyle
166
+ label: MergedAxisElementStyle
167
+ title: MergedAxisElementStyle
168
+ orient: str | None # None is a valid concrete value ("use VL default")
169
+ categorical_orient: str | None
170
+ offset: float | None
171
+ scale: ScaleStyle | None # per-axis scale override (board cascade only)
172
+ fill: Literal[
173
+ "null",
174
+ "zero",
175
+ "linear",
176
+ "step-after",
177
+ "step-before",
178
+ "step-center",
179
+ "curve",
180
+ ] # only active on axis_x ordinal bucketed-time charts
181
+ format: str | None # tick-label format string; None uses VL auto-format
182
+
183
+
184
+ @dataclasses.dataclass(frozen=True)
185
+ class MergedLegendElementStyle:
186
+ font: MergedFontStyle
187
+ padding: float
188
+
189
+
190
+ @dataclasses.dataclass(frozen=True)
191
+ class MergedLegendStyle:
192
+ """Merged legend style.
193
+
194
+ ``disable`` is a cascade-managed UX-default sentinel: None means
195
+ "legend visible" (neither theme nor chart suppressed it); True means
196
+ "explicitly suppressed" — set by the theme via _build_resolved_legend
197
+ or by a chart-local patch via build_resolved_style. The render layer
198
+ at _build_encoding_legend checks ``disable is True`` to emit VL
199
+ ``legend: null``.
200
+ """
201
+
202
+ orient: str
203
+ direction: str
204
+ label: MergedLegendElementStyle
205
+ title: MergedLegendElementStyle
206
+ interactive_legend: bool
207
+ disable: bool | None = None
208
+
209
+
210
+ @dataclasses.dataclass(frozen=True)
211
+ class MergedCalloutElementStyle:
212
+ font: MergedFontStyle
213
+ y_offset: float
214
+
215
+
216
+ @dataclasses.dataclass(frozen=True)
217
+ class MergedCalloutChartStyle:
218
+ tone: str | None
219
+ background: str
220
+ border: BorderStyle
221
+ padding: float
222
+ section_gap: float
223
+ title: MergedCalloutElementStyle
224
+ message: MergedCalloutElementStyle
225
+
226
+
227
+ @dataclasses.dataclass(frozen=True)
228
+ class MergedChartsStyle:
229
+ """Merged charts — all fields required, no defaults.
230
+
231
+ Board-level fields (title, pagination, font_family) are carried here for the
232
+ VL config mapper (n()) which takes MergedChartsStyle, not the full
233
+ MergedStyle. They are required (not defaulted) to enforce that every
234
+ MergedChartsStyle is constructed by _build_resolved_charts().
235
+ """
236
+
237
+ palette: list[str]
238
+ # Dash palette for line-family marks. None means "no strokeDash encoding";
239
+ # themes that want dash-based categorical distinction set this. The render
240
+ # layer reads None and skips emission. Cascade-managed sentinel.
241
+ dashes: list[list[int]] | None
242
+ # Theme-resolved axis layers (Layers 1+2+3 of the cascade). build_resolved_style
243
+ # does NOT merge chart-local axis Patches into these — they're stored on
244
+ # axis_overrides_* below so resolved_axis_style can apply them in the canonical
245
+ # cascade order at emit time. Renderer reads the merged AxisStyle that
246
+ # resolved_axis_style returns, never the override Patches directly.
247
+ axis: MergedAxisStyle
248
+ axis_x: MergedAxisStyle
249
+ axis_y: MergedAxisStyle
250
+ axis_quantitative: MergedAxisStyle
251
+ legend: MergedLegendStyle
252
+ # Remaining sections keep compiled types (skeleton — centralize task fills these)
253
+ inference: InferenceStyle
254
+ # Global mark defaults (tier 1 of three-tier cascade). Renderers read mark
255
+ # fields from per-family chart styles via resolve_mark(charts.marks.<mark>,
256
+ # family.marks.<mark>). This field is also read by style_to_vega_lite for
257
+ # the VL config emit (circle, square, tick, rule, etc.).
258
+ marks: GlobalMarksStyle
259
+ bar: BarChartStyle
260
+ line: LineChartStyle
261
+ area: AreaChartStyle
262
+ scatter: ScatterChartStyle
263
+ view: ViewStyle
264
+ # Series-label primitive — shared typography for endpoint labels,
265
+ # direct stack labels, and slice callouts.
266
+ series_label: SeriesLabelStyle
267
+ pie: PieChartStyle
268
+ geoshape: GeoshapeChartStyle
269
+ point_map: PointMapChartStyle
270
+ histogram: HistogramChartStyle
271
+ heatmap: HeatmapChartStyle
272
+ boxplot: BoxplotChartStyle
273
+ errorbar: ErrorbarChartStyle
274
+ errorband: ErrorbandChartStyle
275
+ autosize: AutosizeStyle
276
+ # Remaining chart dimensions (not yet resolved to concrete layout values)
277
+ aspect_ratio: float
278
+ min_height: float
279
+ max_height: float
280
+ padding: PaddingStyle
281
+ border: BorderStyle
282
+ animation_duration: float
283
+ kpi: KpiChartStyle
284
+ table: TableChartStyle
285
+ spark: SparkStyle
286
+ spark_bar: SparkBarChartStyle
287
+ data_table: DataTableStyle
288
+ # Callout chart-family style (type: callout and runtime chart-error fallback)
289
+ callout: MergedCalloutChartStyle
290
+ # Authorable chart rendering settings — migrated from chart_rendering config
291
+ tooltip: TooltipStyle
292
+ font_family: str | None # root font.family; emitted as top-level VL `font`
293
+ title: TitleStyle
294
+ pagination: PaginationConfig | None
295
+
296
+ # Format alias vocabulary from the root Style.formats cascade.
297
+ # None = theme defines no format aliases. Render callsites pass this to
298
+ # resolve_format() so aliases resolve through the face→theme cascade.
299
+ formats: dict[str, str] | None = None
300
+
301
+ # Chart-local axis overrides (Layers 4/5/6) as typed AxisStylePatch
302
+ # sentinels — populated only by build_resolved_style from the chart's
303
+ # ChartStylePatch. None = no chart-local override on that axis variant.
304
+ # These are NOT exposed to chart emit code directly; resolved_axis_style is
305
+ # the single canonical reader, returning a fully-merged AxisStyle.
306
+ axis_overrides_global: AxisStylePatch | None = None
307
+ axis_overrides_x: AxisStylePatch | None = None
308
+ axis_overrides_y: AxisStylePatch | None = None
309
+ axis_overrides_quantitative: AxisStylePatch | None = None
310
+ axis_overrides_band: AxisStylePatch | None = None
311
+ # Chart-level scale override — populated only by build_resolved_style from
312
+ # style.scale. None = no chart-local global scale override.
313
+ scale: ScaleStyle | None = None
314
+ # Chart-local-only Vega passthroughs — populated by build_resolved_style
315
+ # from style.color / style.background / style.border. None = no chart-local
316
+ # override (renderer falls back to theme defaults).
317
+ color: str | None = None
318
+ # Chart-local background override — set by ChartStylePatch.background when
319
+ # a chart authors a per-chart background color. None = no chart-local override;
320
+ # standard_renderer._resolve_effective_background() falls back to face/theme.
321
+ background: str | None = None
322
+
323
+
324
+ @dataclasses.dataclass(frozen=True)
325
+ class MergedStyle:
326
+ """Final merged style — all fields required, no defaults.
327
+
328
+ The sole type accepted by the render/sizing layer. Every instance must
329
+ be produced by resolve_style() — never constructed directly.
330
+ """
331
+
332
+ background: str
333
+ accent: str
334
+ muted: str
335
+ font: MergedFontStyle
336
+ border: BorderStyle
337
+ box_shadow: str | None # None = no shadow (valid concrete value)
338
+ opacity: float
339
+ board: BoardStyle
340
+ title: TitleStyle
341
+ text: TextStyle
342
+ placeholder: PlaceholderStyle
343
+ charts: MergedChartsStyle
344
+ layout: LayoutStyle
345
+ variables: VariablesStyle
346
+ page: PageStyle
347
+ # Per-face CSS-chrome. None = not authored; theme supplies no default.
348
+ padding: SpacingValues | None
349
+ margin: SpacingValues | None
350
+ gap: float | None
351
+ color: str | None
352
+
353
+
354
+ def effective_padding(resolved: MergedStyle) -> SpacingValues:
355
+ """Return padding with border-occupancy added (half stroke inside the box)."""
356
+ pad = resolved.padding or SpacingValues()
357
+ bw = resolved.border.width
358
+ return SpacingValues(
359
+ top=pad.top + bw,
360
+ bottom=pad.bottom + bw,
361
+ left=pad.left + bw,
362
+ right=pad.right + bw,
363
+ )
364
+
365
+
366
+ # =============================================================================
367
+ # resolve_style
368
+ # =============================================================================
369
+
370
+
371
+ def _fill_font(target: FontStyle | None, source: FontStyle) -> FontStyle:
372
+ """Fill None fields in target from source (cascade inheritance).
373
+
374
+ If target is None (per-family font not authored), return source as-is.
375
+ """
376
+ if target is None:
377
+ return source
378
+ return FontStyle(
379
+ family=target.family if target.family is not None else source.family,
380
+ color=target.color if target.color is not None else source.color,
381
+ size=target.size if target.size is not None else source.size,
382
+ weight=target.weight if target.weight is not None else source.weight,
383
+ style=target.style if target.style is not None else source.style,
384
+ decoration=(
385
+ target.decoration if target.decoration is not None else source.decoration
386
+ ),
387
+ case=target.case if target.case is not None else source.case,
388
+ )
389
+
390
+
391
+ def resolve_cascaded_font(font: FontStyle, path: str = "") -> MergedFontStyle:
392
+ """Convert FontStyle to MergedFontStyle, raising if any required field is None."""
393
+ if (
394
+ font.family is None
395
+ or font.color is None
396
+ or font.size is None
397
+ or font.weight is None
398
+ or font.style is None
399
+ or font.decoration is None
400
+ or font.case is None
401
+ ):
402
+ missing = [
403
+ f
404
+ for f, v in [
405
+ ("family", font.family),
406
+ ("color", font.color),
407
+ ("size", font.size),
408
+ ("weight", font.weight),
409
+ ("style", font.style),
410
+ ("decoration", font.decoration),
411
+ ("case", font.case),
412
+ ]
413
+ if v is None
414
+ ]
415
+ raise ValueError(
416
+ f"Cannot resolve font{' at ' + path if path else ''}: "
417
+ f"missing required fields after cascade: {missing}"
418
+ )
419
+ return MergedFontStyle(
420
+ family=font.family,
421
+ color=font.color,
422
+ size=font.size,
423
+ weight=font.weight,
424
+ style=font.style,
425
+ decoration=font.decoration,
426
+ case=font.case,
427
+ )
428
+
429
+
430
+ # Dotted palette token: "<palette-name>.<slot>" — e.g. "dft-grays.gray-30",
431
+ # "negative.solid", "category-10.0". The leading character must be a letter so
432
+ # that hex literals (#abcdef), CSS rgb()/rgba() values, decimal numerics like
433
+ # "0.5", and quoted font-family lists never match.
434
+ #
435
+ # Contract: any string anywhere in Style that matches this regex is
436
+ # treated as a palette token. If a future non-color string field could match
437
+ # (none today), it must be excluded from the walk or the field reshaped.
438
+ _COLOR_TOKEN_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*\.[A-Za-z0-9_-]+$")
439
+
440
+
441
+ # Theme-self tokens: strings that name a value elsewhere in the same compiled
442
+ # style tree, resolved against the root Style. The canonical use case
443
+ # is canvas coupling — any field that should track the theme's background
444
+ # color (knockout strokes, halos) sets the field to ``theme.background`` and
445
+ # the resolver substitutes the theme's actual ``style.background`` value.
446
+ #
447
+ # Resolution runs BEFORE ``_resolve_color_tokens`` so the substituted value
448
+ # (which may itself be a palette token like ``dft-creams.cream-025``) is
449
+ # carried through the color-token pass. Each theme resolves against its own
450
+ # ``style.background``, so cream's arc.stroke ends up at the cream
451
+ # canvas color while stark's stays white — without per-theme
452
+ # override duplication.
453
+ #
454
+ # Resolution runs at two points (mirroring _resolve_color_tokens): once at
455
+ # theme-load time inside ``get_compiled_theme()`` so cached themes carry
456
+ # concrete values, and again at the end of ``resolve_style()`` so face-level
457
+ # patches that introduce self-tokens are also resolved. A face that authors
458
+ # ``arc.stroke: theme.background`` in its own style block resolves the same
459
+ # way as the theme YAML did — no asymmetric contract.
460
+ _THEME_SELF_TOKENS = {"theme.background"}
461
+
462
+
463
+ def _self_token_replacement(token: str, root: Style) -> str:
464
+ """Look up a theme-self token's replacement value on ``root``.
465
+
466
+ ``token`` is guaranteed to be a member of ``_THEME_SELF_TOKENS`` by the
467
+ caller; raises ``ValueError`` for any unknown token so a future
468
+ ``_THEME_SELF_TOKENS`` addition without a matching branch fails loudly
469
+ instead of silently leaking the literal sentinel into compiled output.
470
+ """
471
+ if token == "theme.background":
472
+ return root.background
473
+ raise ValueError(f"Unknown theme-self token: {token!r}")
474
+
475
+
476
+ def _resolve_self_tokens(node: Any, root: Any | None = None) -> Any:
477
+ """Walk a Style subtree and substitute theme-self tokens.
478
+
479
+ ``root`` is the top-level Style the substitution looks values up
480
+ on; it defaults to ``node`` for the initial top-level call. The recursion
481
+ keeps the same root so deeply nested fields (e.g. ``charts.arc.stroke.color``)
482
+ resolve against the theme root, not against an enclosing sub-model.
483
+ """
484
+ if root is None:
485
+ root = node
486
+ if isinstance(node, BaseModel):
487
+ updates: dict[str, Any] = {}
488
+ for name, value in node:
489
+ new = _resolve_self_tokens(value, root)
490
+ if new is not value:
491
+ updates[name] = new
492
+ return node.model_copy(update=updates) if updates else node
493
+ if isinstance(node, str):
494
+ if node in _THEME_SELF_TOKENS:
495
+ return _self_token_replacement(node, root)
496
+ return node
497
+ if isinstance(node, list):
498
+ new_list = [_resolve_self_tokens(item, root) for item in node]
499
+ return (
500
+ new_list
501
+ if any(a is not b for a, b in zip(new_list, node, strict=True))
502
+ else node
503
+ )
504
+ return node
505
+
506
+
507
+ def _resolve_one_color_token(
508
+ token: str,
509
+ palettes: dict[str, str] | None,
510
+ roles: dict[str, str] | None,
511
+ ) -> str:
512
+ """Resolve a single dotted color token to hex.
513
+
514
+ Tries the direct ``palette.slot`` path first. If the left-hand side is not
515
+ a known palette name the token is a role-indirected address (e.g.
516
+ ``chrome.ink``); try ``color_from_theme()`` with the theme palettes/roles
517
+ context. Raises ``UnknownColorError`` if both paths fail.
518
+ """
519
+ from dataface.core.compile.palette import (
520
+ UnknownColorError,
521
+ UnknownPaletteError,
522
+ color as resolve_palette_color,
523
+ color_from_theme,
524
+ )
525
+
526
+ try:
527
+ return resolve_palette_color(token)
528
+ except (UnknownColorError, UnknownPaletteError):
529
+ pass
530
+
531
+ if palettes is not None:
532
+ return color_from_theme(token, palettes=palettes, roles=roles)
533
+
534
+ raise UnknownColorError(
535
+ f"color token '{token}' is not a known palette.slot address and no "
536
+ "theme palettes context is available for role-indirection lookup"
537
+ )
538
+
539
+
540
+ def _resolve_color_tokens(
541
+ node: Any,
542
+ palettes: dict[str, str] | None = None,
543
+ roles: dict[str, str] | None = None,
544
+ ) -> Any:
545
+ """Walk a Style subtree and resolve every dotted palette token to hex.
546
+
547
+ Strings that don't look like a palette token (literal hex, rgb()/rgba(),
548
+ CSS named colors, font-family lists, etc.) pass through unchanged. Lists
549
+ are walked element-by-element so ``charts.palette`` slots that happen to
550
+ be tokens (rare) are also resolved. Dicts are not walked — no
551
+ color-bearing dicts exist on Style today; if one is added it
552
+ needs to be handled here explicitly.
553
+
554
+ ``palettes`` and ``roles`` are extracted from the root Style at call time
555
+ and passed through the recursion so role-indirected tokens (e.g.
556
+ ``chrome.ink`` where ``chrome`` maps to a palette name via the theme's
557
+ ``palettes:`` block) resolve correctly alongside direct palette.slot tokens.
558
+
559
+ Run twice: once at theme-load time inside ``get_compiled_theme()`` so
560
+ cached themes carry hex, and again at the end of ``resolve_style()`` so
561
+ face-level patches that introduce tokens are also resolved.
562
+ """
563
+ from dataface.core.compile.models.style.compiled import Style
564
+
565
+ if isinstance(node, Style):
566
+ # Extract palettes/roles context from the root Style for this walk.
567
+ palettes = node.palettes
568
+ roles = node.roles
569
+ if isinstance(node, BaseModel):
570
+ updates: dict[str, Any] = {}
571
+ for name, value in node:
572
+ new = _resolve_color_tokens(value, palettes, roles)
573
+ if new is not value:
574
+ updates[name] = new
575
+ return node.model_copy(update=updates) if updates else node
576
+ if isinstance(node, str):
577
+ if _COLOR_TOKEN_RE.match(node):
578
+ return _resolve_one_color_token(node, palettes, roles)
579
+ return node
580
+ if isinstance(node, list):
581
+ new_list = [_resolve_color_tokens(item, palettes, roles) for item in node]
582
+ return (
583
+ new_list
584
+ if any(a is not b for a, b in zip(new_list, node, strict=True))
585
+ else node
586
+ )
587
+ return node
588
+
589
+
590
+ _EMOJI_MODE_TO_FAMILY: dict[str, str] = {
591
+ "monochrome": NOTO_EMOJI_FONT_FAMILY,
592
+ "color": NOTO_COLOR_EMOJI_FONT_FAMILY,
593
+ }
594
+
595
+
596
+ def insert_emoji_family(family: str, emoji_family: str) -> str:
597
+ """Insert ``emoji_family`` after the primary font in a CSS font-family stack.
598
+
599
+ Idempotent: no-op if ``emoji_family`` is already present. Public so callers in
600
+ typography.py / sizing.py / render can append an emoji family to a raw
601
+ family string without the full resolve_style() cascade walk.
602
+ """
603
+ if emoji_family in family:
604
+ return family
605
+ # Assumes commas are CSS font-family stack separators, not quoted name characters.
606
+ first_comma = family.find(",")
607
+ if first_comma == -1:
608
+ return f"{family}, '{emoji_family}'"
609
+ return f"{family[:first_comma]}, '{emoji_family}'{family[first_comma:]}"
610
+
611
+
612
+ def apply_emoji_to_family(
613
+ raw: str,
614
+ emoji_mode: str,
615
+ ) -> str:
616
+ """Conditionally insert the appropriate emoji family into a font-family stack.
617
+
618
+ Maps ``emoji_mode`` to a bundled emoji font family name and inserts it.
619
+ Returns ``raw`` unchanged for ``system-default`` and ``disabled``.
620
+ """
621
+ emoji_family = _EMOJI_MODE_TO_FAMILY.get(emoji_mode)
622
+ if emoji_family is None:
623
+ return raw
624
+ return insert_emoji_family(raw, emoji_family)
625
+
626
+
627
+ def _append_emoji_family(node: Any, emoji_family: str) -> Any:
628
+ """Walk a style subtree and append ``emoji_family`` to every non-None FontStyle.family.
629
+
630
+ Mirrors _resolve_color_tokens in structure: walks BaseModel trees recursively,
631
+ returning a patched copy only when a field actually changes.
632
+ FontStyle never appears inside a list/dict field in this schema; if that
633
+ changes, add a list branch mirroring _resolve_color_tokens.
634
+ """
635
+ if isinstance(node, FontStyle):
636
+ if node.family is not None:
637
+ new_family = insert_emoji_family(node.family, emoji_family)
638
+ if new_family != node.family:
639
+ return node.model_copy(update={"family": new_family})
640
+ return node
641
+ if isinstance(node, BaseModel):
642
+ updates: dict[str, Any] = {}
643
+ for name, value in node:
644
+ new = _append_emoji_family(value, emoji_family)
645
+ if new is not value:
646
+ updates[name] = new
647
+ return node.model_copy(update=updates) if updates else node
648
+ return node
649
+
650
+
651
+ def _non_none_model_type(annotation: Any) -> type[BaseModel] | None:
652
+ """From `T | None`, return T if T is a BaseModel subclass, else None."""
653
+ import typing
654
+
655
+ origin = get_origin(annotation)
656
+ if origin is types.UnionType or origin is typing.Union:
657
+ non_none = [a for a in get_args(annotation) if a is not type(None)]
658
+ if (
659
+ len(non_none) == 1
660
+ and isinstance(non_none[0], type)
661
+ and issubclass(non_none[0], BaseModel)
662
+ ):
663
+ return non_none[0]
664
+ return None
665
+
666
+
667
+ def deep_merge(base: BaseModel, patch: Any | None) -> BaseModel:
668
+ """Recursively merge a patch (all-Optional) onto a compiled base.
669
+
670
+ - Non-None patch fields win over base.
671
+ - Nested BaseModel fields are merged recursively (field-by-field), so a
672
+ patch that sets only axis.label.font.color still inherits the rest of
673
+ axis.label and axis from base.
674
+ - dict fields are merged key-wise: patch keys win, base keys not in patch survive.
675
+ This allows face.style.formats to override individual theme aliases without
676
+ replacing the entire dict.
677
+ - Scalars, lists, and Any-typed fields are replaced wholesale when non-None.
678
+ - None patch fields inherit from base.
679
+ - Patch fields whose name does not exist on base are ignored (the base type
680
+ defines the field set; this lets a Merged* base accept a bare-noun *Patch
681
+ that carries extra fields the merged type chose not to model).
682
+
683
+ Returns a new instance of the same type as base.
684
+ """
685
+ if patch is None:
686
+ return base
687
+ result = {}
688
+ for field_name in type(base).model_fields:
689
+ base_val = getattr(base, field_name)
690
+ patch_val = getattr(patch, field_name, None)
691
+ if patch_val is None:
692
+ result[field_name] = base_val
693
+ elif isinstance(base_val, BaseModel) and isinstance(patch_val, BaseModel):
694
+ result[field_name] = deep_merge(base_val, patch_val)
695
+ elif isinstance(base_val, dict) and isinstance(patch_val, dict):
696
+ result[field_name] = {**base_val, **patch_val}
697
+ elif isinstance(patch_val, BaseModel):
698
+ # base_val is None — decide based on whether the compiled field type
699
+ # can be seeded from a partial patch dict (all fields have defaults).
700
+ # ScaleStyle and similar all-Optional compiled types: use the patch.
701
+ # TitleStyle and others with required fields: leave as None (callers
702
+ # like build_resolved_style extract those fields through a separate path).
703
+ ann = type(base).model_fields[field_name].annotation
704
+ target = _non_none_model_type(ann)
705
+ if target is not None and not any(
706
+ fi.is_required() for fi in target.model_fields.values()
707
+ ):
708
+ result[field_name] = patch_val.model_dump(exclude_none=True)
709
+ else:
710
+ result[field_name] = None
711
+ else:
712
+ result[field_name] = patch_val
713
+ return type(base).model_validate(result)
714
+
715
+
716
+ _M = Any # BaseModel subtype; TypeVar would require a bound import
717
+
718
+
719
+ def resolve_mark(global_mark: _M, family_override: _M | None) -> _M:
720
+ """Merge the global mark default with an optional family-level override.
721
+
722
+ Implements tier-1 + tier-2 of the three-tier mark cascade (global → family).
723
+ Face-level overrides (tier 3) are applied by build_resolved_style if present.
724
+
725
+ Returns ``global_mark`` unchanged when ``family_override`` is None.
726
+ """
727
+ if family_override is None:
728
+ return global_mark
729
+ return deep_merge(global_mark, family_override)
730
+
731
+
732
+ def _fill_none(model: _M, **tokens: Any) -> _M:
733
+ """Fill only None fields of model from tokens; explicit values are preserved."""
734
+ updates = {f: v for f, v in tokens.items() if getattr(model, f) is None}
735
+ return model.model_copy(update=updates) if updates else model
736
+
737
+
738
+ def _fill_chart_base(family_style: _M, charts: ChartsStyle) -> _M:
739
+ """Fill _ChartStyleBaseAllOptional sentinels from ChartsStyle global defaults.
740
+
741
+ Called for every per-family style in _apply_cascade. None sentinels on
742
+ the family style inherit the corresponding required value from ChartsStyle.
743
+
744
+ Only scalar fields are filled here. Nested BaseModel fields (legend, padding,
745
+ border, inference, tooltip) are now patch types (LegendStylePatch | None etc.)
746
+ due to is_recursive=True on _ChartStyleBaseAllOptional. Filling them with
747
+ compiled-type values (LegendStyle, PaddingStyle, etc.) would mix types.
748
+ Per-family legend/padding/etc. overrides authored in theme YAML are applied
749
+ in build_resolved_style via chart_type-aware patch application.
750
+ """
751
+ return _fill_none(
752
+ family_style,
753
+ aspect_ratio=charts.aspect_ratio,
754
+ min_height=charts.min_height,
755
+ max_height=charts.max_height,
756
+ animation_duration=charts.animation_duration,
757
+ palette=charts.palette,
758
+ dashes=charts.dashes,
759
+ )
760
+
761
+
762
+ def _cascade_family_marks(family: _M, global_marks: Any) -> _M:
763
+ """Cascade global mark defaults into every mark slot of a family's marks container.
764
+
765
+ For each mark field shared between the family marks container and GlobalMarksStyle,
766
+ applies resolve_mark(global_val, family_val) so that:
767
+ - None family slots inherit the full global mark (including nested required fields).
768
+ - Non-None family slots are deep-merged on top of global, preserving fields the
769
+ family doesn't override (e.g. pie.marks.slice sets gap/stroke but not labels —
770
+ labels cascades from global so build_resolved_style always has a complete base).
771
+ """
772
+ family_marks = family.marks
773
+ updates = {}
774
+ for field_name in type(family_marks).model_fields:
775
+ global_val = getattr(global_marks, field_name, None)
776
+ if global_val is None:
777
+ continue
778
+ updates[field_name] = resolve_mark(
779
+ global_val, getattr(family_marks, field_name)
780
+ )
781
+ if not updates:
782
+ return family
783
+ return family.model_copy(update={"marks": family_marks.model_copy(update=updates)})
784
+
785
+
786
+ def _fill_stroke_color(mark: Any, color: str) -> Any:
787
+ """Fill mark.stroke.color from root font color when not already set.
788
+
789
+ Used for TickMarkStyle/RuleMarkStyle whose stroke.color cascades from
790
+ root font.color via _apply_token_cascade.
791
+ """
792
+ if mark.stroke is None:
793
+ return mark.model_copy(update={"stroke": StrokeStyle(color=color)})
794
+ if mark.stroke.color is None:
795
+ return mark.model_copy(
796
+ update={"stroke": mark.stroke.model_copy(update={"color": color})}
797
+ )
798
+ return mark
799
+
800
+
801
+ def _apply_token_cascade(merged: Style) -> Style:
802
+ """Fill None sub-fields from root semantic tokens (accent, muted, font.color).
803
+
804
+ Cascade invariant (ADR-009): token cascade runs ONLY inside resolve_style(),
805
+ after all face/chart patches have been merged. Running it earlier (e.g. at
806
+ theme load time) would fill sub-fields to concrete values, making them
807
+ invisible to _fill_none and silently dropping face-level token overrides.
808
+
809
+ Tokens: accent → spark.color, spark.bar.color, spark_bar.bar.color,
810
+ input.focus_color; muted → spark.bar.background,
811
+ spark_bar.bar_background; font.color → tick.stroke.color, rule.stroke.color.
812
+ """
813
+ accent = merged.accent
814
+ muted = merged.muted
815
+ # _DEFAULT_ROOT_FONT.color is FontStyle.color (str | None), so spell out
816
+ # the literal fallback to keep root_color: str for mypy.
817
+ root_color: str = merged.font.color if merged.font.color is not None else "#222222"
818
+
819
+ spark = merged.charts.table.spark
820
+ new_spark = _fill_none(spark, color=accent).model_copy(
821
+ update={"bar": _fill_none(spark.bar, color=accent, background=muted)}
822
+ )
823
+
824
+ cascaded_marks = merged.charts.marks.model_copy(
825
+ update={
826
+ "tick": _fill_stroke_color(merged.charts.marks.tick, root_color),
827
+ "rule": _fill_stroke_color(merged.charts.marks.rule, root_color),
828
+ }
829
+ )
830
+ cascaded_charts = merged.charts.model_copy(
831
+ update={
832
+ "table": merged.charts.table.model_copy(update={"spark": new_spark}),
833
+ "spark_bar": merged.charts.spark_bar.model_copy(
834
+ update={
835
+ "bar": _fill_none(
836
+ merged.charts.spark_bar.bar, color=accent, background=muted
837
+ )
838
+ }
839
+ ),
840
+ "marks": cascaded_marks,
841
+ }
842
+ )
843
+ return merged.model_copy(
844
+ update={
845
+ "charts": cascaded_charts,
846
+ "variables": merged.variables.model_copy(
847
+ update={"input": _fill_none(merged.variables.input, focus_color=accent)}
848
+ ),
849
+ }
850
+ )
851
+
852
+
853
+ def _cascade_variables(variables: VariablesStyle, root: FontStyle) -> VariablesStyle:
854
+ """Cascade root font → variables.font → variables.{label,value,placeholder}.font."""
855
+ variables_font = _fill_font(variables.font, root)
856
+ return variables.model_copy(
857
+ update={
858
+ "font": variables_font,
859
+ "label": variables.label.model_copy(
860
+ update={"font": _fill_font(variables.label.font, variables_font)}
861
+ ),
862
+ "value": variables.value.model_copy(
863
+ update={"font": _fill_font(variables.value.font, variables_font)}
864
+ ),
865
+ "placeholder": variables.placeholder.model_copy(
866
+ update={"font": _fill_font(variables.placeholder.font, variables_font)}
867
+ ),
868
+ }
869
+ )
870
+
871
+
872
+ # Fields on AxisElementStyle that are optional VL passthroughs:
873
+ # cascaded from parent axis to child (axis_x/y/etc.) in _fill_axis,
874
+ # then carried through _build_resolved_axis to MergedAxisElementStyle.
875
+ _ELEM_PASSTHROUGH = (
876
+ "max_width",
877
+ "angle",
878
+ "align",
879
+ "baseline",
880
+ "overlap",
881
+ "separation",
882
+ "visible",
883
+ "time_unit",
884
+ "expr",
885
+ "bound",
886
+ "flush",
887
+ "offset",
888
+ "line_height",
889
+ "anchor",
890
+ "tilt_increments",
891
+ )
892
+
893
+
894
+ def _apply_cascade(merged: Style) -> Style:
895
+ """Apply semantic token cascade + ADR-003 font cascade.
896
+
897
+ Two-phase cascade applied after all patches are merged:
898
+ 1. Token cascade (_apply_token_cascade): accent/muted/font.color → spark,
899
+ spark_bar, tick, rule, focus_color.
900
+ 2. Font cascade: root font fills unset font fields at all text-bearing levels.
901
+ Hierarchy: root → charts_font → axis/legend/kpi/spark_bar/text/table;
902
+ table_font → table.header/title/more_rows/empty_state;
903
+ board_font → board.title.
904
+
905
+ Font cascade is deferred here (not at theme load time) so face-level font
906
+ overrides can propagate through the hierarchy — _fill_font only fills None
907
+ fields, so pre-filling at load time would prevent face patches propagating.
908
+ """
909
+ # Phase 1: semantic token cascade
910
+ merged = _apply_token_cascade(merged)
911
+
912
+ # Phase 2: font cascade
913
+ # Fill the root font from the fallback, then put back a RootFontStyle
914
+ # so the emoji field (only present on the root, not on nested FontStyle) is
915
+ # preserved through the cascade for the emoji-injection gate in resolve_style.
916
+ _filled_root = _fill_font(merged.font, _DEFAULT_ROOT_FONT)
917
+ root = merged.font.model_copy(
918
+ update={
919
+ "family": _filled_root.family,
920
+ "color": _filled_root.color,
921
+ "size": _filled_root.size,
922
+ "weight": _filled_root.weight,
923
+ "style": _filled_root.style,
924
+ "decoration": _filled_root.decoration,
925
+ "case": _filled_root.case,
926
+ }
927
+ )
928
+ charts_font = _fill_font(merged.charts.font, root)
929
+
930
+ def _fill_axis(axis: AxisStyle, parent: AxisStyle | None = None) -> AxisStyle:
931
+ # When parent is supplied (axis_x/y/quantitative), every None field in
932
+ # grid / domain / ticks inherits from the canonical `axis` slot. This
933
+ # lets the base theme write only intentional differences in the child
934
+ # axis types — everything else cascades from `axis`.
935
+ grid = axis.grid
936
+ domain = axis.domain
937
+ ticks = axis.ticks
938
+ if parent is not None:
939
+ grid_updates = {
940
+ f: getattr(parent.grid, f)
941
+ for f in ("visible", "opacity", "width", "color")
942
+ if getattr(grid, f) is None and getattr(parent.grid, f) is not None
943
+ }
944
+ if grid_updates:
945
+ grid = grid.model_copy(update=grid_updates)
946
+ # Cascade zero sub-object field-by-field from parent axis slot.
947
+ if parent.grid.zero is not None:
948
+ if grid.zero is None:
949
+ grid = grid.model_copy(update={"zero": parent.grid.zero})
950
+ else:
951
+ zero_updates = {
952
+ f: getattr(parent.grid.zero, f)
953
+ for f in ("color", "width")
954
+ if getattr(grid.zero, f) is None
955
+ and getattr(parent.grid.zero, f) is not None
956
+ }
957
+ if zero_updates:
958
+ grid = grid.model_copy(
959
+ update={"zero": grid.zero.model_copy(update=zero_updates)}
960
+ )
961
+ domain_updates = {
962
+ f: getattr(parent.domain, f)
963
+ for f in ("visible", "width", "color")
964
+ if getattr(domain, f) is None and getattr(parent.domain, f) is not None
965
+ }
966
+ if domain_updates:
967
+ domain = domain.model_copy(update=domain_updates)
968
+ ticks_updates = {
969
+ f: getattr(parent.ticks, f)
970
+ for f in ("visible", "color", "size", "width")
971
+ if getattr(ticks, f) is None and getattr(parent.ticks, f) is not None
972
+ }
973
+ if ticks_updates:
974
+ ticks = ticks.model_copy(update=ticks_updates)
975
+ # label/title font cascade: per-axis font fills missing fields from the
976
+ # canonical `axis` parent first, then from charts_font. Without the
977
+ # parent step, an override on `charts.axis.label.font.color` would only
978
+ # land on `config.axis` and be clobbered by `config.axisX/Y` defaults.
979
+ label_parent_font = parent.label.font if parent is not None else charts_font
980
+ title_parent_font = parent.title.font if parent is not None else charts_font
981
+ label_padding = axis.label.padding
982
+ if label_padding is None and parent is not None:
983
+ label_padding = parent.label.padding
984
+ title_padding = axis.title.padding
985
+ if title_padding is None and parent is not None:
986
+ title_padding = parent.title.padding
987
+ if parent is not None:
988
+ label_passthrough = {
989
+ f: getattr(parent.label, f)
990
+ for f in _ELEM_PASSTHROUGH
991
+ if getattr(axis.label, f) is None
992
+ and getattr(parent.label, f) is not None
993
+ }
994
+ title_passthrough = {
995
+ f: getattr(parent.title, f)
996
+ for f in _ELEM_PASSTHROUGH
997
+ if getattr(axis.title, f) is None
998
+ and getattr(parent.title, f) is not None
999
+ }
1000
+ else:
1001
+ label_passthrough = {}
1002
+ title_passthrough = {}
1003
+ return axis.model_copy(
1004
+ update={
1005
+ "grid": grid,
1006
+ "domain": domain,
1007
+ "ticks": ticks,
1008
+ "label": axis.label.model_copy(
1009
+ update={
1010
+ "font": _fill_font(
1011
+ _fill_font(axis.label.font, label_parent_font),
1012
+ charts_font,
1013
+ ),
1014
+ "padding": label_padding,
1015
+ **label_passthrough,
1016
+ }
1017
+ ),
1018
+ "title": axis.title.model_copy(
1019
+ update={
1020
+ "font": _fill_font(
1021
+ _fill_font(axis.title.font, title_parent_font),
1022
+ charts_font,
1023
+ ),
1024
+ "padding": title_padding,
1025
+ **title_passthrough,
1026
+ }
1027
+ ),
1028
+ }
1029
+ )
1030
+
1031
+ def _fill_legend(legend: LegendStyle) -> LegendStyle:
1032
+ return legend.model_copy(
1033
+ update={
1034
+ "label": legend.label.model_copy(
1035
+ update={"font": _fill_font(legend.label.font, charts_font)}
1036
+ ),
1037
+ "title": legend.title.model_copy(
1038
+ update={"font": _fill_font(legend.title.font, charts_font)}
1039
+ ),
1040
+ }
1041
+ )
1042
+
1043
+ def _fill_callout_chart(
1044
+ callout: CalloutChartStyle,
1045
+ ) -> CalloutChartStyle:
1046
+ return callout.model_copy(
1047
+ update={
1048
+ "title": callout.title.model_copy(
1049
+ update={"font": _fill_font(callout.title.font, charts_font)}
1050
+ ),
1051
+ "message": callout.message.model_copy(
1052
+ update={"font": _fill_font(callout.message.font, charts_font)}
1053
+ ),
1054
+ }
1055
+ )
1056
+
1057
+ kpi_font = _fill_font(merged.charts.kpi.font, charts_font)
1058
+ cascaded_kpi = merged.charts.kpi.model_copy(
1059
+ update={
1060
+ "font": kpi_font,
1061
+ "value": merged.charts.kpi.value.model_copy(
1062
+ update={"font": _fill_font(merged.charts.kpi.value.font, kpi_font)}
1063
+ ),
1064
+ "label": merged.charts.kpi.label.model_copy(
1065
+ update={"font": _fill_font(merged.charts.kpi.label.font, kpi_font)}
1066
+ ),
1067
+ "affix": merged.charts.kpi.affix.model_copy(
1068
+ update={"font": _fill_font(merged.charts.kpi.affix.font, kpi_font)}
1069
+ ),
1070
+ "glyph": merged.charts.kpi.glyph.model_copy(
1071
+ update={"font": _fill_font(merged.charts.kpi.glyph.font, kpi_font)}
1072
+ ),
1073
+ }
1074
+ )
1075
+
1076
+ cascaded_spark_bar = merged.charts.spark_bar.model_copy(
1077
+ update={"font": _fill_font(merged.charts.spark_bar.font, charts_font)}
1078
+ )
1079
+
1080
+ table_font = _fill_font(merged.charts.table.font, charts_font)
1081
+ cascaded_bar = merged.charts.table.spark.bar.model_copy(
1082
+ update={"font": _fill_font(merged.charts.table.spark.bar.font, charts_font)}
1083
+ )
1084
+ cascaded_spark = merged.charts.table.spark.model_copy(update={"bar": cascaded_bar})
1085
+ cascaded_table = merged.charts.table.model_copy(
1086
+ update={
1087
+ "font": table_font,
1088
+ "header": merged.charts.table.header.model_copy(
1089
+ update={"font": _fill_font(merged.charts.table.header.font, table_font)}
1090
+ ),
1091
+ "title_row": merged.charts.table.title_row.model_copy(
1092
+ update={
1093
+ "font": _fill_font(merged.charts.table.title_row.font, table_font)
1094
+ }
1095
+ ),
1096
+ "more_rows": merged.charts.table.more_rows.model_copy(
1097
+ update={
1098
+ "font": _fill_font(merged.charts.table.more_rows.font, table_font)
1099
+ }
1100
+ ),
1101
+ "empty_state": merged.charts.table.empty_state.model_copy(
1102
+ update={
1103
+ "font": _fill_font(merged.charts.table.empty_state.font, table_font)
1104
+ }
1105
+ ),
1106
+ "spark": cascaded_spark,
1107
+ }
1108
+ )
1109
+
1110
+ # data_table font cascade: charts_font → data_table.font,
1111
+ # then data_table.font → divider-less leaves (label).
1112
+ data_table_font = _fill_font(merged.charts.data_table.font, charts_font)
1113
+ cascaded_data_table = merged.charts.data_table.model_copy(
1114
+ update={
1115
+ "font": data_table_font,
1116
+ "label": merged.charts.data_table.label.model_copy(
1117
+ update={
1118
+ "font": _fill_font(
1119
+ merged.charts.data_table.label.font, data_table_font
1120
+ )
1121
+ }
1122
+ ),
1123
+ }
1124
+ )
1125
+
1126
+ cascaded_axis = _fill_axis(merged.charts.axis)
1127
+ cascaded_pie_total = merged.charts.pie.total.model_copy(
1128
+ update={
1129
+ "value": merged.charts.pie.total.value.model_copy(
1130
+ update={
1131
+ "font": _fill_font(merged.charts.pie.total.value.font, charts_font)
1132
+ }
1133
+ ),
1134
+ "label": merged.charts.pie.total.label.model_copy(
1135
+ update={
1136
+ "font": _fill_font(merged.charts.pie.total.label.font, charts_font)
1137
+ }
1138
+ ),
1139
+ }
1140
+ )
1141
+ # Pie slice labels: live under pie.marks.slice.labels after ADR-015 migration.
1142
+ # PieChartMarksStyle.slice is Optional (None = no family-level override).
1143
+ # When slice is set, cascade the font into its labels.
1144
+ pie_marks = merged.charts.pie.marks
1145
+ pie_marks_slice = pie_marks.slice
1146
+ if pie_marks_slice is not None and pie_marks_slice.labels is not None:
1147
+ cascaded_slice_labels = pie_marks_slice.labels.model_copy(
1148
+ update={"font": _fill_font(pie_marks_slice.labels.font, charts_font)}
1149
+ )
1150
+ cascaded_pie_marks_slice = pie_marks_slice.model_copy(
1151
+ update={"labels": cascaded_slice_labels}
1152
+ )
1153
+ cascaded_pie_marks = pie_marks.model_copy(
1154
+ update={"slice": cascaded_pie_marks_slice}
1155
+ )
1156
+ cascaded_pie = merged.charts.pie.model_copy(
1157
+ update={"total": cascaded_pie_total, "marks": cascaded_pie_marks}
1158
+ )
1159
+ else:
1160
+ cascaded_pie = merged.charts.pie.model_copy(
1161
+ update={"total": cascaded_pie_total}
1162
+ )
1163
+
1164
+ # Global marks — font cascade: text (renamed label) + slice labels.
1165
+ # tick/rule stroke.color already set by _apply_token_cascade (Phase 1).
1166
+ marks_updates: dict[str, Any] = {
1167
+ "text": merged.charts.marks.text.model_copy(
1168
+ update={"font": _fill_font(merged.charts.marks.text.font, charts_font)}
1169
+ ),
1170
+ }
1171
+ if merged.charts.marks.slice.labels is not None:
1172
+ marks_updates["slice"] = merged.charts.marks.slice.model_copy(
1173
+ update={
1174
+ "labels": merged.charts.marks.slice.labels.model_copy(
1175
+ update={
1176
+ "font": _fill_font(
1177
+ merged.charts.marks.slice.labels.font, charts_font
1178
+ )
1179
+ }
1180
+ )
1181
+ }
1182
+ )
1183
+ cascaded_marks = merged.charts.marks.model_copy(update=marks_updates)
1184
+
1185
+ _c = merged.charts # shorthand for the per-family fill calls below
1186
+
1187
+ cascaded_charts = merged.charts.model_copy(
1188
+ update={
1189
+ "font": charts_font,
1190
+ "axis": cascaded_axis,
1191
+ "axis_x": _fill_axis(merged.charts.axis_x, parent=cascaded_axis),
1192
+ "axis_y": _fill_axis(merged.charts.axis_y, parent=cascaded_axis),
1193
+ "axis_quantitative": _fill_axis(
1194
+ merged.charts.axis_quantitative, parent=cascaded_axis
1195
+ ),
1196
+ "legend": _fill_legend(merged.charts.legend),
1197
+ "marks": cascaded_marks,
1198
+ "series_label": merged.charts.series_label.model_copy(
1199
+ update={
1200
+ "font": _fill_font(merged.charts.series_label.font, charts_font)
1201
+ }
1202
+ ),
1203
+ # Per-family cascade: fill _ChartStyleBase sentinel fields
1204
+ # (height, width, aspect_ratio, min_height, max_height, padding,
1205
+ # border, animation_duration, palette, dashes, inference, legend,
1206
+ # tooltip) from ChartsStyle global defaults.
1207
+ # _cascade_family_marks also runs here: it deep-merges global mark
1208
+ # defaults into every slot of each family's marks container so that
1209
+ # build_resolved_style always has a complete compiled mark as the base
1210
+ # for face-level overrides (no base_val=None gaps in deep_merge).
1211
+ "bar": _cascade_family_marks(_fill_chart_base(_c.bar, _c), cascaded_marks),
1212
+ "line": _cascade_family_marks(
1213
+ _fill_chart_base(_c.line, _c), cascaded_marks
1214
+ ),
1215
+ "area": _cascade_family_marks(
1216
+ _fill_chart_base(_c.area, _c), cascaded_marks
1217
+ ),
1218
+ "scatter": _cascade_family_marks(
1219
+ _fill_chart_base(_c.scatter, _c), cascaded_marks
1220
+ ),
1221
+ "histogram": _cascade_family_marks(
1222
+ _fill_chart_base(_c.histogram, _c), cascaded_marks
1223
+ ),
1224
+ "heatmap": _cascade_family_marks(
1225
+ _fill_chart_base(_c.heatmap, _c), cascaded_marks
1226
+ ),
1227
+ "boxplot": _cascade_family_marks(
1228
+ _fill_chart_base(_c.boxplot, _c), cascaded_marks
1229
+ ),
1230
+ "errorbar": _cascade_family_marks(
1231
+ _fill_chart_base(_c.errorbar, _c), cascaded_marks
1232
+ ),
1233
+ "errorband": _cascade_family_marks(
1234
+ _fill_chart_base(_c.errorband, _c), cascaded_marks
1235
+ ),
1236
+ "geoshape": _cascade_family_marks(
1237
+ _fill_chart_base(_c.geoshape, _c), cascaded_marks
1238
+ ),
1239
+ "point_map": _cascade_family_marks(
1240
+ _fill_chart_base(_c.point_map, _c), cascaded_marks
1241
+ ),
1242
+ # For families with pre-existing font cascade, apply geometry fill
1243
+ # to the already-cascaded version so both passes take effect.
1244
+ "pie": _cascade_family_marks(
1245
+ _fill_chart_base(cascaded_pie, _c), cascaded_marks
1246
+ ),
1247
+ "kpi": _fill_chart_base(cascaded_kpi, _c),
1248
+ "table": _fill_chart_base(cascaded_table, _c),
1249
+ "spark_bar": cascaded_spark_bar,
1250
+ "data_table": cascaded_data_table,
1251
+ "callout": _fill_callout_chart(merged.charts.callout),
1252
+ }
1253
+ )
1254
+
1255
+ return merged.model_copy(
1256
+ update={
1257
+ "font": root,
1258
+ "title": merged.title.model_copy(
1259
+ update={
1260
+ "font": _fill_font(merged.title.font, root),
1261
+ "subtitle": merged.title.subtitle.model_copy(
1262
+ update={"font": _fill_font(merged.title.subtitle.font, root)}
1263
+ ),
1264
+ }
1265
+ ),
1266
+ "text": merged.text.model_copy(
1267
+ update={"font": _fill_font(merged.text.font, root)}
1268
+ ),
1269
+ "placeholder": merged.placeholder.model_copy(
1270
+ update={
1271
+ "overlay": merged.placeholder.overlay.model_copy(
1272
+ update={
1273
+ "font": _fill_font(merged.placeholder.overlay.font, root)
1274
+ }
1275
+ )
1276
+ }
1277
+ ),
1278
+ "charts": cascaded_charts,
1279
+ "layout": merged.layout.model_copy(
1280
+ update={
1281
+ "tabs": merged.layout.tabs.model_copy(
1282
+ update={"font": _fill_font(merged.layout.tabs.font, root)}
1283
+ ),
1284
+ "details": merged.layout.details.model_copy(
1285
+ update={"font": _fill_font(merged.layout.details.font, root)}
1286
+ ),
1287
+ }
1288
+ ),
1289
+ "variables": _cascade_variables(merged.variables, root),
1290
+ }
1291
+ )
1292
+
1293
+
1294
+ def _build_resolved_axis(axis: AxisStyle) -> MergedAxisStyle:
1295
+ def _require(value: Any, path: str) -> Any:
1296
+ if value is None:
1297
+ raise ValueError(
1298
+ f"{path} must be authored by the theme or cascade from axis"
1299
+ )
1300
+ return value
1301
+
1302
+ return MergedAxisStyle(
1303
+ grid=MergedAxisGridStyle(
1304
+ visible=_require(axis.grid.visible, "charts.axis.grid.visible"),
1305
+ opacity=_require(axis.grid.opacity, "charts.axis.grid.opacity"),
1306
+ width=_require(axis.grid.width, "charts.axis.grid.width"),
1307
+ color=_require(axis.grid.color, "charts.axis.grid.color"),
1308
+ zero=MergedAxisGridZeroStyle(
1309
+ color=_require(
1310
+ axis.grid.zero.color if axis.grid.zero else None,
1311
+ "charts.axis.grid.zero.color",
1312
+ ),
1313
+ width=_require(
1314
+ axis.grid.zero.width if axis.grid.zero else None,
1315
+ "charts.axis.grid.zero.width",
1316
+ ),
1317
+ ),
1318
+ ),
1319
+ domain=MergedAxisDomainStyle(
1320
+ visible=_require(axis.domain.visible, "charts.axis.domain.visible"),
1321
+ width=_require(axis.domain.width, "charts.axis.domain.width"),
1322
+ color=_require(axis.domain.color, "charts.axis.domain.color"),
1323
+ ),
1324
+ ticks=MergedAxisTicksStyle(
1325
+ visible=_require(axis.ticks.visible, "charts.axis.ticks.visible"),
1326
+ color=_require(axis.ticks.color, "charts.axis.ticks.color"),
1327
+ width=axis.ticks.width,
1328
+ size=axis.ticks.size,
1329
+ count=axis.ticks.count,
1330
+ ),
1331
+ label=MergedAxisElementStyle(
1332
+ font=resolve_cascaded_font(axis.label.font, "charts.axis.label.font"),
1333
+ padding=_require(axis.label.padding, "charts.axis.label.padding"),
1334
+ **{f: getattr(axis.label, f) for f in _ELEM_PASSTHROUGH},
1335
+ ),
1336
+ title=MergedAxisElementStyle(
1337
+ font=resolve_cascaded_font(axis.title.font, "charts.axis.title.font"),
1338
+ padding=_require(axis.title.padding, "charts.axis.title.padding"),
1339
+ **{f: getattr(axis.title, f) for f in _ELEM_PASSTHROUGH},
1340
+ ),
1341
+ orient=axis.orient,
1342
+ categorical_orient=axis.categorical_orient,
1343
+ offset=axis.offset,
1344
+ scale=axis.scale,
1345
+ fill=axis.fill,
1346
+ format=axis.format,
1347
+ )
1348
+
1349
+
1350
+ def _build_resolved_legend(legend: LegendStyle) -> MergedLegendStyle:
1351
+ return MergedLegendStyle(
1352
+ orient=legend.orient,
1353
+ direction=legend.direction,
1354
+ disable=legend.disable,
1355
+ interactive_legend=legend.interactive_legend,
1356
+ label=MergedLegendElementStyle(
1357
+ font=resolve_cascaded_font(legend.label.font, "charts.legend.label.font"),
1358
+ padding=legend.label.padding,
1359
+ ),
1360
+ title=MergedLegendElementStyle(
1361
+ font=resolve_cascaded_font(legend.title.font, "charts.legend.title.font"),
1362
+ padding=legend.title.padding,
1363
+ ),
1364
+ )
1365
+
1366
+
1367
+ def _build_resolved_callout_chart(
1368
+ callout: CalloutChartStyle,
1369
+ ) -> MergedCalloutChartStyle:
1370
+ return MergedCalloutChartStyle(
1371
+ tone=callout.tone,
1372
+ background=callout.background,
1373
+ border=callout.border,
1374
+ padding=callout.padding,
1375
+ section_gap=callout.section_gap,
1376
+ title=MergedCalloutElementStyle(
1377
+ font=resolve_cascaded_font(callout.title.font, "charts.callout.title.font"),
1378
+ y_offset=callout.title.y_offset,
1379
+ ),
1380
+ message=MergedCalloutElementStyle(
1381
+ font=resolve_cascaded_font(
1382
+ callout.message.font, "charts.callout.message.font"
1383
+ ),
1384
+ y_offset=callout.message.y_offset,
1385
+ ),
1386
+ )
1387
+
1388
+
1389
+ def _build_resolved_charts(
1390
+ charts: ChartsStyle,
1391
+ font_family: str | None,
1392
+ title: TitleStyle,
1393
+ spark: SparkStyle,
1394
+ pagination: PaginationConfig | None,
1395
+ formats: dict[str, str] | None = None,
1396
+ ) -> MergedChartsStyle:
1397
+ # Axis/legend/callout are type-transformed; the rest are supplied by params or
1398
+ # copied directly from charts where the field name matches.
1399
+ _AXIS_LEGEND_ERROR = {
1400
+ "axis",
1401
+ "axis_x",
1402
+ "axis_y",
1403
+ "axis_quantitative",
1404
+ "legend",
1405
+ "callout",
1406
+ }
1407
+ _FROM_PARAMS = {
1408
+ "font_family",
1409
+ "title",
1410
+ "spark",
1411
+ "pagination",
1412
+ "formats",
1413
+ }
1414
+ # Chart-local-only fields that have no representation on ChartsStyle —
1415
+ # they're populated only by build_resolved_style from a chart's ChartStylePatch.
1416
+ # Default to None at theme-resolve time.
1417
+ _CHART_LOCAL_ONLY = {
1418
+ "scale",
1419
+ "axis_overrides_global",
1420
+ "axis_overrides_x",
1421
+ "axis_overrides_y",
1422
+ "axis_overrides_quantitative",
1423
+ "axis_overrides_band",
1424
+ "color",
1425
+ "background",
1426
+ }
1427
+ passthrough = {
1428
+ name: getattr(charts, name)
1429
+ for name in (f.name for f in dataclasses.fields(MergedChartsStyle))
1430
+ if name not in _AXIS_LEGEND_ERROR
1431
+ and name not in _FROM_PARAMS
1432
+ and name not in _CHART_LOCAL_ONLY
1433
+ }
1434
+ return MergedChartsStyle(
1435
+ **passthrough,
1436
+ axis=_build_resolved_axis(charts.axis),
1437
+ axis_x=_build_resolved_axis(charts.axis_x),
1438
+ axis_y=_build_resolved_axis(charts.axis_y),
1439
+ axis_quantitative=_build_resolved_axis(charts.axis_quantitative),
1440
+ legend=_build_resolved_legend(charts.legend),
1441
+ callout=_build_resolved_callout_chart(charts.callout),
1442
+ font_family=font_family,
1443
+ title=title,
1444
+ spark=spark,
1445
+ pagination=pagination,
1446
+ formats=formats,
1447
+ )
1448
+
1449
+
1450
+ _RESOLVED_STYLE_CACHE: dict[int, tuple[Style, MergedStyle]] = {}
1451
+
1452
+
1453
+ def clear_resolve_style_cache() -> None:
1454
+ """Clear cached no-patch resolved styles after config reloads."""
1455
+ _RESOLVED_STYLE_CACHE.clear()
1456
+
1457
+
1458
+ def _resolve_style_uncached(base: Style, *patches: Any) -> MergedStyle:
1459
+ """Merge compiled base with patches, apply cascade, return resolved style.
1460
+
1461
+ 1. Deep-merge: for each patch, non-None fields override the base.
1462
+ 2. Cascade inheritance (ADR-003): font flows down from root.
1463
+ 3. Convert FontStyle → MergedFontStyle at each level (raises if any field None).
1464
+ 4. Return MergedStyle.
1465
+ """
1466
+ merged = base
1467
+ for patch in patches:
1468
+ merged = deep_merge(merged, patch) # type: ignore[assignment]
1469
+
1470
+ cascaded = _apply_cascade(merged)
1471
+ cascaded = _resolve_self_tokens(cascaded)
1472
+ cascaded = _resolve_color_tokens(cascaded)
1473
+
1474
+ emoji_family = _EMOJI_MODE_TO_FAMILY.get(cascaded.font.emoji)
1475
+ if emoji_family is not None:
1476
+ cascaded = _append_emoji_family(cascaded, emoji_family)
1477
+
1478
+ pagination = cascaded.charts.table.pagination
1479
+
1480
+ # Fields that pass through unchanged from Style → MergedStyle.
1481
+ # font/charts require type transformation.
1482
+ _TRANSFORMED = {"font", "charts"}
1483
+ passthrough = {
1484
+ name: getattr(cascaded, name)
1485
+ for name in (f.name for f in dataclasses.fields(MergedStyle))
1486
+ if name not in _TRANSFORMED
1487
+ }
1488
+ resolved_root_font = resolve_cascaded_font(cascaded.font, "font")
1489
+ return MergedStyle(
1490
+ **passthrough,
1491
+ font=resolved_root_font,
1492
+ charts=_build_resolved_charts(
1493
+ cascaded.charts,
1494
+ font_family=resolved_root_font.family,
1495
+ title=cascaded.title,
1496
+ spark=cascaded.charts.table.spark,
1497
+ pagination=pagination,
1498
+ formats=cascaded.formats,
1499
+ ),
1500
+ )
1501
+
1502
+
1503
+ def resolve_style(base: Style, *patches: Any) -> MergedStyle:
1504
+ """Merge compiled base with patches, apply cascade, return resolved style."""
1505
+ if patches:
1506
+ return _resolve_style_uncached(base, *patches)
1507
+
1508
+ cache_key = id(base)
1509
+ cached = _RESOLVED_STYLE_CACHE.get(cache_key)
1510
+ if cached is not None and cached[0] is base:
1511
+ return copy.deepcopy(cached[1])
1512
+
1513
+ resolved = _resolve_style_uncached(base)
1514
+ _RESOLVED_STYLE_CACHE[cache_key] = (base, resolved)
1515
+ return copy.deepcopy(resolved)
1516
+
1517
+
1518
+ # =============================================================================
1519
+ # VEGA-LITE MAPPING (new nested → flat camelCase)
1520
+ # =============================================================================
1521
+
1522
+
1523
+ def style_to_vega_lite(
1524
+ charts: MergedChartsStyle,
1525
+ ) -> Any: # -> vlc.VegaLiteConfig, avoid circular import
1526
+ """Map MergedChartsStyle to VegaLiteConfig.
1527
+
1528
+ Translates nested Dataface field names to flat Vega-Lite camelCase.
1529
+ This is the sole place that knows about Vega-Lite naming.
1530
+
1531
+ Sole VL mapper. The overloaded style_to_vega_lite(Style) signature was removed.
1532
+ """
1533
+ from dataface.core.compile.models.vega_lite import config as vlc
1534
+
1535
+ data: dict[str, Any] = {}
1536
+
1537
+ # Root font family → VL top-level `font` key
1538
+ if charts.font_family is not None:
1539
+ data["font"] = charts.font_family
1540
+
1541
+ # Color palette → range.category. (Dashes flow through encoding-level
1542
+ # `scale.range` on the strokeDash channel, not config.range — vl_convert's
1543
+ # Vega-Lite v6 ignores `range.dashPattern` even though the docs list it.)
1544
+ if charts.palette:
1545
+ data["range"] = {"category": charts.palette}
1546
+
1547
+ # Default mark color flows through config.range.category (palette[0]) and
1548
+ # per-mark palette stanzas for generic marks below.
1549
+ default_mark_color: str | None = charts.palette[0] if charts.palette else None
1550
+
1551
+ # axis/axisX/axisY/axisQuantitative/axisBand: all moved to encoding level.
1552
+ # profile.py:_build_encoding_axis layers global + channel + type-conditional.
1553
+ # No config.axis* keys emitted here.
1554
+
1555
+ # legend: moved to encoding.color.legend.* / encoding.size.legend.* etc.
1556
+ # profile.py:_build_encoding_legend handles theme + chart-local merge.
1557
+ # No config.legend key emitted here.
1558
+
1559
+ # bar/line/area/scatter: moved to spec.mark.{...} extended mark object.
1560
+ # profile.py:_map_mark/_build_mark_style handles theme + chart-local merge.
1561
+ # No config.bar/line/area/point keys emitted here.
1562
+
1563
+ # Generic mark types: map opacity/stroke/strokeWidth where set.
1564
+ # After ADR-015, these live under charts.marks.* instead of charts.*.
1565
+ def _mark_config(m: Any) -> dict[str, Any]:
1566
+ d: dict[str, Any] = {}
1567
+ if getattr(m, "opacity", None) is not None:
1568
+ d["opacity"] = m.opacity
1569
+ stroke = getattr(m, "stroke", None)
1570
+ if stroke is not None:
1571
+ if stroke.color is not None:
1572
+ d["stroke"] = stroke.color
1573
+ if stroke.width is not None:
1574
+ d["strokeWidth"] = stroke.width
1575
+ return d
1576
+
1577
+ # Solid marks (rect) take fill from palette[0]; line-shaped marks (rule,
1578
+ # trail) take stroke. circle/square/tick keep stroke-only mapping; their
1579
+ # default coloring flows through scale.range.category.
1580
+ _SOLID_MARKS = {"rect"}
1581
+ _LINE_MARKS = {"rule", "trail"}
1582
+ for mark_name, mark in [
1583
+ ("circle", charts.marks.circle),
1584
+ ("square", charts.marks.square),
1585
+ ("tick", charts.marks.tick),
1586
+ ("rule", charts.marks.rule),
1587
+ ("trail", charts.marks.trail),
1588
+ ("rect", charts.marks.rect),
1589
+ ]:
1590
+ cfg = _mark_config(mark)
1591
+ if default_mark_color is not None:
1592
+ if mark_name in _SOLID_MARKS:
1593
+ cfg["fill"] = default_mark_color
1594
+ elif mark_name in _LINE_MARKS and "stroke" not in cfg:
1595
+ cfg["stroke"] = default_mark_color
1596
+ if cfg:
1597
+ data[mark_name] = vlc.MarkConfig.model_validate(cfg)
1598
+
1599
+ # path/shape/symbol: VL marks not modeled in ChartsStyle. Emit
1600
+ # the palette[0] default only — these marks are passed through to VL
1601
+ # with no other Dataface-owned fields.
1602
+ if default_mark_color is not None:
1603
+ data["path"] = vlc.MarkConfig.model_validate({"stroke": default_mark_color})
1604
+ data["shape"] = vlc.MarkConfig.model_validate({"stroke": default_mark_color})
1605
+ data["symbol"] = vlc.MarkConfig.model_validate({"fill": default_mark_color})
1606
+
1607
+ # View
1608
+ view = charts.view
1609
+ view_data: dict[str, Any] = {
1610
+ "continuousWidth": view.continuous_width,
1611
+ "continuousHeight": view.continuous_height,
1612
+ # Always emit stroke. ``None`` is meaningful here: VL's default view
1613
+ # stroke is ``"#ddd"``, so an absent key draws a default border. To
1614
+ # actually disable the bounding box, the spec must contain
1615
+ # ``stroke: null``.
1616
+ "stroke": view.stroke,
1617
+ }
1618
+ if view.discrete_width is not None:
1619
+ view_data["discreteWidth"] = view.discrete_width
1620
+ if view.discrete_height is not None:
1621
+ view_data["discreteHeight"] = view.discrete_height
1622
+ data["view"] = view_data
1623
+
1624
+ # Autosize
1625
+ autosize = charts.autosize
1626
+ data["autosize"] = {
1627
+ "type": autosize.type,
1628
+ "contains": autosize.contains,
1629
+ "resize": autosize.resize,
1630
+ }
1631
+
1632
+ # Text mark (data labels, annotations) — renamed from label to marks.text
1633
+ text_mark = charts.marks.text
1634
+ tf_text = text_mark.font
1635
+ text_data: dict[str, Any] = {}
1636
+ # All TextMarkStyle fields are cascade tier sentinels — only emit when set.
1637
+ if text_mark.align is not None:
1638
+ text_data["align"] = text_mark.align
1639
+ if tf_text.family is not None:
1640
+ text_data["font"] = tf_text.family
1641
+ if tf_text.color is not None:
1642
+ text_data["fill"] = tf_text.color # VL text mark uses fill for color
1643
+ if tf_text.size is not None:
1644
+ text_data["fontSize"] = tf_text.size
1645
+ if tf_text.weight is not None:
1646
+ text_data["fontWeight"] = tf_text.weight
1647
+ if text_data:
1648
+ data["text"] = text_data
1649
+
1650
+ # Title (board/chart title styling)
1651
+ title = charts.title
1652
+ title_data: dict[str, Any] = {}
1653
+ tf = title.font
1654
+ # FontStyle fields are Optional — only emit when authored (not None)
1655
+ if tf.color is not None:
1656
+ title_data["color"] = tf.color
1657
+ if tf.family is not None:
1658
+ title_data["font"] = tf.family
1659
+ if tf.size is not None:
1660
+ title_data["fontSize"] = tf.size
1661
+ if tf.weight is not None:
1662
+ title_data["fontWeight"] = tf.weight
1663
+ title_data["anchor"] = title.position.anchor
1664
+ if title.position.angle is not None:
1665
+ title_data["angle"] = title.position.angle
1666
+ if title.position.offset is not None:
1667
+ title_data["offset"] = title.position.offset
1668
+ if title.position.baseline is not None:
1669
+ title_data["baseline"] = title.position.baseline
1670
+ sf = title.subtitle.font
1671
+ if sf.color is not None:
1672
+ title_data["subtitleColor"] = sf.color
1673
+ if sf.family is not None:
1674
+ title_data["subtitleFont"] = sf.family
1675
+ if sf.size is not None:
1676
+ title_data["subtitleFontSize"] = sf.size
1677
+ if sf.weight is not None:
1678
+ title_data["subtitleFontWeight"] = sf.weight
1679
+ if title_data:
1680
+ data["title"] = title_data
1681
+
1682
+ return vlc.VegaLiteConfig.model_validate(data)