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,1100 @@
1
+ """Palette resolver — role indirection over typed palette records.
2
+
3
+ Stage: COMPILE / RENDER
4
+ Purpose: Resolve palette names and color tokens to sRGB hex stops.
5
+
6
+ Entry points
7
+ ------------
8
+ palette(name, *, surface=None, steps=None, reverse=False) -> list[str]
9
+ color(token) -> str
10
+ color_from_theme(token, *, palettes, roles=None) -> str
11
+ resolve_alias_chain(key, aliases, *, colors) -> str
12
+ palette_metadata(name) -> dict
13
+ list_palettes(family=None) -> list[str]
14
+ select_default_palette(data_shape) -> str
15
+
16
+ YAML palette data is loaded from ``dataface/core/defaults/palettes/<directory>/``.
17
+ Sequential and diverging palettes use a hand-authored ``<name>.yml`` spine
18
+ with an 11-stop ``colors:`` array. Downsampling operates directly on the spine.
19
+
20
+ Palette YAML shape (unified):
21
+ name: str
22
+ colors: list[str] | None — ordered hex stops for [N] bracket access
23
+ aliases: dict[str, str|int] | None — terminal hex, alias chain, or 1-indexed int
24
+ extends: str | None — inherit alias graph from a parent palette
25
+ description: str | None
26
+
27
+ Role-indirection grammar:
28
+ chrome.ink → theme.palettes[chrome] → palette → resolve alias "ink"
29
+ category[1] → theme.palettes[category] → palette → colors[0] (1-indexed)
30
+ ink → theme.roles[ink] → recurse as role.alias
31
+
32
+ surface="table" (sequential and diverging only)
33
+ -----------------------------------------------
34
+ Produces a WCAG AA–safe sub-palette for use as table cell backgrounds.
35
+ The algorithm:
36
+ 1. Binary-search the OKLCH-interpolated spine for the exact t where
37
+ contrast vs #222222 crosses 4.5:1 (no dense LUT required).
38
+ 2. Generate ``steps`` stops evenly from the light end of the spine to
39
+ the boundary t.
40
+ The boundary is found by evaluating the continuous OKLCH curve, so it
41
+ does not snap to a spine index.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import difflib
47
+ import functools
48
+ import math
49
+ import re
50
+ import warnings
51
+ from pathlib import Path
52
+ from typing import Any, Literal
53
+
54
+ import yaml
55
+
56
+ from dataface.core.compile.models.palette import Palette
57
+
58
+ # ============================================================================
59
+ # Exceptions
60
+ # ============================================================================
61
+
62
+
63
+ class UnknownPaletteError(ValueError):
64
+ """Raised when a palette name doesn't match any shipped palette."""
65
+
66
+
67
+ class UnknownColorError(ValueError):
68
+ """Raised when ``color()`` token doesn't resolve."""
69
+
70
+
71
+ class CategoricalOverrequestError(ValueError):
72
+ """Raised when ``steps=N`` exceeds ``len(stops)`` for categorical/scaffold."""
73
+
74
+
75
+ class SurfaceUnsupportedError(ValueError):
76
+ """Raised when ``surface=`` is passed for a family that doesn't carve."""
77
+
78
+
79
+ class ToneAsPaletteError(ValueError):
80
+ """Raised when a tone palette name is passed to ``palette()`` instead
81
+ of ``color()``."""
82
+
83
+
84
+ class UnsupportedPaletteWarning(UserWarning):
85
+ """Warning for palettes that resolve but are known anti-patterns.
86
+
87
+ Emitted for RdYlGn and parula. jet/rainbow/hsv do NOT resolve
88
+ (``UnknownPaletteError``) since they have no defensible use.
89
+ """
90
+
91
+
92
+ # ============================================================================
93
+ # Module state
94
+ # ============================================================================
95
+
96
+ _PALETTES_DIR: Path = Path(__file__).parent.parent / "defaults" / "palettes"
97
+ _FAMILIES: tuple[str, ...] = (
98
+ "sequential",
99
+ "diverging",
100
+ "categorical",
101
+ "scaffold",
102
+ "tone",
103
+ )
104
+ # Index: {name: {"family": ..., "path": Path}}
105
+ _index: dict[str, dict[str, Any]] | None = None
106
+
107
+ # Cache for loaded spine YAMLs (keyed by palette name).
108
+ _spine_cache: dict[str, Palette] = {}
109
+
110
+
111
+ # Known anti-patterns (§10).
112
+ _HARD_FAIL_NAMES: frozenset[str] = frozenset({"jet", "rainbow", "hsv"})
113
+
114
+ # RdYlGn/parula resolve (for migration paths) but emit a warning.
115
+ # The mapped substitute is the nearest DFT palette so dashboards don't crash
116
+ # when users encounter these names from prior tools. Warning text names
117
+ # the substitution explicitly.
118
+ _WARN_ALIASES: dict[str, str] = {
119
+ "RdYlGn": "dft-div-crimson-green",
120
+ "parula": "dft-seq-blue",
121
+ }
122
+
123
+
124
+ # ============================================================================
125
+ # OKLCH math + WCAG helpers for surface="table" carving
126
+ # ============================================================================
127
+
128
+ # DFT body text ink — used as the contrast reference for table cell backgrounds.
129
+ _WCAG_TABLE_BODY = "#222222"
130
+ # WCAG AA minimum contrast ratio for normal text.
131
+ _WCAG_TABLE_MIN = 4.5
132
+
133
+
134
+ def _srgb_to_linear(c: float) -> float:
135
+ return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
136
+
137
+
138
+ def _linear_to_srgb(c: float) -> float:
139
+ if c <= 0.0:
140
+ return 0.0
141
+ return c * 12.92 if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055
142
+
143
+
144
+ def _cbrt(x: float) -> float:
145
+ return x ** (1 / 3) if x >= 0 else -((-x) ** (1 / 3))
146
+
147
+
148
+ def _lrgb_to_oklab(r: float, g: float, b: float) -> tuple[float, float, float]:
149
+ lo = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
150
+ m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
151
+ s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
152
+ lo, m, s = _cbrt(lo), _cbrt(m), _cbrt(s)
153
+ return (
154
+ 0.2104542553 * lo + 0.7936177850 * m - 0.0040720468 * s,
155
+ 1.9779984951 * lo - 2.4285922050 * m + 0.4505937099 * s,
156
+ 0.0259040371 * lo + 0.7827717662 * m - 0.8086757660 * s,
157
+ )
158
+
159
+
160
+ def _oklab_to_lrgb(L: float, a: float, b: float) -> tuple[float, float, float]:
161
+ lo = L + 0.3963377774 * a + 0.2158037573 * b
162
+ m = L - 0.1055613458 * a - 0.0638541728 * b
163
+ s = L - 0.0894841775 * a - 1.2914855480 * b
164
+ lo, m, s = lo**3, m**3, s**3
165
+ return (
166
+ +4.0767416621 * lo - 3.3077115913 * m + 0.2309699292 * s,
167
+ -1.2684380046 * lo + 2.6097574011 * m - 0.3413193965 * s,
168
+ -0.0041960863 * lo - 0.7034186147 * m + 1.7076147010 * s,
169
+ )
170
+
171
+
172
+ def _hex_to_oklch(hex_str: str) -> tuple[float, float, float]:
173
+ h = hex_str.lstrip("#")
174
+ r = int(h[0:2], 16) / 255
175
+ g = int(h[2:4], 16) / 255
176
+ b = int(h[4:6], 16) / 255
177
+ rl, gl, bl = _srgb_to_linear(r), _srgb_to_linear(g), _srgb_to_linear(b)
178
+ L, a, bb = _lrgb_to_oklab(rl, gl, bl)
179
+ C = math.sqrt(a * a + bb * bb)
180
+ H = math.degrees(math.atan2(bb, a)) % 360
181
+ return (L, C, H)
182
+
183
+
184
+ def _oklch_to_hex(L: float, C: float, H: float) -> str:
185
+ """Convert OKLCH to sRGB hex, gamut-clipping via binary search on C."""
186
+
187
+ def _try_c(cc: float) -> tuple[float, float, float] | None:
188
+ a = cc * math.cos(math.radians(H))
189
+ b = cc * math.sin(math.radians(H))
190
+ r, g, bb = _oklab_to_lrgb(L, a, b)
191
+ # Loose bounds tolerate floating-point overshoot; clamp to [0,1] on accept.
192
+ if -1e-5 <= r <= 1.0001 and -1e-5 <= g <= 1.0001 and -1e-5 <= bb <= 1.0001:
193
+ return (
194
+ max(0.0, min(1.0, r)),
195
+ max(0.0, min(1.0, g)),
196
+ max(0.0, min(1.0, bb)),
197
+ )
198
+ return None
199
+
200
+ result = _try_c(C)
201
+ if result is None:
202
+ lo, hi = 0.0, C
203
+ # 30 iterations → precision ~C/2^30 ≈ 1e-9 in C, well below 8-bit rounding.
204
+ for _ in range(30):
205
+ mid = (lo + hi) / 2
206
+ if _try_c(mid):
207
+ lo = mid
208
+ else:
209
+ hi = mid
210
+ result = _try_c(lo) or (0.0, 0.0, 0.0)
211
+ r, g, b = result
212
+ rs = max(0.0, min(1.0, _linear_to_srgb(r)))
213
+ gs = max(0.0, min(1.0, _linear_to_srgb(g)))
214
+ bs = max(0.0, min(1.0, _linear_to_srgb(b)))
215
+ return f"#{int(round(rs * 255)):02x}{int(round(gs * 255)):02x}{int(round(bs * 255)):02x}"
216
+
217
+
218
+ def _relative_luminance(hex_str: str) -> float:
219
+ """WCAG 2.1 relative luminance of an sRGB hex color."""
220
+ h = hex_str.lstrip("#")
221
+ r, g, b = int(h[0:2], 16) / 255, int(h[2:4], 16) / 255, int(h[4:6], 16) / 255
222
+ rl, gl, bl = _srgb_to_linear(r), _srgb_to_linear(g), _srgb_to_linear(b)
223
+ return 0.2126 * rl + 0.7152 * gl + 0.0722 * bl
224
+
225
+
226
+ def _wcag_contrast(hex_a: str, hex_b: str) -> float:
227
+ """WCAG 2.1 contrast ratio between two sRGB hex colors."""
228
+ l1, l2 = _relative_luminance(hex_a), _relative_luminance(hex_b)
229
+ if l1 < l2:
230
+ l1, l2 = l2, l1
231
+ return (l1 + 0.05) / (l2 + 0.05)
232
+
233
+
234
+ def _interpolate_oklch_at_t(
235
+ spine_oklch: list[tuple[float, float, float]], t: float
236
+ ) -> str:
237
+ """Evaluate the OKLCH spine at parameter t ∈ [0, 1] and return sRGB hex.
238
+
239
+ Uses shortest-arc hue interpolation between each pair of adjacent spine stops.
240
+ """
241
+ n = len(spine_oklch)
242
+ seg_f = t * (n - 1)
243
+ seg_i = min(n - 2, int(seg_f))
244
+ seg_t = seg_f - seg_i
245
+ L0, C0, H0 = spine_oklch[seg_i]
246
+ L1, C1, H1 = spine_oklch[seg_i + 1]
247
+ L = L0 + seg_t * (L1 - L0)
248
+ C = C0 + seg_t * (C1 - C0)
249
+ d = ((H1 - H0 + 540) % 360) - 180
250
+ H = (H0 + seg_t * d) % 360
251
+ return _oklch_to_hex(L, C, H)
252
+
253
+
254
+ def _wcag_boundary_t(
255
+ spine_oklch: list[tuple[float, float, float]],
256
+ t_pass: float,
257
+ t_fail: float,
258
+ iters: int = 50,
259
+ ) -> float:
260
+ """Binary-search for the WCAG-contrast boundary along the OKLCH spine.
261
+
262
+ ``t_pass`` must be a position where contrast ≥ _WCAG_TABLE_MIN;
263
+ ``t_fail`` must be a position where it is below. Returns the last-passing t
264
+ (precision ~1/2^50 in t, well below any visible colour difference).
265
+ """
266
+ for _ in range(iters):
267
+ t_mid = (t_pass + t_fail) / 2
268
+ if (
269
+ _wcag_contrast(
270
+ _WCAG_TABLE_BODY, _interpolate_oklch_at_t(spine_oklch, t_mid)
271
+ )
272
+ >= _WCAG_TABLE_MIN
273
+ ):
274
+ t_pass = t_mid
275
+ else:
276
+ t_fail = t_mid
277
+ return t_pass
278
+
279
+
280
+ def _table_surface_seq(source: list[str], steps: int) -> list[str]:
281
+ """Return a WCAG-safe table palette for a sequential source spine.
282
+
283
+ Algorithm:
284
+ 1. Binary-search the OKLCH-interpolated spine for the t where contrast
285
+ vs #222222 crosses 4.5:1 (light end t=0 passes; dark end may fail).
286
+ 2. Generate ``steps`` stops evenly from t=0 to that boundary t.
287
+
288
+ Raises ``ValueError`` if even the lightest stop fails the threshold.
289
+ """
290
+ spine_oklch = [_hex_to_oklch(h) for h in source]
291
+
292
+ lightest_hex = _interpolate_oklch_at_t(spine_oklch, 0.0)
293
+ if _wcag_contrast(_WCAG_TABLE_BODY, lightest_hex) < _WCAG_TABLE_MIN:
294
+ raise ValueError(
295
+ f"Lightest stop of sequential palette fails WCAG {_WCAG_TABLE_MIN}:1 "
296
+ f"against {_WCAG_TABLE_BODY!r}."
297
+ )
298
+
299
+ darkest_hex = _interpolate_oklch_at_t(spine_oklch, 1.0)
300
+ if _wcag_contrast(_WCAG_TABLE_BODY, darkest_hex) >= _WCAG_TABLE_MIN:
301
+ t_end = 1.0 # Entire spine is safe.
302
+ else:
303
+ t_end = _wcag_boundary_t(spine_oklch, t_pass=0.0, t_fail=1.0)
304
+
305
+ if steps == 1:
306
+ return [_interpolate_oklch_at_t(spine_oklch, t_end / 2.0)]
307
+ return [
308
+ _interpolate_oklch_at_t(spine_oklch, i * t_end / (steps - 1))
309
+ for i in range(steps)
310
+ ]
311
+
312
+
313
+ def _table_surface_div(source: list[str], steps: int) -> list[str]:
314
+ """Return a WCAG-safe table palette for a diverging source spine.
315
+
316
+ Algorithm:
317
+ 1. Binary-search each arm of the OKLCH-interpolated spine for the t
318
+ where contrast vs #222222 crosses 4.5:1.
319
+ 2. Generate ``steps`` stops from the left boundary to the right boundary.
320
+ For even ``steps``, the neutral midpoint (t=0.5) is excluded so stops
321
+ flank it symmetrically.
322
+
323
+ Raises ``ValueError`` if the palette midpoint fails the threshold.
324
+ """
325
+ spine_oklch = [_hex_to_oklch(h) for h in source]
326
+
327
+ mid_hex = _interpolate_oklch_at_t(spine_oklch, 0.5)
328
+ if _wcag_contrast(_WCAG_TABLE_BODY, mid_hex) < _WCAG_TABLE_MIN:
329
+ raise ValueError(
330
+ f"Midpoint of diverging palette fails WCAG {_WCAG_TABLE_MIN}:1 "
331
+ f"against {_WCAG_TABLE_BODY!r}."
332
+ )
333
+
334
+ # Left boundary: dark end (t=0) may fail; midpoint (t=0.5) passes.
335
+ left_hex = _interpolate_oklch_at_t(spine_oklch, 0.0)
336
+ t_left = (
337
+ 0.0
338
+ if _wcag_contrast(_WCAG_TABLE_BODY, left_hex) >= _WCAG_TABLE_MIN
339
+ else _wcag_boundary_t(spine_oklch, t_pass=0.5, t_fail=0.0)
340
+ )
341
+
342
+ # Right boundary: dark end (t=1) may fail; midpoint (t=0.5) passes.
343
+ right_hex = _interpolate_oklch_at_t(spine_oklch, 1.0)
344
+ t_right = (
345
+ 1.0
346
+ if _wcag_contrast(_WCAG_TABLE_BODY, right_hex) >= _WCAG_TABLE_MIN
347
+ else _wcag_boundary_t(spine_oklch, t_pass=0.5, t_fail=1.0)
348
+ )
349
+
350
+ if steps == 1:
351
+ return [_interpolate_oklch_at_t(spine_oklch, 0.5)]
352
+
353
+ if steps % 2 == 0:
354
+ # Even: place n//2 stops on each arm, excluding the midpoint.
355
+ half = steps // 2
356
+ left_ts = [t_left + i * (0.5 - t_left) / half for i in range(half)]
357
+ right_ts = [0.5 + (i + 1) * (t_right - 0.5) / half for i in range(half)]
358
+ ts = left_ts + right_ts
359
+ else:
360
+ ts = [t_left + i * (t_right - t_left) / (steps - 1) for i in range(steps)]
361
+
362
+ return [_interpolate_oklch_at_t(spine_oklch, t) for t in ts]
363
+
364
+
365
+ def _build_index() -> dict[str, dict[str, Any]]:
366
+ """Scan the palettes directory and build ``name → (family, path)`` map."""
367
+ idx: dict[str, dict[str, Any]] = {}
368
+ for family in _FAMILIES:
369
+ fam_dir = _PALETTES_DIR / family
370
+ if not fam_dir.is_dir():
371
+ continue
372
+ for yml in sorted(fam_dir.glob("*.yml")):
373
+ with yml.open("r", encoding="utf-8") as fh:
374
+ data = yaml.safe_load(fh)
375
+ if not isinstance(data, dict) or "name" not in data:
376
+ continue
377
+ idx[data["name"]] = {"family": family, "path": yml}
378
+ return idx
379
+
380
+
381
+ def _get_index() -> dict[str, dict[str, Any]]:
382
+ global _index
383
+ if _index is None:
384
+ _index = _build_index()
385
+ return _index
386
+
387
+
388
+ def _load_spine_raw(name: str) -> Palette:
389
+ """Load and validate the YAML for ``name`` without resolving ``extends:``."""
390
+ entry = _get_index().get(name)
391
+ if entry is None:
392
+ raise UnknownPaletteError(_unknown_palette_message(name))
393
+ with entry["path"].open("r", encoding="utf-8") as fh:
394
+ data = yaml.safe_load(fh)
395
+ return Palette.model_validate(data)
396
+
397
+
398
+ def _load_spine_merged(name: str, _seen: frozenset[str] | None = None) -> Palette:
399
+ """Load palette ``name`` with ``extends:`` inheritance applied.
400
+
401
+ Merge rules:
402
+ - ``colors:`` — child replaces parent array wholesale.
403
+ - ``aliases:`` — deep-merge; child keys override parent keys.
404
+ Alias values are kept as-is (integers stay integers) so that integer
405
+ refs resolve against the *child's* colors array at resolve time.
406
+ - ``name``, ``description``, ``type`` — child wins.
407
+ - ``extends`` key is stripped from the merged result.
408
+ - Cycle → ValueError with clear message.
409
+ - Unknown parent → UnknownPaletteError with clear message.
410
+ """
411
+ seen = _seen or frozenset()
412
+ if name in seen:
413
+ chain = " → ".join(sorted(seen) + [name])
414
+ raise ValueError(f"palette extends cycle detected: {chain}")
415
+
416
+ child = _load_spine_raw(name)
417
+ if child.extends is None:
418
+ return child
419
+
420
+ parent_name = child.extends
421
+ parent = _load_spine_merged(parent_name, seen | {name})
422
+
423
+ # Merge: parent aliases as base, child aliases override.
424
+ merged_aliases: dict[str, str | int] = dict(parent.aliases or {})
425
+ merged_aliases.update(child.aliases or {})
426
+
427
+ # child.colors replaces parent wholesale (or is None if child omits it).
428
+ merged = Palette(
429
+ name=child.name,
430
+ extends=None, # stripped
431
+ colors=child.colors if child.colors is not None else parent.colors,
432
+ aliases=merged_aliases if merged_aliases else None,
433
+ description=(
434
+ child.description if child.description is not None else parent.description
435
+ ),
436
+ design_notes=(
437
+ child.design_notes
438
+ if child.design_notes is not None
439
+ else parent.design_notes
440
+ ),
441
+ r8_validation=child.r8_validation,
442
+ )
443
+ return merged
444
+
445
+
446
+ def _load_spine(name: str) -> Palette:
447
+ if name in _spine_cache:
448
+ return _spine_cache[name]
449
+ spine = _load_spine_merged(name)
450
+ _spine_cache[name] = spine
451
+ return spine
452
+
453
+
454
+ def _unknown_palette_message(name: str) -> str:
455
+ idx = _get_index()
456
+ candidates = list(idx.keys())
457
+ suggestions = difflib.get_close_matches(name, candidates, n=1, cutoff=0.6)
458
+ if suggestions:
459
+ return f"unknown palette '{name}'. Did you mean '{suggestions[0]}'?"
460
+ return f"unknown palette '{name}'"
461
+
462
+
463
+ # ============================================================================
464
+ # Parsing shorthand — "name:N_r"
465
+ # ============================================================================
466
+
467
+
468
+ def _parse_palette_reference(ref: str) -> tuple[str, int | None, bool]:
469
+ """Parse ``name:N_r`` shorthand into (name, steps, reverse).
470
+
471
+ Examples:
472
+ "dft-seq-blue" → ("dft-seq-blue", None, False)
473
+ "dft-seq-blue:5" → ("dft-seq-blue", 5, False)
474
+ "dft-seq-blue_r" → ("dft-seq-blue", None, True)
475
+ "dft-seq-blue:5_r" → ("dft-seq-blue", 5, True)
476
+ """
477
+ if not ref:
478
+ raise ValueError("palette reference must be a non-empty string")
479
+ body = ref
480
+ reverse = False
481
+ if body.endswith("_r"):
482
+ reverse = True
483
+ body = body[:-2]
484
+ steps: int | None = None
485
+ if ":" in body:
486
+ name, _, steps_str = body.rpartition(":")
487
+ if not steps_str or not steps_str.lstrip("-").isdigit():
488
+ raise ValueError(
489
+ f"palette reference steps must be a positive integer, got {ref!r}"
490
+ )
491
+ steps = int(steps_str)
492
+ if steps <= 0:
493
+ raise ValueError(f"steps must be positive, got {steps}")
494
+ else:
495
+ name = body
496
+ if not name:
497
+ raise ValueError(f"palette reference is missing name: {ref!r}")
498
+ return (name, steps, reverse)
499
+
500
+
501
+ # ============================================================================
502
+ # Downsample
503
+ # ============================================================================
504
+
505
+
506
+ def _downsample(
507
+ stops: list[str], n: int, skip_midpoint_on_even: bool = False
508
+ ) -> list[str]:
509
+ """Pick ``n`` evenly-spaced stops from the input list.
510
+
511
+ For diverging palettes with even ``n`` and ``skip_midpoint_on_even=True``,
512
+ the midpoint (index ``len/2``) is skipped so stops flank it symmetrically.
513
+ """
514
+ if n <= 0:
515
+ raise ValueError(f"n must be positive, got {n}")
516
+ L = len(stops)
517
+ if n >= L:
518
+ return list(stops)
519
+ if n == 1:
520
+ return [stops[L // 2]]
521
+
522
+ if skip_midpoint_on_even and n % 2 == 0 and L % 2 == 1:
523
+ midpoint = L // 2
524
+ half = n // 2
525
+ if half == 1:
526
+ # Only two stops requested — just the endpoints flanking the midpoint.
527
+ return [stops[0], stops[-1]]
528
+ # Split range into [0..midpoint-1] and [midpoint+1..L-1]; pick `half`
529
+ # evenly-spaced from each, preserving endpoints.
530
+ left = [round(i * (midpoint - 1) / (half - 1)) for i in range(half)]
531
+ right = [
532
+ midpoint + 1 + round(i * (L - 1 - midpoint - 1) / (half - 1))
533
+ for i in range(half)
534
+ ]
535
+ return [stops[i] for i in left + right]
536
+
537
+ # Even spacing with endpoints preserved.
538
+ indices = [round(i * (L - 1) / (n - 1)) for i in range(n)]
539
+ return [stops[i] for i in indices]
540
+
541
+
542
+ # ============================================================================
543
+ # Public API — palette()
544
+ # ============================================================================
545
+
546
+
547
+ def palette(
548
+ name: str,
549
+ *,
550
+ surface: Literal["default", "table"] | None = None,
551
+ steps: int | None = None,
552
+ reverse: bool = False,
553
+ ) -> list[str]:
554
+ """Resolve a palette name to a list of sRGB hex stops.
555
+
556
+ See module docstring for full parameter reference. Raises
557
+ ``UnknownPaletteError``, ``CategoricalOverrequestError``,
558
+ ``SurfaceUnsupportedError``, or ``ToneAsPaletteError`` as appropriate.
559
+ """
560
+ # Shorthand parsing from strings.
561
+ if ":" in name or name.endswith("_r"):
562
+ parsed_name, parsed_steps, parsed_rev = _parse_palette_reference(name)
563
+ name = parsed_name
564
+ if steps is None:
565
+ steps = parsed_steps
566
+ reverse = reverse or parsed_rev
567
+
568
+ # Anti-patterns.
569
+ if name in _HARD_FAIL_NAMES:
570
+ raise UnknownPaletteError(
571
+ f"palette '{name}' is a known perceptual anti-pattern and is not "
572
+ "shipped. See docs/guides/palette-anti-patterns.md."
573
+ )
574
+ if name in _WARN_ALIASES:
575
+ substitute = _WARN_ALIASES[name]
576
+ warnings.warn(
577
+ f"palette '{name}' is a known anti-pattern; resolving to "
578
+ f"'{substitute}' instead. See docs/guides/palette-anti-patterns.md.",
579
+ UnsupportedPaletteWarning,
580
+ stacklevel=2,
581
+ )
582
+ name = substitute
583
+
584
+ entry = _get_index().get(name)
585
+ if entry is None:
586
+ raise UnknownPaletteError(_unknown_palette_message(name))
587
+
588
+ family = entry["family"]
589
+
590
+ if family == "tone":
591
+ raise ToneAsPaletteError(
592
+ f"'{name}' is a tone palette. Use color('{name}.solid') etc., "
593
+ "not palette()."
594
+ )
595
+
596
+ if family in ("categorical", "scaffold"):
597
+ return _resolve_discrete(name, family, surface, steps, reverse)
598
+
599
+ # sequential or diverging.
600
+ return _resolve_continuous(name, family, surface, steps, reverse)
601
+
602
+
603
+ def _resolve_discrete(
604
+ name: str,
605
+ family: str,
606
+ surface: str | None,
607
+ steps: int | None,
608
+ reverse: bool,
609
+ ) -> list[str]:
610
+ if surface is not None:
611
+ raise SurfaceUnsupportedError(
612
+ f"palette '{name}' (family={family}) does not support surface variants"
613
+ )
614
+ spine = _load_spine(name)
615
+
616
+ if family == "categorical":
617
+ stops = list(spine.colors) # type: ignore[arg-type]
618
+ else: # scaffold — new format: colors: + aliases:
619
+ if spine.colors is None:
620
+ raise UnknownPaletteError(
621
+ f"scaffold palette '{name}' is missing 'colors:' array. "
622
+ "Ensure the YAML uses the unified colors:/aliases: shape."
623
+ )
624
+ stops = list(spine.colors)
625
+
626
+ if steps is not None:
627
+ if steps > len(stops):
628
+ raise CategoricalOverrequestError(
629
+ f"palette '{name}' has {len(stops)} stops; cannot return {steps}. "
630
+ "Pick a different palette or reduce steps."
631
+ )
632
+ stops = stops[:steps]
633
+
634
+ if reverse:
635
+ stops = list(reversed(stops))
636
+ return stops
637
+
638
+
639
+ def _resolve_continuous(
640
+ name: str,
641
+ family: str,
642
+ surface: str | None,
643
+ steps: int | None,
644
+ reverse: bool,
645
+ ) -> list[str]:
646
+ spine = _load_spine(name)
647
+
648
+ if surface not in (None, "default", "table"):
649
+ raise SurfaceUnsupportedError(
650
+ f"unknown surface variant '{surface}' for palette '{name}'"
651
+ )
652
+
653
+ if spine.colors is None:
654
+ raise UnknownPaletteError(
655
+ f"palette '{name}' (family={family}) is missing 'colors:' array"
656
+ )
657
+ source = list(spine.colors)
658
+
659
+ if steps is None:
660
+ steps = 11
661
+
662
+ if surface == "table":
663
+ if family == "sequential":
664
+ stops = _table_surface_seq(source, steps)
665
+ else: # diverging
666
+ stops = _table_surface_div(source, steps)
667
+ else:
668
+ skip_mid = family == "diverging"
669
+ stops = _downsample(source, steps, skip_midpoint_on_even=skip_mid)
670
+
671
+ if reverse:
672
+ stops = list(reversed(stops))
673
+ return stops
674
+
675
+
676
+ # ============================================================================
677
+ # Public API — color()
678
+ # ============================================================================
679
+
680
+
681
+ def resolve_alias_chain(
682
+ key: str,
683
+ aliases: dict[str, str | int],
684
+ *,
685
+ colors: list[str] | None,
686
+ ) -> str:
687
+ """Resolve an alias name through the chain to a terminal hex string.
688
+
689
+ Alias values may be:
690
+ - A hex string (``#rrggbb``) — terminal; return it.
691
+ - A 1-indexed integer — index into ``colors`` (``colors[n-1]``).
692
+ - Another alias name — recurse with cycle detection.
693
+
694
+ Raises ``UnknownColorError`` on unknown alias, out-of-range index,
695
+ missing colors array when an integer index is encountered, or cycle.
696
+ """
697
+ visited: list[str] = []
698
+ current = key
699
+ while True:
700
+ if current in visited:
701
+ raise UnknownColorError(
702
+ f"alias cycle detected: {' → '.join(visited + [current])}"
703
+ )
704
+ visited.append(current)
705
+ if current not in aliases:
706
+ raise UnknownColorError(
707
+ f"alias '{key}' → '{current}' not found in palette aliases. "
708
+ f"Known aliases: {sorted(aliases)}"
709
+ )
710
+ value = aliases[current]
711
+ if isinstance(value, int):
712
+ if colors is None:
713
+ raise UnknownColorError(
714
+ f"alias '{current}' resolves to slot index {value} "
715
+ "but palette has no 'colors:' array"
716
+ )
717
+ if value < 1 or value > len(colors):
718
+ raise UnknownColorError(
719
+ f"alias '{current}' slot index {value} out of range "
720
+ f"(palette has {len(colors)} stops; 1-indexed)"
721
+ )
722
+ return colors[value - 1]
723
+ if value.startswith("#"):
724
+ return value
725
+ # Must be another alias name — continue walking
726
+ current = value
727
+
728
+
729
+ def color(token: str) -> str:
730
+ """Resolve a single color token (``palette.slot``) to an sRGB hex string.
731
+
732
+ Supports tone, scaffold (new aliases: format), and categorical palettes.
733
+ For role-indirected tokens (``chrome.ink`` where ``chrome`` is a theme
734
+ palette role), use ``color_from_theme()`` instead.
735
+ """
736
+ if "." not in token:
737
+ raise UnknownColorError(
738
+ f"color token must be dotted 'palette.slot', got {token!r}"
739
+ )
740
+ palette_name, _, slot = token.partition(".")
741
+ entry = _get_index().get(palette_name)
742
+ if entry is None:
743
+ raise UnknownColorError(_unknown_palette_message(palette_name))
744
+ family = entry["family"]
745
+ spine = _load_spine(palette_name)
746
+
747
+ # Unified resolver: tone and scaffold both use aliases: dict now.
748
+ if family in ("tone", "scaffold"):
749
+ aliases = spine.aliases or {}
750
+ if slot not in aliases:
751
+ raise UnknownColorError(
752
+ f"palette '{palette_name}' has no alias '{slot}'. "
753
+ f"Known aliases: {sorted(aliases)}"
754
+ )
755
+ return resolve_alias_chain(slot, aliases, colors=spine.colors)
756
+
757
+ if family == "categorical":
758
+ # Rare: category-10.0 etc. by integer index.
759
+ try:
760
+ idx = int(slot)
761
+ except ValueError as e:
762
+ raise UnknownColorError(
763
+ f"categorical palette '{palette_name}' indexed by integer; got '{slot}'"
764
+ ) from e
765
+ stops = list(spine.colors) # type: ignore[arg-type]
766
+ if not 0 <= idx < len(stops):
767
+ raise UnknownColorError(
768
+ f"categorical palette '{palette_name}' index {idx} out of range [0, {len(stops)})"
769
+ )
770
+ return stops[idx]
771
+
772
+ raise UnknownColorError(
773
+ f"palette '{palette_name}' (family={family}) does not support color() access"
774
+ )
775
+
776
+
777
+ # ============================================================================
778
+ # Public API — color_from_theme() role indirection
779
+ # ============================================================================
780
+
781
+
782
+ def color_from_theme(
783
+ token: str,
784
+ *,
785
+ palettes: dict[str, str],
786
+ roles: dict[str, str] | None = None,
787
+ _visited_roles: frozenset[str] | None = None,
788
+ ) -> str:
789
+ """Resolve a role-indirected color token against theme palettes and roles.
790
+
791
+ Token grammar:
792
+ ``chrome.ink`` — role.alias: look up palettes[role], resolve alias
793
+ ``category[1]`` — role[N]: look up palettes[role], colors[N-1] (1-indexed)
794
+ ``ink`` — bare name: look up roles[ink], recurse
795
+
796
+ Args:
797
+ token: Color token string in one of the three grammar forms.
798
+ palettes: Theme ``palettes:`` block — maps role names to palette names.
799
+ roles: Theme ``roles:`` block — maps bare alias names to role.alias tokens.
800
+ _visited_roles: Internal cycle-detection set (do not pass from call sites).
801
+
802
+ Returns:
803
+ Resolved sRGB hex string.
804
+
805
+ Raises:
806
+ UnknownColorError: Token is unresolvable (unknown role, missing alias,
807
+ out-of-range index, cycle, or missing colors array).
808
+ """
809
+ # Bracket form: role[N]
810
+ bracket_match = re.match(r"^([A-Za-z][A-Za-z0-9_-]*)\[(\d+)\]$", token)
811
+ if bracket_match:
812
+ role, n_str = bracket_match.group(1), bracket_match.group(2)
813
+ n = int(n_str)
814
+ if n < 1:
815
+ raise UnknownColorError(
816
+ f"bracket index must be 1-indexed (≥ 1), got {token!r}"
817
+ )
818
+ palette_name = palettes.get(role)
819
+ if palette_name is None:
820
+ raise UnknownColorError(
821
+ f"theme has no palette assigned to role '{role}'. "
822
+ f"Defined roles: {sorted(palettes)}"
823
+ )
824
+ entry = _get_index().get(palette_name)
825
+ if entry is None:
826
+ raise UnknownColorError(
827
+ f"palette '{palette_name}' (role '{role}') not found in catalog"
828
+ )
829
+ spine = _load_spine(palette_name)
830
+ if spine.colors is None:
831
+ raise UnknownColorError(
832
+ f"palette '{palette_name}' (role '{role}') has no 'colors:' array; "
833
+ "bracket form requires a colors array"
834
+ )
835
+ if n > len(spine.colors):
836
+ raise UnknownColorError(
837
+ f"palette '{palette_name}' has {len(spine.colors)} slot(s); "
838
+ f"requested slot {n} (1-indexed)"
839
+ )
840
+ return spine.colors[n - 1]
841
+
842
+ # Dotted form: role.alias
843
+ if "." in token:
844
+ role, _, alias = token.partition(".")
845
+ palette_name = palettes.get(role)
846
+ if palette_name is None:
847
+ raise UnknownColorError(
848
+ f"theme has no palette assigned to role '{role}'. "
849
+ f"Defined roles: {sorted(palettes)}"
850
+ )
851
+ entry = _get_index().get(palette_name)
852
+ if entry is None:
853
+ raise UnknownColorError(
854
+ f"palette '{palette_name}' (role '{role}') not found in catalog"
855
+ )
856
+ spine = _load_spine(palette_name)
857
+ aliases_raw = spine.aliases or {}
858
+ if alias not in aliases_raw:
859
+ raise UnknownColorError(
860
+ f"palette '{palette_name}' (role '{role}') has no alias '{alias}'. "
861
+ f"Known aliases: {sorted(aliases_raw)}"
862
+ )
863
+ return resolve_alias_chain(alias, aliases_raw, colors=spine.colors)
864
+
865
+ # Bare name: look up in roles
866
+ effective_roles = roles or {}
867
+ target = effective_roles.get(token)
868
+ if target is None:
869
+ raise UnknownColorError(
870
+ f"unknown color token '{token}': not a dotted palette address, "
871
+ "bracket form, or theme role. "
872
+ f"Defined theme roles: {sorted(effective_roles)}"
873
+ )
874
+ # Cycle detection for bare-name role recursion.
875
+ visited = _visited_roles or frozenset()
876
+ if token in visited:
877
+ raise UnknownColorError(f"theme.roles cycle detected involving '{token}'")
878
+ # Recurse on the resolved target (which must be a dotted or bracket form)
879
+ return color_from_theme(
880
+ target,
881
+ palettes=palettes,
882
+ roles=effective_roles,
883
+ _visited_roles=visited | {token},
884
+ )
885
+
886
+
887
+ # ============================================================================
888
+ # Public API — discovery
889
+ # ============================================================================
890
+
891
+
892
+ def palette_metadata(name: str) -> dict[str, Any]:
893
+ """Return palette metadata without loading the full stops."""
894
+ entry = _get_index().get(name)
895
+ if entry is None:
896
+ raise UnknownPaletteError(_unknown_palette_message(name))
897
+ spine = _load_spine(name)
898
+ return {
899
+ "name": spine.name,
900
+ "family": entry["family"],
901
+ "description": spine.description or "",
902
+ "design_notes": spine.design_notes or "",
903
+ }
904
+
905
+
906
+ def list_palettes(
907
+ family: (
908
+ Literal["sequential", "diverging", "categorical", "scaffold", "tone"] | None
909
+ ) = None,
910
+ ) -> list[str]:
911
+ """List palette names, optionally filtered by family."""
912
+ idx = _get_index()
913
+ if family is None:
914
+ return sorted(idx)
915
+ return sorted(n for n, e in idx.items() if e["family"] == family)
916
+
917
+
918
+ # ============================================================================
919
+ # Smart default selection
920
+ # ============================================================================
921
+
922
+
923
+ def select_default_palette(
924
+ data_shape: Literal[
925
+ "continuous_numeric", "signed_numeric", "discrete_enum", "status_semantic"
926
+ ],
927
+ ) -> str:
928
+ """Pick a palette name based on inferred data shape. See Session 1 §A4."""
929
+ if data_shape == "continuous_numeric":
930
+ return "dft-seq-blue"
931
+ if data_shape == "signed_numeric":
932
+ return "dft-div-blue-red"
933
+ if data_shape == "discrete_enum":
934
+ return "category-10"
935
+ if data_shape == "status_semantic":
936
+ # Upstream caller looks up the specific role (negative/warning/etc.)
937
+ # after this returns the tone sentinel. For now, return "negative" as
938
+ # the canonical default (caller should override based on field value).
939
+ return "negative"
940
+ raise ValueError(f"unknown data_shape: {data_shape}")
941
+
942
+
943
+ # ── Bright/dark companion pairing for direct-label inking ───────────────────
944
+ #
945
+ # A "dark companion" palette is pair-defined with its bright counterpart: slot
946
+ # N in the bright palette is the same hue as slot N in the dark palette, just
947
+ # at a darker tone. Used for label ink that sits a notch darker than its mark
948
+ # colour (endpoint labels on multi-series line/area charts, ``per_series``
949
+ # strip labels on data_table-bearing charts).
950
+ #
951
+ # Pairs the engine knows about today:
952
+ # - category-10 → category-10-dark (stark theme)
953
+ # - editorial-10 → editorial-10-dark (editorial / cream themes)
954
+ #
955
+ # Future palette ``X`` shipped alongside ``X-dark`` works automatically — the
956
+ # resolver looks up ``<bright>-dark`` in the catalog and falls back to the
957
+ # bright colour itself if no dark companion is registered.
958
+ #
959
+ # Both helpers live here (not in render/) because they are pure palette
960
+ # indexing — no rendering, no spec construction. ``data_table_attachment.py``
961
+ # (compile layer) and ``standard_renderer.py`` (render layer) both consume
962
+ # the same companion-pairing path; placing the helpers here removes the
963
+ # inverted compile→render dependency that an earlier draft papered over with
964
+ # a deferred import.
965
+
966
+
967
+ @functools.lru_cache(maxsize=8)
968
+ def _palette_stops_cached(name: str) -> list[str]:
969
+ """Memoized palette-stop lookup keyed on palette name.
970
+
971
+ Sized for the handful of categorical palettes shipped with the catalog —
972
+ bright + dark companions for each theme, plus a small headroom. Replaces
973
+ the earlier hardcoded ``_category_10_bright_stops`` / ``_category_10_dark_stops``
974
+ helpers; their values fall out of this cache for the same memoization win.
975
+ """
976
+ return palette(name)
977
+
978
+
979
+ @functools.lru_cache(maxsize=8)
980
+ def _has_dark_companion(bright_palette_name: str) -> bool:
981
+ """Whether ``<bright_palette_name>-dark`` is registered in the catalog.
982
+
983
+ Cached because ``list_palettes`` scans the catalog directory; we call this
984
+ once per chart-render and want to avoid re-scanning per stop.
985
+ """
986
+ dark_name = f"{bright_palette_name}-dark"
987
+ return dark_name in list_palettes(family="categorical")
988
+
989
+
990
+ @functools.lru_cache(maxsize=1)
991
+ def _categorical_palettes_with_dark_companions() -> tuple[str, ...]:
992
+ """Names of categorical palettes that ship a ``<name>-dark`` companion.
993
+
994
+ Cached for the process lifetime — palette registration is static.
995
+ Ordering puts ``category-10`` first so the legacy default takes priority
996
+ when stops happen to overlap between catalogs.
997
+ """
998
+ names = list_palettes(family="categorical")
999
+ candidates = [n for n in names if not n.endswith("-dark")]
1000
+ paired = [n for n in candidates if f"{n}-dark" in names]
1001
+ # Stable order with category-10 first (legacy default), others alphabetic.
1002
+ paired.sort(key=lambda n: (0 if n == "category-10" else 1, n))
1003
+ return tuple(paired)
1004
+
1005
+
1006
+ def _find_dark_companion(c: str) -> str | None:
1007
+ """Find ``c``'s dark companion by scanning paired palettes for a slot match.
1008
+
1009
+ For each bright palette that has a ``<name>-dark`` registered, checks
1010
+ whether ``c`` appears in its stops. If found, returns the dark companion
1011
+ at the same slot index. Returns ``None`` if ``c`` is not a stop in any
1012
+ paired palette (custom-hex override → fall through to bright color).
1013
+
1014
+ Each emitted color is resolved independently, so author-picked non-slot-0
1015
+ palette stops resolve correctly (e.g. editorial-10[1] → editorial-10-dark[1]).
1016
+
1017
+ Note: if the caller passes wrong input — e.g. the line/area chart off-by-one
1018
+ tracked in chart-series-label-color-binding emits ``palette[1:n+1]`` instead
1019
+ of ``palette[:n]`` — each color is still found at its actual slot index, and
1020
+ the returned companion is at that index. The resolver does its job; the caller
1021
+ passed wrong input.
1022
+ """
1023
+ for bright_name in _categorical_palettes_with_dark_companions():
1024
+ bright_stops = _palette_stops_cached(bright_name)
1025
+ if c in bright_stops:
1026
+ idx = bright_stops.index(c)
1027
+ dark_stops = _palette_stops_cached(f"{bright_name}-dark")
1028
+ if idx < len(dark_stops):
1029
+ return dark_stops[idx]
1030
+ return None
1031
+
1032
+
1033
+ def resolve_dark_companion_stops(
1034
+ emitted_colors: list[str],
1035
+ bright_palette_name: str | None = None,
1036
+ ) -> list[str]:
1037
+ """Map a list of emitted mark colours to their dark-companion ink colours.
1038
+
1039
+ For each colour in ``emitted_colors``:
1040
+
1041
+ - If ``bright_palette_name`` is provided, find the colour's index in that
1042
+ palette and return the corresponding stop from ``<bright_palette_name>-dark``.
1043
+ - If ``bright_palette_name`` is None, scan all registered paired palettes for
1044
+ one whose stops contain the colour (per-color independent lookup). Each
1045
+ colour in the list is resolved independently — author-picked non-slot-0
1046
+ stops resolve correctly without any caller-side palette plumbing.
1047
+ - If no dark companion is found for a colour (custom override not in any
1048
+ palette, or no dark companion registered), fall back to the bright colour
1049
+ itself (label matches the mark without contrast bump).
1050
+
1051
+ ``bright_palette_name`` is optional. When unset, the per-color scan handles
1052
+ theme-cycled stops, author-picked palette slots, and cross-palette mixes
1053
+ automatically. Callers that know the palette name should still pass it
1054
+ for explicit disambiguation (forward-compat for chart-series-label-color-
1055
+ binding plumbing once compiled-style carries the palette name).
1056
+
1057
+ Used by both ``_build_endpoint_label_pane`` (endpoint labels) and the
1058
+ ``per_series`` data-table strip row emitter so the two surfaces share
1059
+ the same palette-companion pairing path.
1060
+
1061
+ Future direction — slot-keyed lookup. This helper is hex-keyed because
1062
+ the compile pipeline already discards the palette name at validation:
1063
+ ``CompiledChartsStyle._expand_palette_name`` expands ``"editorial-10"``
1064
+ into a ``list[str]`` of hex stops, so downstream only hex is visible.
1065
+ Once color tokens become first-class on the authoring surface (the
1066
+ ``dashboard-color-roles`` initiative; tokens like ``palette.editorial-10.2``
1067
+ and role bindings flowing through the cascade), the natural shape is
1068
+ ``dark_companion(palette_name, slot_idx) -> str``: token-aware callers
1069
+ pass ``(palette, slot)`` tuples and bypass the per-color scan. Slot-keyed
1070
+ has three advantages over hex-keyed worth preserving in design memory:
1071
+
1072
+ 1. No slot-0 collision risk. ``palette.single-blue.0`` and
1073
+ ``palette.category-10.0`` share ``#2d74b3``; hex-keyed picks one
1074
+ by scan order. Token-keyed disambiguates by construction.
1075
+ 2. O(1) lookup. Hex-keyed scans the catalog; slot-keyed indexes.
1076
+ 3. Robust to palette content edits. A Round-B hex tune silently
1077
+ breaks hex-keyed lookups; slot references survive.
1078
+
1079
+ The migration path: keep this hex-keyed entry point as a fallback, add a
1080
+ slot-keyed entry point once tokens land, and let callers opt into the
1081
+ new path as their inputs become token-aware. The optional
1082
+ ``bright_palette_name`` parameter is the structural seam — callers that
1083
+ know the palette name today (or once the chart-series-label-color-binding
1084
+ plumbing exposes it) already bypass the catalog scan.
1085
+ """
1086
+ if bright_palette_name is not None:
1087
+ if not _has_dark_companion(bright_palette_name):
1088
+ return list(emitted_colors)
1089
+ bright_stops = _palette_stops_cached(bright_palette_name)
1090
+ dark_stops = _palette_stops_cached(f"{bright_palette_name}-dark")
1091
+ result: list[str] = []
1092
+ for c in emitted_colors:
1093
+ try:
1094
+ idx = bright_stops.index(c)
1095
+ except ValueError:
1096
+ idx = -1
1097
+ result.append(dark_stops[idx] if 0 <= idx < len(dark_stops) else c)
1098
+ return result
1099
+
1100
+ return [_find_dark_companion(c) or c for c in emitted_colors]