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.
- d3_format/__init__.py +14 -0
- d3_format/errors.py +19 -0
- d3_format/format.py +551 -0
- d3_format/spec.py +159 -0
- dataface/DATAFACE_SYNTAX.md +1135 -0
- dataface/__init__.py +93 -0
- dataface/_docs_site.py +20 -0
- dataface/_install_hint.py +26 -0
- dataface/agent_api/__init__.py +79 -0
- dataface/agent_api/_init_templates/__init__.py +0 -0
- dataface/agent_api/_init_templates/agents_dft_snippet.md +26 -0
- dataface/agent_api/_init_templates/dataface.yml +15 -0
- dataface/agent_api/_init_templates/faces-dataface.yml +144 -0
- dataface/agent_api/_init_templates/index.md +24 -0
- dataface/agent_api/_paths.py +118 -0
- dataface/agent_api/_project_agents_md.py +43 -0
- dataface/agent_api/_session_store.py +486 -0
- dataface/agent_api/_state.py +28 -0
- dataface/agent_api/chat.py +221 -0
- dataface/agent_api/dashboards.py +257 -0
- dataface/agent_api/describe.py +366 -0
- dataface/agent_api/describe_query.py +120 -0
- dataface/agent_api/docs/__init__.py +25 -0
- dataface/agent_api/docs/_loader.py +292 -0
- dataface/agent_api/docs/yaml-reference.md +2757 -0
- dataface/agent_api/file_refs.py +118 -0
- dataface/agent_api/init.py +126 -0
- dataface/agent_api/inspect.py +128 -0
- dataface/agent_api/mcp_install.py +170 -0
- dataface/agent_api/query.py +274 -0
- dataface/agent_api/schema.py +658 -0
- dataface/agent_api/schema_search.py +284 -0
- dataface/agent_api/search.py +270 -0
- dataface/agent_api/skill_install.py +141 -0
- dataface/agent_api/skill_render.py +90 -0
- dataface/agent_api/skills.py +293 -0
- dataface/agent_api/surface_aliases.yaml +128 -0
- dataface/agent_api/validate.py +175 -0
- dataface/agent_api/validate_query.py +84 -0
- dataface/ai/__init__.py +39 -0
- dataface/ai/agent.py +139 -0
- dataface/ai/context.py +45 -0
- dataface/ai/events.py +62 -0
- dataface/ai/external_mcp.py +610 -0
- dataface/ai/generate_sql.py +96 -0
- dataface/ai/llm.py +403 -0
- dataface/ai/mcp/__init__.py +51 -0
- dataface/ai/mcp/server.py +289 -0
- dataface/ai/memories.py +85 -0
- dataface/ai/prompts.py +177 -0
- dataface/ai/schema_context.py +138 -0
- dataface/ai/skills/before-after-comparison/SKILL.md +102 -0
- dataface/ai/skills/before-after-comparison/examples/before-after-comparison.yml +24 -0
- dataface/ai/skills/dashboard-build/SKILL.md +212 -0
- dataface/ai/skills/dashboard-build/examples/_smoke.yml +15 -0
- dataface/ai/skills/dashboard-design/SKILL.md +182 -0
- dataface/ai/skills/dashboard-review/SKILL.md +113 -0
- dataface/ai/skills/dashboard-structural-review/SKILL.md +173 -0
- dataface/ai/skills/dashboard-visual-review/SKILL.md +139 -0
- dataface/ai/skills/dataface-mcp-setup/SKILL.md +177 -0
- dataface/ai/skills/dataface-troubleshooting/SKILL.md +225 -0
- dataface/ai/skills/drill-down-link/SKILL.md +112 -0
- dataface/ai/skills/drill-down-link/examples/drill-down-link.yml +27 -0
- dataface/ai/skills/faceted-small-multiples/SKILL.md +116 -0
- dataface/ai/skills/faceted-small-multiples/examples/faceted-small-multiples.yml +33 -0
- dataface/ai/skills/filter-bar-with-variables/SKILL.md +105 -0
- dataface/ai/skills/filter-bar-with-variables/examples/filter-bar-with-variables.yml +49 -0
- dataface/ai/skills/kpi-row/SKILL.md +101 -0
- dataface/ai/skills/kpi-row/examples/kpi-row.yml +55 -0
- dataface/ai/skills/report-design/SKILL.md +184 -0
- dataface/ai/skills/single-metric-bignum/SKILL.md +90 -0
- dataface/ai/skills/single-metric-bignum/examples/single-metric-bignum.yml +27 -0
- dataface/ai/skills/table-heavy-ops-dashboard/SKILL.md +114 -0
- dataface/ai/skills/table-heavy-ops-dashboard/examples/table-heavy-ops-dashboard.yml +48 -0
- dataface/ai/skills/time-series-trend/SKILL.md +93 -0
- dataface/ai/skills/time-series-trend/examples/time-series-trend.yml +26 -0
- dataface/ai/skills/top-n-with-detail/SKILL.md +98 -0
- dataface/ai/skills/top-n-with-detail/examples/top-n-with-detail.yml +45 -0
- dataface/ai/skills/two-by-two-grid-overview/SKILL.md +78 -0
- dataface/ai/skills/two-by-two-grid-overview/examples/two-by-two-grid-overview.yml +64 -0
- dataface/ai/tool_schemas.py +132 -0
- dataface/ai/tools/__init__.py +312 -0
- dataface/ai/yaml_utils.py +57 -0
- dataface/cli/__init__.py +3 -0
- dataface/cli/_console.py +48 -0
- dataface/cli/_error_format.py +83 -0
- dataface/cli/_extras.py +190 -0
- dataface/cli/_json_output.py +8 -0
- dataface/cli/_parsing.py +17 -0
- dataface/cli/_version_info.py +56 -0
- dataface/cli/commands/__init__.py +3 -0
- dataface/cli/commands/_agent_input.py +205 -0
- dataface/cli/commands/_agent_server.py +115 -0
- dataface/cli/commands/chat.py +645 -0
- dataface/cli/commands/describe.py +107 -0
- dataface/cli/commands/docs.py +131 -0
- dataface/cli/commands/extension.py +179 -0
- dataface/cli/commands/init.py +240 -0
- dataface/cli/commands/inspect.py +94 -0
- dataface/cli/commands/mcp_init.py +167 -0
- dataface/cli/commands/query.py +386 -0
- dataface/cli/commands/render.py +291 -0
- dataface/cli/commands/schema.py +411 -0
- dataface/cli/commands/search.py +49 -0
- dataface/cli/commands/serve.py +114 -0
- dataface/cli/commands/skills.py +133 -0
- dataface/cli/commands/skills_init.py +161 -0
- dataface/cli/commands/validate.py +63 -0
- dataface/cli/main.py +1501 -0
- dataface/core/__init__.py +75 -0
- dataface/core/compile/__init__.py +244 -0
- dataface/core/compile/_jinja_helpers.py +78 -0
- dataface/core/compile/channel.py +222 -0
- dataface/core/compile/chart_focus.py +101 -0
- dataface/core/compile/chart_resolved.py +169 -0
- dataface/core/compile/chart_type_detection.py +489 -0
- dataface/core/compile/chart_update.py +261 -0
- dataface/core/compile/colors.py +64 -0
- dataface/core/compile/compiler.py +904 -0
- dataface/core/compile/config.py +823 -0
- dataface/core/compile/custom_chart_types.py +208 -0
- dataface/core/compile/data_table_attachment.py +1287 -0
- dataface/core/compile/detect.py +110 -0
- dataface/core/compile/errors.py +302 -0
- dataface/core/compile/filter_injection.py +319 -0
- dataface/core/compile/introspection.py +527 -0
- dataface/core/compile/jinja.py +511 -0
- dataface/core/compile/labels_env.py +52 -0
- dataface/core/compile/markdown.py +154 -0
- dataface/core/compile/meta.py +388 -0
- dataface/core/compile/models/__init__.py +0 -0
- dataface/core/compile/models/chart/__init__.py +0 -0
- dataface/core/compile/models/chart/authored.py +2137 -0
- dataface/core/compile/models/chart/compiled.py +398 -0
- dataface/core/compile/models/config.py +347 -0
- dataface/core/compile/models/face/__init__.py +0 -0
- dataface/core/compile/models/face/authored.py +659 -0
- dataface/core/compile/models/face/compiled.py +522 -0
- dataface/core/compile/models/factories.py +201 -0
- dataface/core/compile/models/markers.py +40 -0
- dataface/core/compile/models/palette.py +36 -0
- dataface/core/compile/models/primitives.py +415 -0
- dataface/core/compile/models/query/__init__.py +0 -0
- dataface/core/compile/models/query/authored.py +246 -0
- dataface/core/compile/models/query/compiled.py +710 -0
- dataface/core/compile/models/refs.py +137 -0
- dataface/core/compile/models/source.py +611 -0
- dataface/core/compile/models/style/__init__.py +0 -0
- dataface/core/compile/models/style/authored.py +481 -0
- dataface/core/compile/models/style/compiled.py +3399 -0
- dataface/core/compile/models/style/merged.py +1682 -0
- dataface/core/compile/models/theme.py +362 -0
- dataface/core/compile/models/variable/__init__.py +0 -0
- dataface/core/compile/models/variable/authored.py +254 -0
- dataface/core/compile/models/vega_lite/__init__.py +0 -0
- dataface/core/compile/models/vega_lite/config.py +510 -0
- dataface/core/compile/models/vega_lite/contracts.py +171 -0
- dataface/core/compile/normalize_charts.py +494 -0
- dataface/core/compile/normalize_layout.py +1000 -0
- dataface/core/compile/normalize_queries.py +297 -0
- dataface/core/compile/normalize_variables.py +489 -0
- dataface/core/compile/normalizer.py +543 -0
- dataface/core/compile/palette.py +1100 -0
- dataface/core/compile/parameterized.py +658 -0
- dataface/core/compile/parser.py +228 -0
- dataface/core/compile/schema.py +20 -0
- dataface/core/compile/schema_renderers/__init__.py +0 -0
- dataface/core/compile/schema_renderers/json_schema.py +163 -0
- dataface/core/compile/schema_renderers/prompt.py +152 -0
- dataface/core/compile/schema_renderers/vscode_schema.py +301 -0
- dataface/core/compile/sizing.py +2126 -0
- dataface/core/compile/sources.py +518 -0
- dataface/core/compile/sql_authoring_lint.py +56 -0
- dataface/core/compile/style_cascade.py +471 -0
- dataface/core/compile/typography.py +299 -0
- dataface/core/compile/validator.py +301 -0
- dataface/core/compile/variables.py +53 -0
- dataface/core/compile/vega_config.py +98 -0
- dataface/core/compile/vega_lite/__init__.py +6 -0
- dataface/core/compile/vega_lite/validation.py +95 -0
- dataface/core/compile/yaml_error_formatter.py +838 -0
- dataface/core/connections.py +38 -0
- dataface/core/dashboard.py +358 -0
- dataface/core/defaults/default_config.yml +101 -0
- dataface/core/defaults/palettes/categorical/category-10-dark.yml +32 -0
- dataface/core/defaults/palettes/categorical/category-10-light.yml +43 -0
- dataface/core/defaults/palettes/categorical/category-10.yml +31 -0
- dataface/core/defaults/palettes/categorical/category-6-tonal-blue.yml +22 -0
- dataface/core/defaults/palettes/categorical/category-6-tonal-brown.yml +29 -0
- dataface/core/defaults/palettes/categorical/category-6-tonal-green.yml +20 -0
- dataface/core/defaults/palettes/categorical/category-6-tonal-orange.yml +21 -0
- dataface/core/defaults/palettes/categorical/category-6-tonal-purple.yml +20 -0
- dataface/core/defaults/palettes/categorical/editorial-10-dark.yml +32 -0
- dataface/core/defaults/palettes/categorical/editorial-10.yml +40 -0
- dataface/core/defaults/palettes/categorical/hero-6.yml +17 -0
- dataface/core/defaults/palettes/categorical/single-blue.yml +11 -0
- dataface/core/defaults/palettes/categorical/tableau.yml +20 -0
- dataface/core/defaults/palettes/data/xkcd_colors.json +3803 -0
- dataface/core/defaults/palettes/diverging/blue-red.yml +25 -0
- dataface/core/defaults/palettes/diverging/coolwarm.yml +24 -0
- dataface/core/defaults/palettes/diverging/crimson-green.yml +23 -0
- dataface/core/defaults/palettes/diverging/orange-teal.yml +23 -0
- dataface/core/defaults/palettes/diverging/sunset.yml +24 -0
- dataface/core/defaults/palettes/scaffold/dft-creams.yml +38 -0
- dataface/core/defaults/palettes/scaffold/dft-grays.yml +53 -0
- dataface/core/defaults/palettes/sequential/amber.yml +22 -0
- dataface/core/defaults/palettes/sequential/blue.yml +22 -0
- dataface/core/defaults/palettes/sequential/brown.yml +22 -0
- dataface/core/defaults/palettes/sequential/gray.yml +22 -0
- dataface/core/defaults/palettes/sequential/green.yml +22 -0
- dataface/core/defaults/palettes/sequential/purple.yml +22 -0
- dataface/core/defaults/palettes/sequential/rust.yml +22 -0
- dataface/core/defaults/palettes/sequential/teal.yml +22 -0
- dataface/core/defaults/palettes/tone/negative.yml +32 -0
- dataface/core/defaults/palettes/tone/positive.yml +22 -0
- dataface/core/defaults/palettes/tone/warning.yml +22 -0
- dataface/core/defaults/themes/_base.yaml +786 -0
- dataface/core/defaults/themes/bi.yaml +16 -0
- dataface/core/defaults/themes/carbong100.yaml +41 -0
- dataface/core/defaults/themes/cream.yaml +122 -0
- dataface/core/defaults/themes/dark.yaml +40 -0
- dataface/core/defaults/themes/diagnostics-title-angle-extreme.yaml +9 -0
- dataface/core/defaults/themes/diagnostics-title-baseline-extreme.yaml +9 -0
- dataface/core/defaults/themes/diagnostics-title-baseline.yaml +24 -0
- dataface/core/defaults/themes/diagnostics-title-center.yaml +8 -0
- dataface/core/defaults/themes/diagnostics-title-color-extreme.yaml +24 -0
- dataface/core/defaults/themes/diagnostics-title-font-extreme.yaml +25 -0
- dataface/core/defaults/themes/diagnostics-title-left.yaml +8 -0
- dataface/core/defaults/themes/diagnostics-title-offset-extreme.yaml +9 -0
- dataface/core/defaults/themes/diagnostics-title-size-extreme.yaml +24 -0
- dataface/core/defaults/themes/diagnostics-title-weight-extreme.yaml +24 -0
- dataface/core/defaults/themes/editorial.yaml +147 -0
- dataface/core/defaults/themes/light.yaml +30 -0
- dataface/core/defaults/themes/looker.yaml +17 -0
- dataface/core/defaults/themes/stark.yaml +134 -0
- dataface/core/errors/__init__.py +67 -0
- dataface/core/errors/codes_compile.py +56 -0
- dataface/core/errors/codes_execute.py +177 -0
- dataface/core/errors/codes_render.py +106 -0
- dataface/core/errors/codes_unknown.py +15 -0
- dataface/core/errors/hints.py +74 -0
- dataface/core/errors/registry.py +42 -0
- dataface/core/errors/structured.py +92 -0
- dataface/core/execute/__init__.py +91 -0
- dataface/core/execute/adapters/__init__.py +49 -0
- dataface/core/execute/adapters/adapter_registry.py +400 -0
- dataface/core/execute/adapters/base.py +245 -0
- dataface/core/execute/adapters/csv_adapter.py +239 -0
- dataface/core/execute/adapters/dbt_adapter.py +283 -0
- dataface/core/execute/adapters/dbt_adapter_factory.py +212 -0
- dataface/core/execute/adapters/dbt_macro_loader.py +95 -0
- dataface/core/execute/adapters/dbt_utils.py +150 -0
- dataface/core/execute/adapters/http_adapter.py +224 -0
- dataface/core/execute/adapters/metricflow_adapter.py +94 -0
- dataface/core/execute/adapters/schema_resolver_adapter.py +144 -0
- dataface/core/execute/adapters/sql_adapter.py +710 -0
- dataface/core/execute/adapters/values_adapter.py +58 -0
- dataface/core/execute/batch.py +744 -0
- dataface/core/execute/cache_backend.py +135 -0
- dataface/core/execute/cache_keys.py +66 -0
- dataface/core/execute/dbt_jinja.py +21 -0
- dataface/core/execute/dialects/__init__.py +121 -0
- dataface/core/execute/dialects/athena.py +75 -0
- dataface/core/execute/dialects/base.py +302 -0
- dataface/core/execute/dialects/bigquery.py +38 -0
- dataface/core/execute/dialects/databricks.py +68 -0
- dataface/core/execute/dialects/duckdb.py +35 -0
- dataface/core/execute/dialects/mysql.py +68 -0
- dataface/core/execute/dialects/postgres.py +39 -0
- dataface/core/execute/dialects/redshift.py +12 -0
- dataface/core/execute/dialects/snowflake.py +51 -0
- dataface/core/execute/dialects/sqlserver.py +92 -0
- dataface/core/execute/duckdb_cache.py +712 -0
- dataface/core/execute/duckdb_config.py +26 -0
- dataface/core/execute/errors.py +213 -0
- dataface/core/execute/executor.py +1249 -0
- dataface/core/execute/parallel.py +162 -0
- dataface/core/execute/setup_sql.py +58 -0
- dataface/core/execute/source_registry.py +72 -0
- dataface/core/execute/source_resolver.py +255 -0
- dataface/core/execute/sql_guard.py +387 -0
- dataface/core/execute/sql_literals.py +199 -0
- dataface/core/fonts.py +52 -0
- dataface/core/inspect/__init__.py +32 -0
- dataface/core/inspect/cache_factory.py +98 -0
- dataface/core/inspect/db_types.py +162 -0
- dataface/core/inspect/dbt_schema.py +96 -0
- dataface/core/inspect/defaults.yml +37 -0
- dataface/core/inspect/fanout_risk.py +109 -0
- dataface/core/inspect/manifest_utils.py +77 -0
- dataface/core/inspect/partials/categorical.yml +40 -0
- dataface/core/inspect/partials/date.yml +40 -0
- dataface/core/inspect/partials/numeric.yml +55 -0
- dataface/core/inspect/partition_types.py +38 -0
- dataface/core/inspect/query_validator.py +975 -0
- dataface/core/inspect/renderer.py +354 -0
- dataface/core/inspect/resolver.py +808 -0
- dataface/core/inspect/search.py +461 -0
- dataface/core/inspect/sources/__init__.py +32 -0
- dataface/core/inspect/sources/dbt.py +738 -0
- dataface/core/inspect/sources/duckdb_utils.py +66 -0
- dataface/core/inspect/templates/__init__.py +1 -0
- dataface/core/inspect/templates/categorical_column.yml +196 -0
- dataface/core/inspect/templates/charts.yml +109 -0
- dataface/core/inspect/templates/date_column.yml +248 -0
- dataface/core/inspect/templates/model.yml +138 -0
- dataface/core/inspect/templates/numeric_column.yml +261 -0
- dataface/core/inspect/templates/quality.yml +80 -0
- dataface/core/inspect/templates/string_column.yml +263 -0
- dataface/core/project_roots.py +165 -0
- dataface/core/render/__init__.py +87 -0
- dataface/core/render/board_links.py +176 -0
- dataface/core/render/chart/__init__.py +27 -0
- dataface/core/render/chart/arc_attached_table.py +251 -0
- dataface/core/render/chart/artifacts.py +16 -0
- dataface/core/render/chart/callout.py +225 -0
- dataface/core/render/chart/decisions.py +358 -0
- dataface/core/render/chart/geo.py +700 -0
- dataface/core/render/chart/kpi.py +916 -0
- dataface/core/render/chart/labels.py +76 -0
- dataface/core/render/chart/pipeline.py +818 -0
- dataface/core/render/chart/presentation.py +36 -0
- dataface/core/render/chart/profile.py +3438 -0
- dataface/core/render/chart/render_single.py +347 -0
- dataface/core/render/chart/renderers.py +193 -0
- dataface/core/render/chart/rendering.py +565 -0
- dataface/core/render/chart/serialization.py +90 -0
- dataface/core/render/chart/spark.py +496 -0
- dataface/core/render/chart/spark_bar.py +370 -0
- dataface/core/render/chart/spec_builders.py +154 -0
- dataface/core/render/chart/standard_renderer.py +2645 -0
- dataface/core/render/chart/table.py +2957 -0
- dataface/core/render/chart/table_support.py +1452 -0
- dataface/core/render/chart/tick_values.py +66 -0
- dataface/core/render/chart/time_unit_detect.py +809 -0
- dataface/core/render/chart/title_overflow.py +157 -0
- dataface/core/render/chart/type_inference.py +122 -0
- dataface/core/render/chart/validation.py +99 -0
- dataface/core/render/chart/vega_lite.py +125 -0
- dataface/core/render/chart/vega_lite_types.py +268 -0
- dataface/core/render/chart/vl_field_maps.py +346 -0
- dataface/core/render/chart_interactivity.py +24 -0
- dataface/core/render/control_registry.py +287 -0
- dataface/core/render/converters/__init__.py +24 -0
- dataface/core/render/converters/chart.py +276 -0
- dataface/core/render/converters/html.py +98 -0
- dataface/core/render/converters/pdf.py +40 -0
- dataface/core/render/converters/png.py +41 -0
- dataface/core/render/errors.py +144 -0
- dataface/core/render/face_api.py +160 -0
- dataface/core/render/faces.py +1194 -0
- dataface/core/render/font_measurement.py +48 -0
- dataface/core/render/font_support.py +197 -0
- dataface/core/render/fonts/DFTSansTabular-Regular.ttf +0 -0
- dataface/core/render/fonts/DFTSansTabular-Regular.woff2 +0 -0
- dataface/core/render/fonts/DFTSerifOldstyleProportional-Regular.ttf +0 -0
- dataface/core/render/fonts/DFTSerifOldstyleTabular-Regular.ttf +0 -0
- dataface/core/render/fonts/InterVariable.ttf +0 -0
- dataface/core/render/fonts/InterVariable.woff2 +0 -0
- dataface/core/render/fonts/NOTO_COLOR_EMOJI_LICENSE.txt +93 -0
- dataface/core/render/fonts/NOTO_EMOJI_LICENSE.txt +93 -0
- dataface/core/render/fonts/NotoColorEmoji-Regular.ttf +0 -0
- dataface/core/render/fonts/NotoColorEmoji-Regular.woff2 +0 -0
- dataface/core/render/fonts/NotoEmoji-Regular.ttf +0 -0
- dataface/core/render/fonts/NotoEmoji-Regular.woff2 +0 -0
- dataface/core/render/fonts/SOURCE_CODE_PRO_LICENSE.txt +93 -0
- dataface/core/render/fonts/SOURCE_SERIF_4_LICENSE.txt +98 -0
- dataface/core/render/fonts/SourceCodePro-Regular.ttf +0 -0
- dataface/core/render/fonts/SourceSerif4-Regular.ttf +0 -0
- dataface/core/render/fonts/_emoji_font_face.css +43 -0
- dataface/core/render/fonts/source-serif-4-variable-latin.woff2 +0 -0
- dataface/core/render/format_utils.py +329 -0
- dataface/core/render/geo_defaults.yml +28 -0
- dataface/core/render/json_format.py +146 -0
- dataface/core/render/layout_sizing.py +865 -0
- dataface/core/render/layouts.py +541 -0
- dataface/core/render/markdown_defaults.yml +16 -0
- dataface/core/render/missing_vars_prompt.py +79 -0
- dataface/core/render/placeholder.py +389 -0
- dataface/core/render/render_result.py +14 -0
- dataface/core/render/renderer.py +467 -0
- dataface/core/render/script_embedding.py +16 -0
- dataface/core/render/svg_utils.py +212 -0
- dataface/core/render/template_loader.py +69 -0
- dataface/core/render/templates/controls/_styles.css +606 -0
- dataface/core/render/templates/controls/checkbox.html +16 -0
- dataface/core/render/templates/controls/date.html +16 -0
- dataface/core/render/templates/controls/number.html +19 -0
- dataface/core/render/templates/controls/readonly.html +9 -0
- dataface/core/render/templates/controls/select.html +21 -0
- dataface/core/render/templates/controls/slider.html +22 -0
- dataface/core/render/templates/controls/text.html +16 -0
- dataface/core/render/templates/scripts/chart_interactivity.js +191 -0
- dataface/core/render/templates/scripts/variables.js +976 -0
- dataface/core/render/templates/svg/grid_pattern.svg +3 -0
- dataface/core/render/templates/svg/styles.css +51 -0
- dataface/core/render/terminal.py +311 -0
- dataface/core/render/terminal_charts.py +563 -0
- dataface/core/render/terminal_defaults.yml +2 -0
- dataface/core/render/terminal_layouts.py +299 -0
- dataface/core/render/terminal_text.py +31 -0
- dataface/core/render/text/__init__.py +1 -0
- dataface/core/render/text/case.py +113 -0
- dataface/core/render/text_format.py +129 -0
- dataface/core/render/utils.py +106 -0
- dataface/core/render/variable_controls.py +946 -0
- dataface/core/render/variable_input_refinement.py +140 -0
- dataface/core/render/warnings/__init__.py +15 -0
- dataface/core/render/warnings/bar_color_1_to_1_with_x.py +80 -0
- dataface/core/render/warnings/base.py +44 -0
- dataface/core/render/warnings/fanout_risk.py +15 -0
- dataface/core/render/warnings/from_query_diagnostic.py +56 -0
- dataface/core/render/warnings/missing_join_predicate.py +13 -0
- dataface/core/render/warnings/query_parse_error.py +14 -0
- dataface/core/render/warnings/query_returned_zero_rows.py +42 -0
- dataface/core/render/warnings/reaggregation.py +14 -0
- dataface/core/render/warnings/registry.py +45 -0
- dataface/core/render/warnings/suppression.py +46 -0
- dataface/core/render/warnings/temporal_single_point.py +63 -0
- dataface/core/render/warnings/unreferenced_chart.py +15 -0
- dataface/core/render/warnings/y_encoding_mostly_null.py +76 -0
- dataface/core/render/yaml_format.py +167 -0
- dataface/core/resolve_face.py +195 -0
- dataface/core/schema/__init__.py +0 -0
- dataface/core/schema/guidance.py +151 -0
- dataface/core/scoped_paths.py +59 -0
- dataface/core/serve/__init__.py +14 -0
- dataface/core/serve/bootstrap.py +39 -0
- dataface/core/serve/embedded.py +57 -0
- dataface/core/serve/port.py +129 -0
- dataface/core/serve/server.py +938 -0
- dataface/core/serve/templates/__init__.py +0 -0
- dataface/core/serve/templates/directory.yml +6 -0
- dataface/core/serve/templates/error.html.j2 +217 -0
- dataface/core/utils.py +121 -0
- dataface/core/validate.py +64 -0
- dataface/integrations/__init__.py +0 -0
- dataface/integrations/highlighting.py +351 -0
- dataface/integrations/markdown.py +537 -0
- dataface/py.typed +0 -0
- dataface-0.1.2.dist-info/METADATA +375 -0
- dataface-0.1.2.dist-info/RECORD +455 -0
- dataface-0.1.2.dist-info/WHEEL +4 -0
- dataface-0.1.2.dist-info/entry_points.txt +2 -0
- dataface-0.1.2.dist-info/licenses/LICENSE +202 -0
- mdsvg/__init__.py +168 -0
- mdsvg/fonts.py +656 -0
- mdsvg/images.py +299 -0
- mdsvg/parser.py +629 -0
- mdsvg/playground.py +284 -0
- mdsvg/py.typed +2 -0
- mdsvg/renderer.py +1623 -0
- mdsvg/style.py +355 -0
- mdsvg/types.py +200 -0
- 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]
|