dataface 0.2.1.dev323__py3-none-any.whl → 0.2.1.dev377__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 (80) hide show
  1. dataface/agent_api/_link_scan.py +125 -0
  2. dataface/agent_api/cache.py +6 -2
  3. dataface/agent_api/dashboards.py +10 -0
  4. dataface/agent_api/docs/yaml-reference.md +35 -18
  5. dataface/agent_api/files.py +128 -0
  6. dataface/agent_api/project_session.py +2 -0
  7. dataface/agent_api/surface_aliases.yaml +4 -0
  8. dataface/ai/agent.py +31 -2
  9. dataface/ai/skills/analyst-runbook/SKILL.md +3 -0
  10. dataface/ai/skills/dashboard-build/SKILL.md +4 -3
  11. dataface/ai/skills/dashboard-design/SKILL.md +17 -8
  12. dataface/ai/skills/dashboard-replicate/SKILL.md +175 -0
  13. dataface/ai/skills/dashboard-structural-review/SKILL.md +2 -1
  14. dataface/ai/tool_schemas.py +39 -1
  15. dataface/ai/tools/__init__.py +43 -1
  16. dataface/core/compile/enrich.py +38 -0
  17. dataface/core/compile/inherit_graph.py +0 -31
  18. dataface/core/compile/inherit_registry.yaml +0 -12
  19. dataface/core/compile/models/chart/authored/_base.py +45 -14
  20. dataface/core/compile/models/chart/authored/point_map.py +17 -0
  21. dataface/core/compile/models/face/authored.py +4 -1
  22. dataface/core/compile/models/source.py +16 -28
  23. dataface/core/compile/models/style/resolved/__init__.py +2 -0
  24. dataface/core/compile/models/style/resolved/_base.py +15 -21
  25. dataface/core/compile/models/style/resolved/_cascade.py +31 -62
  26. dataface/core/compile/models/style/theme/axis.py +23 -0
  27. dataface/core/compile/models/style/theme/board.py +17 -0
  28. dataface/core/compile/models/style/theme/charts.py +20 -10
  29. dataface/core/compile/models/style/theme/marks.py +7 -8
  30. dataface/core/compile/normalize_charts.py +11 -16
  31. dataface/core/compile/normalize_queries.py +42 -33
  32. dataface/core/compile/normalizer.py +71 -9
  33. dataface/core/compile/resolve/_resolve.py +136 -170
  34. dataface/core/compile/schema_renderers/completion_catalog.py +42 -0
  35. dataface/core/compile/style_cascade.py +143 -31
  36. dataface/core/dashboard.py +22 -5
  37. dataface/core/defaults/palettes/scaffold/dft-grays.yml +6 -1
  38. dataface/core/defaults/themes/_base.yaml +11 -7
  39. dataface/core/defaults/themes/cream.yaml +1 -1
  40. dataface/core/defaults/themes/editorial.yaml +15 -1
  41. dataface/core/defaults/themes/neon.yaml +12 -0
  42. dataface/core/defaults/themes/plain.yaml +25 -2
  43. dataface/core/defaults/themes/stark.yaml +22 -10
  44. dataface/core/defaults/themes/vivid.yaml +15 -3
  45. dataface/core/project.py +27 -0
  46. dataface/core/render/chart/data_table_attachment.py +240 -88
  47. dataface/core/render/chart/emitters/_cartesian.py +61 -23
  48. dataface/core/render/chart/emitters/_layers.py +30 -12
  49. dataface/core/render/chart/emitters/_overlay.py +22 -9
  50. dataface/core/render/chart/emitters/_tooltip.py +210 -0
  51. dataface/core/render/chart/emitters/area.py +33 -12
  52. dataface/core/render/chart/emitters/bar.py +16 -7
  53. dataface/core/render/chart/emitters/line.py +26 -10
  54. dataface/core/render/chart/features/__init__.py +5 -0
  55. dataface/core/render/chart/features/structured_tooltip.py +419 -0
  56. dataface/core/render/chart/spec.py +13 -0
  57. dataface/core/render/chart/table.py +16 -1
  58. dataface/core/render/chart/table_overflow.py +78 -0
  59. dataface/core/render/chart/time_unit_detect.py +51 -1
  60. dataface/core/render/chart/translate.py +81 -6
  61. dataface/core/render/chart/type_inference.py +27 -3
  62. dataface/core/render/chart_interactivity.py +2 -0
  63. dataface/core/render/face_to_dict.py +48 -5
  64. dataface/core/render/faces.py +14 -6
  65. dataface/core/render/json_format.py +5 -3
  66. dataface/core/render/renderer.py +91 -33
  67. dataface/core/render/sizing.py +5 -7
  68. dataface/core/render/templates/scripts/chart_interactivity.js +450 -25
  69. dataface/core/render/text_format.py +16 -5
  70. dataface/core/render/warnings/base.py +9 -0
  71. dataface/core/render/warnings/registry.py +2 -0
  72. dataface/core/render/warnings/table_columns_overflow.py +50 -0
  73. dataface/core/render/yaml_format.py +27 -7
  74. {dataface-0.2.1.dev323.dist-info → dataface-0.2.1.dev377.dist-info}/METADATA +1 -1
  75. {dataface-0.2.1.dev323.dist-info → dataface-0.2.1.dev377.dist-info}/RECORD +78 -73
  76. dataface/agent_api/render_face.py +0 -135
  77. dataface/core/compile/schema_renderers/settings_form.py +0 -171
  78. {dataface-0.2.1.dev323.dist-info → dataface-0.2.1.dev377.dist-info}/WHEEL +0 -0
  79. {dataface-0.2.1.dev323.dist-info → dataface-0.2.1.dev377.dist-info}/entry_points.txt +0 -0
  80. {dataface-0.2.1.dev323.dist-info → dataface-0.2.1.dev377.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,125 @@
1
+ """Host-agnostic inbound-link scanning for move_file.
2
+
3
+ Scans the faces tree for references to a dashboard slug -- ``link:`` chart
4
+ fields and markdown link targets -- so a move can rewrite what it can prove
5
+ is a reference and report the rest. A first-class internal-link graph is out
6
+ of scope here (a likely-separate dft-core initiative); this is a narrow,
7
+ best-effort heuristic: exact syntactic matches are rewritten, everything else
8
+ (the slug's basename appearing in prose, without recognized link syntax) is
9
+ reported as a fuzzy hit and never touched.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+
16
+ from pydantic import BaseModel, ConfigDict, Field
17
+
18
+ from dataface.core.project import FACES_SUBDIR, Project
19
+
20
+ _FACES_GLOB = f"{FACES_SUBDIR}/**/*"
21
+
22
+
23
+ def face_slug(relpath: str) -> str | None:
24
+ """Return the dashboard slug for a ``faces/`` path (no prefix, no suffix).
25
+
26
+ ``faces/finance/rev.yml`` -> ``finance/rev``. Returns None for a path
27
+ outside ``faces/`` -- link scanning only has meaning for dashboard slugs.
28
+ """
29
+ prefix = f"{FACES_SUBDIR}/"
30
+ if not relpath.startswith(prefix):
31
+ return None
32
+ tail = relpath[len(prefix) :]
33
+ slug, dot, _suffix = tail.rpartition(".")
34
+ return slug if dot else tail
35
+
36
+
37
+ def _link_field_pattern(slug: str) -> re.Pattern[str]:
38
+ escaped = re.escape(slug)
39
+ return re.compile(rf'(link:\s*["\']?/?){escaped}(?=[\s"\'/?]|$)')
40
+
41
+
42
+ def _markdown_link_pattern(slug: str) -> re.Pattern[str]:
43
+ escaped = re.escape(slug)
44
+ return re.compile(rf"(\]\(/?){escaped}(?=[?)])")
45
+
46
+
47
+ class LinkHit(BaseModel):
48
+ model_config = ConfigDict(frozen=True)
49
+
50
+ path: str
51
+ line_number: int
52
+ line: str
53
+
54
+
55
+ class LinkScanResult(BaseModel):
56
+ model_config = ConfigDict(frozen=True)
57
+
58
+ exact: list[LinkHit] = Field(default_factory=list)
59
+ fuzzy: list[LinkHit] = Field(default_factory=list)
60
+
61
+
62
+ def scan_links(project: Project, slug: str) -> LinkScanResult:
63
+ """Scan the faces tree for references to *slug* (e.g. ``finance/rev``).
64
+
65
+ Greps for the slug's basename (the last path segment), so a same-folder
66
+ relative reference (``link: rev`` inside another face already under
67
+ ``finance/``) is picked up by the grep too -- but the exact-match regexes
68
+ below only recognize the *full* slug, so a bare relative reference is
69
+ always classified FUZZY (reported, never rewritten), never EXACT. Full
70
+ relative-link resolution is out of scope here (see the module docstring);
71
+ only a full-slug ``link:`` chart field or markdown link target classifies
72
+ as EXACT.
73
+ """
74
+ basename = slug.rsplit("/", 1)[-1]
75
+ link_re = _link_field_pattern(slug)
76
+ md_re = _markdown_link_pattern(slug)
77
+ exact: list[LinkHit] = []
78
+ fuzzy: list[LinkHit] = []
79
+ for hit in project.files.grep(basename, glob=_FACES_GLOB):
80
+ bucket = (
81
+ exact if (link_re.search(hit.line) or md_re.search(hit.line)) else fuzzy
82
+ )
83
+ bucket.append(
84
+ LinkHit(path=hit.relpath, line_number=hit.line_number, line=hit.line)
85
+ )
86
+ return LinkScanResult(exact=exact, fuzzy=fuzzy)
87
+
88
+
89
+ def plan_link_rewrites(
90
+ project: Project, old_slug: str, new_slug: str
91
+ ) -> tuple[LinkScanResult, dict[str, str]]:
92
+ """Compute exact-match link rewrites *old_slug* -> *new_slug* without writing.
93
+
94
+ Returns the scan (so the caller can report ``exact`` vs ``fuzzy``)
95
+ alongside a ``{path: rewritten_content}`` mapping for every file with an
96
+ exact hit. The caller commits it however it needs to: one file at a time
97
+ (``rewrite_links``, below) or batched into a single commit (Cloud's
98
+ governed dashboard move).
99
+ """
100
+ result = scan_links(project, old_slug)
101
+ if not result.exact:
102
+ return result, {}
103
+ link_re = _link_field_pattern(old_slug)
104
+ md_re = _markdown_link_pattern(old_slug)
105
+ replacement = new_slug.replace("\\", "\\\\")
106
+ rewrites: dict[str, str] = {}
107
+ for path in dict.fromkeys(hit.path for hit in result.exact):
108
+ content = project.read_text(path)
109
+ content = link_re.sub(rf"\g<1>{replacement}", content)
110
+ content = md_re.sub(rf"\g<1>{replacement}", content)
111
+ rewrites[path] = content
112
+ return result, rewrites
113
+
114
+
115
+ def rewrite_links(project: Project, old_slug: str, new_slug: str) -> LinkScanResult:
116
+ """Rewrite exact ``link:``/markdown references to *old_slug* -> *new_slug*
117
+ in place; fuzzy prose hits are left untouched.
118
+
119
+ Returns the scan computed before rewriting, so the caller can report
120
+ exactly what changed (``exact``) vs what needs a human look (``fuzzy``).
121
+ """
122
+ result, rewrites = plan_link_rewrites(project, old_slug, new_slug)
123
+ for path, content in rewrites.items():
124
+ project.write_text(path, content)
125
+ return result
@@ -4,9 +4,13 @@ from collections.abc import Generator
4
4
  from contextlib import contextmanager
5
5
  from pathlib import Path
6
6
 
7
- from dataface.core.execute.trivial_local_cache import TrivialDuckDBCache
7
+ # Re-exported: TrivialDuckDBCache is the declared return type of open_cache,
8
+ # so embedders annotating a held cache reach it here without a core import.
9
+ from dataface.core.execute.trivial_local_cache import (
10
+ TrivialDuckDBCache as TrivialDuckDBCache,
11
+ )
8
12
 
9
- __all__ = ["open_cache", "cache_ctx"]
13
+ __all__ = ["TrivialDuckDBCache", "open_cache", "cache_ctx"]
10
14
 
11
15
 
12
16
  def open_cache(path: Path | None) -> TrivialDuckDBCache:
@@ -93,6 +93,16 @@ class RenderDashboardArgs(BaseModel):
93
93
  yaml_content: str | None = Field(
94
94
  None, description="YAML content to render directly (use this OR path)"
95
95
  )
96
+ chart: str | None = Field(
97
+ None,
98
+ description=(
99
+ "Render only this chart (by chart id) from the dashboard, with its "
100
+ "dependent variables. Use to show one existing chart that answers "
101
+ "a question instead of re-deriving its query — the rendered chart "
102
+ "keeps the dashboard's canonical definition. Unknown ids fail with "
103
+ "the list of available chart ids."
104
+ ),
105
+ )
96
106
  variables: dict[str, Any] | None = Field(
97
107
  None, description="Variable values to apply to the dashboard"
98
108
  )
@@ -26,7 +26,7 @@ AuthoredFace (dataface) definition from YAML.
26
26
  | `details` | [FaceDetails](#facedetails) | ✓ | Collapsible section metadata. String shorthand: details: 'text' → FaceDetails(summary='text'). Block form: details: {summary: ..., expanded_title: ..., expanded: false}. |
27
27
  | `id` | str | ✓ | Explicit ID for this face. Auto-generated from filename if omitted. |
28
28
  | `style` | [Style](#style) | ✓ | Style patch (background, padding, border, etc.). Background and semantic color tokens (accent, muted) cascade to nested child faces. |
29
- | `width` | str \| int | ✓ | Width when nested (e.g., '50%', '400px', or an integer in pixels). |
29
+ | `width` | str \| int | ✓ | Width when nested (e.g., '50%', '400px', or an integer in pixels). On the root face there is no parent to place it into, so it instead sets the board's own width (equivalent to 'style.board.width') — percentages are rejected there since there's nothing to size relative to. |
30
30
  | `height` | str \| int | ✓ | Height when nested (e.g., '300px' or an integer in pixels). |
31
31
  | `visible` | bool \| str \| [SingleRowBoolProbe](#singlerowboolprobe) | ✓ | Controls whether this layout item is rendered. Accepts a bool, variable name, Jinja expression, or {query, column} probe. |
32
32
  | `extends` | str \| list[str] | ✓ | Face name(s) or relative path(s) this face inherits from, low to high priority. |
@@ -188,8 +188,8 @@ Authored patch for bar and histogram charts.
188
188
  | `sort` | [ChartSort](#chartsort) | ✓ | Sort configuration: column to sort by and direction (asc/desc). |
189
189
  | `multiples` | [MultiplesConfig](#multiplesconfig) | ✓ | Partition this chart into small multiples by a `rows` column (vertical stack), a `columns` column (side by side), or both (grid). Panels share one measure scale by default. |
190
190
  | `data_table` | [ChartDataTable](#chartdatatable) | ✓ | Optional mini data-grid attached below/above the chart. |
191
- | `height` | int \| float | ✓ | Explicit chart height in pixels. Positive number only. When set, overrides aspect_ratio and theme cascade. Only valid on cartesian chart families: area, bar, heatmap, histogram, line, and scatter. Other chart families use renderer-owned or layout-owned sizing contracts. |
192
- | `width` | int \| float | ✓ | Preferred chart width in pixels. Positive number only. Contributes to the dashboard's intrinsic width without overriding the final layout slot — the chart still fills its allocated container. Only valid on cartesian chart families: area, bar, heatmap, histogram, line, and scatter. Other chart families use renderer-owned or layout-owned sizing contracts. |
191
+ | `height` | int \| float | ✓ | Explicit chart height in pixels. Positive number only. When set, overrides aspect_ratio and theme cascade. Valid on cartesian chart families (area, bar, heatmap, histogram, line, scatter), pie/donut, and geo families (geoshape, map, point_map, bubble_map). Other chart families use renderer-owned or layout-owned sizing contracts. |
192
+ | `width` | int \| float | ✓ | Preferred chart width in pixels. Positive number only. Contributes to the dashboard's intrinsic width without overriding the final layout slot — the chart still fills its allocated container. Valid on cartesian chart families (area, bar, heatmap, histogram, line, scatter), pie/donut, and geo families (geoshape, map, point_map, bubble_map). Other chart families use renderer-owned or layout-owned sizing contracts. |
193
193
  | `style` | [BarChartStyle](#barchartstyle) | ✓ | Chart-local style overrides. |
194
194
  | `layers` | list[[BarLayer](#barlayer) \| [LineLayer](#linelayer) \| [AreaLayer](#arealayer) \| [ScatterLayer](#scatterlayer)] | ✓ | Typed overlay layers on this chart. |
195
195
 
@@ -217,8 +217,8 @@ Authored patch for line charts.
217
217
  | `sort` | [ChartSort](#chartsort) | ✓ | Sort configuration: column to sort by and direction (asc/desc). |
218
218
  | `multiples` | [MultiplesConfig](#multiplesconfig) | ✓ | Partition this chart into small multiples by a `rows` column (vertical stack), a `columns` column (side by side), or both (grid). Panels share one measure scale by default. |
219
219
  | `data_table` | [ChartDataTable](#chartdatatable) | ✓ | Optional mini data-grid attached below/above the chart. |
220
- | `height` | int \| float | ✓ | Explicit chart height in pixels. Positive number only. When set, overrides aspect_ratio and theme cascade. Only valid on cartesian chart families: area, bar, heatmap, histogram, line, and scatter. Other chart families use renderer-owned or layout-owned sizing contracts. |
221
- | `width` | int \| float | ✓ | Preferred chart width in pixels. Positive number only. Contributes to the dashboard's intrinsic width without overriding the final layout slot — the chart still fills its allocated container. Only valid on cartesian chart families: area, bar, heatmap, histogram, line, and scatter. Other chart families use renderer-owned or layout-owned sizing contracts. |
220
+ | `height` | int \| float | ✓ | Explicit chart height in pixels. Positive number only. When set, overrides aspect_ratio and theme cascade. Valid on cartesian chart families (area, bar, heatmap, histogram, line, scatter), pie/donut, and geo families (geoshape, map, point_map, bubble_map). Other chart families use renderer-owned or layout-owned sizing contracts. |
221
+ | `width` | int \| float | ✓ | Preferred chart width in pixels. Positive number only. Contributes to the dashboard's intrinsic width without overriding the final layout slot — the chart still fills its allocated container. Valid on cartesian chart families (area, bar, heatmap, histogram, line, scatter), pie/donut, and geo families (geoshape, map, point_map, bubble_map). Other chart families use renderer-owned or layout-owned sizing contracts. |
222
222
  | `style` | [LineChartStyle](#linechartstyle) | ✓ | Chart-local style overrides. |
223
223
  | `layers` | list[[BarLayer](#barlayer) \| [LineLayer](#linelayer) \| [AreaLayer](#arealayer) \| [ScatterLayer](#scatterlayer)] | ✓ | Typed overlay layers on this chart. |
224
224
 
@@ -246,8 +246,8 @@ Authored patch for area charts.
246
246
  | `sort` | [ChartSort](#chartsort) | ✓ | Sort configuration: column to sort by and direction (asc/desc). |
247
247
  | `multiples` | [MultiplesConfig](#multiplesconfig) | ✓ | Partition this chart into small multiples by a `rows` column (vertical stack), a `columns` column (side by side), or both (grid). Panels share one measure scale by default. |
248
248
  | `data_table` | [ChartDataTable](#chartdatatable) | ✓ | Optional mini data-grid attached below/above the chart. |
249
- | `height` | int \| float | ✓ | Explicit chart height in pixels. Positive number only. When set, overrides aspect_ratio and theme cascade. Only valid on cartesian chart families: area, bar, heatmap, histogram, line, and scatter. Other chart families use renderer-owned or layout-owned sizing contracts. |
250
- | `width` | int \| float | ✓ | Preferred chart width in pixels. Positive number only. Contributes to the dashboard's intrinsic width without overriding the final layout slot — the chart still fills its allocated container. Only valid on cartesian chart families: area, bar, heatmap, histogram, line, and scatter. Other chart families use renderer-owned or layout-owned sizing contracts. |
249
+ | `height` | int \| float | ✓ | Explicit chart height in pixels. Positive number only. When set, overrides aspect_ratio and theme cascade. Valid on cartesian chart families (area, bar, heatmap, histogram, line, scatter), pie/donut, and geo families (geoshape, map, point_map, bubble_map). Other chart families use renderer-owned or layout-owned sizing contracts. |
250
+ | `width` | int \| float | ✓ | Preferred chart width in pixels. Positive number only. Contributes to the dashboard's intrinsic width without overriding the final layout slot — the chart still fills its allocated container. Valid on cartesian chart families (area, bar, heatmap, histogram, line, scatter), pie/donut, and geo families (geoshape, map, point_map, bubble_map). Other chart families use renderer-owned or layout-owned sizing contracts. |
251
251
  | `style` | [AreaChartStyle](#areachartstyle) | ✓ | Chart-local style overrides. |
252
252
  | `layers` | list[[BarLayer](#barlayer) \| [LineLayer](#linelayer) \| [AreaLayer](#arealayer) \| [ScatterLayer](#scatterlayer)] | ✓ | Typed overlay layers on this chart. |
253
253
 
@@ -275,8 +275,8 @@ Authored patch for scatter charts.
275
275
  | `sort` | [ChartSort](#chartsort) | ✓ | Sort configuration: column to sort by and direction (asc/desc). |
276
276
  | `multiples` | [MultiplesConfig](#multiplesconfig) | ✓ | Partition this chart into small multiples by a `rows` column (vertical stack), a `columns` column (side by side), or both (grid). Panels share one measure scale by default. |
277
277
  | `data_table` | [ChartDataTable](#chartdatatable) | ✓ | Optional mini data-grid attached below/above the chart. |
278
- | `height` | int \| float | ✓ | Explicit chart height in pixels. Positive number only. When set, overrides aspect_ratio and theme cascade. Only valid on cartesian chart families: area, bar, heatmap, histogram, line, and scatter. Other chart families use renderer-owned or layout-owned sizing contracts. |
279
- | `width` | int \| float | ✓ | Preferred chart width in pixels. Positive number only. Contributes to the dashboard's intrinsic width without overriding the final layout slot — the chart still fills its allocated container. Only valid on cartesian chart families: area, bar, heatmap, histogram, line, and scatter. Other chart families use renderer-owned or layout-owned sizing contracts. |
278
+ | `height` | int \| float | ✓ | Explicit chart height in pixels. Positive number only. When set, overrides aspect_ratio and theme cascade. Valid on cartesian chart families (area, bar, heatmap, histogram, line, scatter), pie/donut, and geo families (geoshape, map, point_map, bubble_map). Other chart families use renderer-owned or layout-owned sizing contracts. |
279
+ | `width` | int \| float | ✓ | Preferred chart width in pixels. Positive number only. Contributes to the dashboard's intrinsic width without overriding the final layout slot — the chart still fills its allocated container. Valid on cartesian chart families (area, bar, heatmap, histogram, line, scatter), pie/donut, and geo families (geoshape, map, point_map, bubble_map). Other chart families use renderer-owned or layout-owned sizing contracts. |
280
280
  | `size` | str | ✓ | Column used to size-encode data points (quantitative). |
281
281
  | `shape` | str | ✓ | Column used to shape-encode data points (categorical). |
282
282
  | `style` | [ScatterChartStyle](#scatterchartstyle) | ✓ | Chart-local style overrides. |
@@ -305,8 +305,8 @@ Authored patch for heatmap charts.
305
305
  | `sort` | [ChartSort](#chartsort) | ✓ | Sort configuration: column to sort by and direction (asc/desc). |
306
306
  | `multiples` | [MultiplesConfig](#multiplesconfig) | ✓ | Partition this chart into small multiples by a `rows` column (vertical stack), a `columns` column (side by side), or both (grid). Panels share one measure scale by default. |
307
307
  | `data_table` | [ChartDataTable](#chartdatatable) | ✓ | Optional mini data-grid attached below/above the chart. |
308
- | `height` | int \| float | ✓ | Explicit chart height in pixels. Positive number only. When set, overrides aspect_ratio and theme cascade. Only valid on cartesian chart families: area, bar, heatmap, histogram, line, and scatter. Other chart families use renderer-owned or layout-owned sizing contracts. |
309
- | `width` | int \| float | ✓ | Preferred chart width in pixels. Positive number only. Contributes to the dashboard's intrinsic width without overriding the final layout slot — the chart still fills its allocated container. Only valid on cartesian chart families: area, bar, heatmap, histogram, line, and scatter. Other chart families use renderer-owned or layout-owned sizing contracts. |
308
+ | `height` | int \| float | ✓ | Explicit chart height in pixels. Positive number only. When set, overrides aspect_ratio and theme cascade. Valid on cartesian chart families (area, bar, heatmap, histogram, line, scatter), pie/donut, and geo families (geoshape, map, point_map, bubble_map). Other chart families use renderer-owned or layout-owned sizing contracts. |
309
+ | `width` | int \| float | ✓ | Preferred chart width in pixels. Positive number only. Contributes to the dashboard's intrinsic width without overriding the final layout slot — the chart still fills its allocated container. Valid on cartesian chart families (area, bar, heatmap, histogram, line, scatter), pie/donut, and geo families (geoshape, map, point_map, bubble_map). Other chart families use renderer-owned or layout-owned sizing contracts. |
310
310
  | `style` | [HeatmapChartStyle](#heatmapchartstyle) | ✓ | Chart-local style overrides. |
311
311
 
312
312
  <a id="piechart"></a>
@@ -328,6 +328,8 @@ Authored patch for pie and donut charts.
328
328
  | `subtitle` | str | ✓ | Chart subtitle displayed below the title. |
329
329
  | `color` | str | ✓ | Color data channel: bare column name only. |
330
330
  | `total` | [ChartTotal](#charttotal) | ✓ | Donut center total. |
331
+ | `height` | int \| float | ✓ | Explicit chart height in pixels. Positive number only. |
332
+ | `width` | int \| float | ✓ | Preferred chart width in pixels. Positive number only. Contributes to the dashboard's intrinsic width without overriding the final layout slot — the chart still fills its allocated container. |
331
333
  | `style` | [PieChartStyle](#piechartstyle) | ✓ | Chart-local style overrides. |
332
334
 
333
335
  <a id="kpichart"></a>
@@ -394,6 +396,8 @@ Authored patch for point_map and bubble_map charts.
394
396
  | `geo_source` | str | ✓ | Named geographic data source for loading GeoJSON boundaries. |
395
397
  | `lookup` | str | ✓ | Data column to join against geographic data (map join key). |
396
398
  | `value` | str | ✓ | Data column mapped to the fill color. |
399
+ | `height` | int \| float | ✓ | Explicit chart height in pixels. Positive number only. |
400
+ | `width` | int \| float | ✓ | Preferred chart width in pixels. Positive number only. Contributes to the dashboard's intrinsic width without overriding the final layout slot — the chart still fills its allocated container. |
397
401
  | `latitude` | str | ✓ | Column containing latitude values for point/bubble maps. |
398
402
  | `longitude` | str | ✓ | Column containing longitude values for point/bubble maps. |
399
403
  | `size` | str | ✓ | Data column used to scale bubble radius (quantitative). Only meaningful on bubble_map. |
@@ -422,8 +426,8 @@ Authored patch for map and geoshape charts.
422
426
  | `geo_source` | str | ✓ | Named geographic data source for loading GeoJSON boundaries. |
423
427
  | `lookup` | str | ✓ | Data column to join against geographic data (map join key). |
424
428
  | `value` | str | ✓ | Data column mapped to the fill color. |
425
- | `latitude` | str | ✓ | Column containing latitude values for point/bubble maps. |
426
- | `longitude` | str | ✓ | Column containing longitude values for point/bubble maps. |
429
+ | `height` | int \| float | ✓ | Explicit chart height in pixels. Positive number only. |
430
+ | `width` | int \| float | ✓ | Preferred chart width in pixels. Positive number only. Contributes to the dashboard's intrinsic width without overriding the final layout slot — the chart still fills its allocated container. |
427
431
  | `style` | [GeoshapeChartStyle](#geoshapechartstyle) | ✓ | Chart-local style overrides. |
428
432
 
429
433
  <a id="calloutchart"></a>
@@ -1172,9 +1176,9 @@ Authored overlay for ChartsStyle. Registry of all chart-type styles plus shared
1172
1176
  | `default_table_height` | float | ✓ | Placeholder table height in pixels; replaced by data-aware row-count sizing at render time. |
1173
1177
  | `label_usable_ratio` | float | ✓ | Fraction of chart width usable for axis labels (0–1); labels are tilted when full labels exceed this width. |
1174
1178
  | `axis` | [AxisStyle](#axisstyle) | ✓ | Shared axis style applied to all axes before per-axis overrides. |
1175
- | `axis_x` | [AxisStyle](#axisstyle) | ✓ | X-axis style overrides applied after the shared axis. Unset fields fall back to `style.charts.axis`. |
1176
- | `axis_y` | [AxisStyle](#axisstyle) | ✓ | Y-axis style overrides applied after the shared axis. Unset fields fall back to `style.charts.axis`. |
1177
- | `axis_quantitative` | [AxisStyle](#axisstyle) | ✓ | Quantitative axis style overrides applied after axis_x/axis_y. Unset fields fall back to `style.charts.axis`. |
1179
+ | `axis_x` | [AxisStyle](#axisstyle) | ✓ | X-axis style overrides applied after the shared axis. Unset fields fall back to style.charts.axis. |
1180
+ | `axis_y` | [AxisStyle](#axisstyle) | ✓ | Y-axis style overrides applied after the shared axis. Unset fields fall back to style.charts.axis. |
1181
+ | `axis_quantitative` | [AxisStyle](#axisstyle) | ✓ | Quantitative axis style overrides applied after axis_x/axis_y. Unset fields fall back to style.charts.axis. |
1178
1182
  | `view` | [ViewStyle](#viewstyle) | ✓ | Vega-Lite view dimensions and border. |
1179
1183
  | `autosize` | [AutosizeStyle](#autosizestyle) | ✓ | Vega-Lite autosize configuration. |
1180
1184
  | `marks` | [GlobalMarksStyle](#globalmarksstyle) | ✓ | Global mark defaults (tier-1 of the marks cascade). |
@@ -1391,6 +1395,8 @@ Authored overlay for TooltipStyle. Tooltip box style — all cascade keys for th
1391
1395
  | `value` | [TooltipSlotStyle](#tooltipslotstyle) | ✓ | Value-column font overrides (color, weight). |
1392
1396
  | `border` | [TooltipBorderStyle](#tooltipborderstyle) | ✓ | Tooltip border style; theme always provides this. |
1393
1397
  | `shadow` | [TooltipShadowStyle](#tooltipshadowstyle) | ✓ | Tooltip drop-shadow config; theme always provides this. |
1398
+ | `swatch` | [TooltipSwatchStyle](#tooltipswatchstyle) | ✓ | Series colour swatch size/shape; theme always provides this. |
1399
+ | `active_marker` | enum: "fill", "triangle" | ✓ | How the hovered row is marked in a multi-row (x-unified) tooltip: 'fill' tints the row background (default); 'triangle' draws an edge-flush wedge in the box's left padding instead. Theme always provides this. |
1394
1400
 
1395
1401
  <a id="stylecolorconfig"></a>
1396
1402
  ## StyleColorConfig
@@ -1917,6 +1923,8 @@ Authored overlay for TextCodeStyle. Inline and fenced code spans in markdown pro
1917
1923
  | `font` | [FontStyle](#fontstyle) | ✓ | Box text font overrides — full FontStyle (family, color, size, weight, style, decoration, case). |
1918
1924
  | `background` | str | ✓ | Box background fill. |
1919
1925
  | `border` | [BorderStyle](#borderstyle) | ✓ | Box border (width, color, radius). |
1926
+ | `highlight` | bool | ✓ | Syntax-highlight fenced markdown code blocks. |
1927
+ | `theme` | str | ✓ | Pygments style name for fenced markdown code tokens. |
1920
1928
 
1921
1929
  <a id="textblockquotestyle"></a>
1922
1930
  ## TextBlockquoteStyle
@@ -2159,6 +2167,15 @@ Authored overlay for TooltipShadowStyle. Tooltip drop-shadow toggle. JS applies
2159
2167
  |-------|------|:--------:|-------------|
2160
2168
  | `visible` | bool | ✓ | Show a drop-shadow on the tooltip box; theme always provides this. |
2161
2169
 
2170
+ <a id="tooltipswatchstyle"></a>
2171
+ ## TooltipSwatchStyle
2172
+ Authored overlay for TooltipSwatchStyle. Series colour swatch in the tooltip — the mark-coloured chip next to each
2173
+
2174
+ | Field | Type | Optional | Description |
2175
+ |-------|------|:--------:|-------------|
2176
+ | `size` | float | ✓ | Swatch edge length in pixels (square). |
2177
+ | `radius` | float | ✓ | Swatch corner radius in pixels (0 = square, ~half of size = circle). |
2178
+
2162
2179
  <a id="scaletargetconfig"></a>
2163
2180
  ## ScaleTargetConfig
2164
2181
  Scale configuration for a single style target (background or color).
@@ -2644,7 +2661,7 @@ Authored overlay for BarLabelsStyle. Bar mark value-label config. Extends MarkLa
2644
2661
  |-------|------|:--------:|-------------|
2645
2662
  | `visible` | bool | ✓ | Show numeric value labels on each mark; False by default. |
2646
2663
  | `field` | str | ✓ | Column to source label text from; None uses the chart's y-field. |
2647
- | `format` | str | ✓ | Number format string for value labels. Falls back to [`style.charts.axis_quantitative.format`](#axisstyle). |
2664
+ | `format` | str | ✓ | Number format string for value labels. |
2648
2665
  | `dx` | int | ✓ | Horizontal pixel offset for value labels; overrides the position default. |
2649
2666
  | `dy` | int | ✓ | Vertical pixel offset for value labels; overrides the position default. |
2650
2667
  | `font` | [FontStyle](#fontstyle) | ✓ | Value label font style (color, size, family, etc.). |
@@ -2670,7 +2687,7 @@ Authored overlay for PointLabelsStyle. Point/line mark value-label config. Used
2670
2687
  |-------|------|:--------:|-------------|
2671
2688
  | `visible` | bool | ✓ | Show numeric value labels on each mark; False by default. |
2672
2689
  | `field` | str | ✓ | Column to source label text from; None uses the chart's y-field. |
2673
- | `format` | str | ✓ | Number format string for value labels. Falls back to [`style.charts.axis_quantitative.format`](#axisstyle). |
2690
+ | `format` | str | ✓ | Number format string for value labels. |
2674
2691
  | `dx` | int | ✓ | Horizontal pixel offset for value labels; overrides the position default. |
2675
2692
  | `dy` | int | ✓ | Vertical pixel offset for value labels; overrides the position default. |
2676
2693
  | `font` | [FontStyle](#fontstyle) | ✓ | Value label font style (color, size, family, etc.). |
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  from pydantic import BaseModel, ConfigDict, Field
16
16
 
17
+ from dataface.agent_api._link_scan import LinkHit, face_slug, rewrite_links
17
18
  from dataface.core.errors.execution import ExecutionError
18
19
  from dataface.core.project import FACES_SUBDIR, Project
19
20
 
@@ -63,6 +64,32 @@ class EditFileArgs(BaseModel):
63
64
  new_string: str = Field(description="Replacement text.")
64
65
 
65
66
 
67
+ class MoveFileArgs(BaseModel):
68
+ """Move (rename) a project file. Fails if the destination already exists
69
+ or the source does not. Both paths are relative to the project root and
70
+ may not escape it.
71
+
72
+ When both paths are dashboard faces (under faces/), exact-match ``link:``
73
+ chart fields and markdown links pointing at the old slug are rewritten to
74
+ the new one in the same operation; any other reference to the old slug's
75
+ basename is reported as a fuzzy hit, never rewritten.
76
+ """
77
+
78
+ source_path: str = Field(
79
+ description="Current file path relative to the project root."
80
+ )
81
+ destination_path: str = Field(
82
+ description="New file path relative to the project root."
83
+ )
84
+
85
+
86
+ class DeleteFileArgs(BaseModel):
87
+ """Delete a project file. Fails if it does not exist. Path is relative to
88
+ the project root and may not escape it."""
89
+
90
+ path: str = Field(description="File path relative to the project root.")
91
+
92
+
66
93
  class GlobFilesArgs(BaseModel):
67
94
  """List files matching a glob pattern (e.g. 'faces/*.yml').
68
95
 
@@ -126,6 +153,25 @@ class EditFileResult(BaseModel):
126
153
  error: str | None = None
127
154
 
128
155
 
156
+ class MoveFileResult(BaseModel):
157
+ model_config = ConfigDict(frozen=True)
158
+
159
+ success: bool
160
+ source_path: str
161
+ destination_path: str
162
+ links_rewritten: int = 0
163
+ fuzzy_links: list[LinkHit] = Field(default_factory=list)
164
+ error: str | None = None
165
+
166
+
167
+ class DeleteFileResult(BaseModel):
168
+ model_config = ConfigDict(frozen=True)
169
+
170
+ success: bool
171
+ path: str
172
+ error: str | None = None
173
+
174
+
129
175
  class GlobResult(BaseModel):
130
176
  model_config = ConfigDict(frozen=True)
131
177
 
@@ -199,6 +245,88 @@ def edit_file(
199
245
  return EditFileResult(success=True, path=path, replacements=1)
200
246
 
201
247
 
248
+ def move_file(
249
+ source_path: str, destination_path: str, project: Project
250
+ ) -> MoveFileResult:
251
+ """Move *source_path* to *destination_path*, rewriting exact inbound
252
+ dashboard links along the way (see ``MoveFileArgs``). LOCAL semantics: no
253
+ exceptions escape, everything is reported in the typed result."""
254
+ try:
255
+ content = project.read_text(source_path)
256
+ except FileNotFoundError:
257
+ return MoveFileResult(
258
+ success=False,
259
+ source_path=source_path,
260
+ destination_path=destination_path,
261
+ error=f"file not found: {source_path}",
262
+ )
263
+ except (ValueError, IsADirectoryError, *_UNREADABLE_FILE) as exc:
264
+ return MoveFileResult(
265
+ success=False,
266
+ source_path=source_path,
267
+ destination_path=destination_path,
268
+ error=str(exc),
269
+ )
270
+ try:
271
+ destination_exists = project.exists(destination_path)
272
+ except ValueError as exc:
273
+ return MoveFileResult(
274
+ success=False,
275
+ source_path=source_path,
276
+ destination_path=destination_path,
277
+ error=str(exc),
278
+ )
279
+ if destination_exists:
280
+ return MoveFileResult(
281
+ success=False,
282
+ source_path=source_path,
283
+ destination_path=destination_path,
284
+ error=f"destination already exists: {destination_path}",
285
+ )
286
+
287
+ old_slug, new_slug = face_slug(source_path), face_slug(destination_path)
288
+ scan = rewrite_links(project, old_slug, new_slug) if old_slug and new_slug else None
289
+
290
+ try:
291
+ project.write_text(destination_path, content)
292
+ except (ValueError, OSError) as exc:
293
+ return MoveFileResult(
294
+ success=False,
295
+ source_path=source_path,
296
+ destination_path=destination_path,
297
+ error=str(exc),
298
+ )
299
+ try:
300
+ project.delete_text(source_path)
301
+ except (ValueError, OSError, FileNotFoundError) as exc:
302
+ return MoveFileResult(
303
+ success=False,
304
+ source_path=source_path,
305
+ destination_path=destination_path,
306
+ error=f"wrote {destination_path!r} but failed to delete {source_path!r}: {exc}",
307
+ )
308
+ return MoveFileResult(
309
+ success=True,
310
+ source_path=source_path,
311
+ destination_path=destination_path,
312
+ links_rewritten=len(scan.exact) if scan else 0,
313
+ fuzzy_links=scan.fuzzy if scan else [],
314
+ )
315
+
316
+
317
+ def delete_file(path: str, project: Project) -> DeleteFileResult:
318
+ """Delete *path*. LOCAL semantics: no exceptions escape."""
319
+ try:
320
+ project.delete_text(path)
321
+ except FileNotFoundError:
322
+ return DeleteFileResult(
323
+ success=False, path=path, error=f"file not found: {path}"
324
+ )
325
+ except (ValueError, OSError) as exc:
326
+ return DeleteFileResult(success=False, path=path, error=str(exc))
327
+ return DeleteFileResult(success=True, path=path)
328
+
329
+
202
330
  def glob_files(pattern: str | None, project: Project) -> GlobResult:
203
331
  """List files matching *pattern*; ``None`` (the documented tool default)
204
332
  lists the faces subtree."""
@@ -432,6 +432,7 @@ class ProjectSession:
432
432
  path: Path | None = None,
433
433
  *,
434
434
  yaml_content: str | None = None,
435
+ chart: str | None = None,
435
436
  variables: dict[str, Any] | None = None,
436
437
  format: RenderFormat = "json",
437
438
  use_cache: bool = True,
@@ -453,6 +454,7 @@ class ProjectSession:
453
454
  return _core_dashboard.render_dashboard(
454
455
  path=path,
455
456
  yaml_content=yaml_content,
457
+ chart=chart,
456
458
  variables=variables,
457
459
  adapter_registry=self.adapter_registry,
458
460
  project=self.project,
@@ -63,6 +63,10 @@ skill_design_report:
63
63
  tool: "get_skill('report-design')"
64
64
  dft: "dft skills report-design"
65
65
 
66
+ skill_replicate_dashboard:
67
+ tool: "get_skill('dashboard-replicate')"
68
+ dft: "dft skills dashboard-replicate"
69
+
66
70
  skill_review:
67
71
  tool: "get_skill('dashboard-review')"
68
72
  dft: "dft skills dashboard-review"
dataface/ai/agent.py CHANGED
@@ -25,7 +25,7 @@ from dataface.ai.llm import LLMClient, LLMClientError
25
25
  from dataface.ai.memories import format_memories_prompt, load_memories
26
26
  from dataface.ai.prompts import load_shared_prompt
27
27
  from dataface.ai.tool_schemas import CHAT_TOOLS
28
- from dataface.ai.tools import dispatch_tool_call
28
+ from dataface.ai.tools import ToolHandler, dispatch_tool_call
29
29
 
30
30
  if TYPE_CHECKING:
31
31
  from dataface.core.execute.adapters import AdapterRegistry
@@ -167,6 +167,12 @@ class AgentProfile:
167
167
  name: str
168
168
  tools: list[dict[str, Any]]
169
169
  build_system_prompt: Callable[[DatafaceAIContext], str]
170
+ # Per-profile override for tool names the shared registry already handles
171
+ # (e.g. Cloud routing move_file/delete_file to a governed service instead
172
+ # of the generic local file-tool handler) — consulted before TOOL_HANDLERS
173
+ # in dispatch_tool_call — one tool name and contract across hosts, two
174
+ # backends. None for profiles with no host-specific behavior.
175
+ tool_overrides: dict[str, ToolHandler] | None = None
170
176
 
171
177
 
172
178
  # The default profile: the dashboard analyst that `dft chat` and the MCP/cloud
@@ -178,6 +184,24 @@ DASHBOARD_PROFILE = AgentProfile(
178
184
  )
179
185
 
180
186
 
187
+ def _model_facing_result(result: dict[str, Any]) -> dict[str, Any]:
188
+ """Return the copy of a tool result the model reads.
189
+
190
+ SVG output is a display artifact for the host (Cloud embeds it; the CLI
191
+ never requests it) — feeding ~100KB of markup to the model wastes context
192
+ and invites it to echo the blob as prose. The model keeps the verdict
193
+ (success, warnings, errors); the host-facing ToolResultEvent keeps the
194
+ full payload.
195
+ """
196
+ data = result.get("data")
197
+ if isinstance(data, str) and data.lstrip().startswith("<svg"):
198
+ return {
199
+ **result,
200
+ "data": f"[svg omitted ({len(data)} chars) — rendered for the user]",
201
+ }
202
+ return result
203
+
204
+
181
205
  def run_agent(
182
206
  prompt: str | list[dict[str, Any]],
183
207
  *,
@@ -188,6 +212,7 @@ def run_agent(
188
212
  profile: AgentProfile | None = None,
189
213
  tools: list[dict[str, Any]] | None = None,
190
214
  external_manager: ExternalMCPManager | None = None,
215
+ extra_handlers: dict[str, ToolHandler] | None = None,
191
216
  ) -> Generator[AgentEvent, None, None]:
192
217
  """Run the agent loop and stream typed events.
193
218
 
@@ -199,6 +224,8 @@ def run_agent(
199
224
  tools. Used by chat sessions; defaults to the profile's tools.
200
225
  external_manager: ExternalMCPManager instance for routing 'server__tool' calls.
201
226
  Must be provided when tools contains external (namespaced) entries.
227
+ extra_handlers: Per-call handlers for tools with no meaning to dft-core
228
+ (e.g. Cloud's placement tool) — threaded straight to dispatch_tool_call.
202
229
  """
203
230
  active = profile if profile is not None else DASHBOARD_PROFILE
204
231
  effective_tools = tools if tools is not None else active.tools
@@ -246,13 +273,15 @@ def run_agent(
246
273
  tc.arguments,
247
274
  context=context,
248
275
  external_manager=external_manager,
276
+ extra_handlers=extra_handlers,
277
+ tool_overrides=active.tool_overrides,
249
278
  )
250
279
  conversation.append(
251
280
  {
252
281
  "role": "tool",
253
282
  "tool_call_id": tc.id,
254
283
  "name": tc.name,
255
- "content": json.dumps(result, default=str),
284
+ "content": json.dumps(_model_facing_result(result), default=str),
256
285
  }
257
286
  )
258
287
  yield ToolResultEvent(id=tc.id, name=tc.name, result=result)
@@ -31,6 +31,9 @@ whenever one fits — and say which you reused.{{/if_tool}}
31
31
  - **one chart** — a single trend, ranking, or breakdown
32
32
  - **a dashboard** — several related charts that explore a topic together
33
33
  - **a report** — narrative plus charts that walk through an analysis
34
+ - **a replication** — the user supplied an existing dashboard (screenshot,
35
+ image, export) to reproduce → `{{ s_skill_replicate_dashboard }}`;
36
+ fidelity to the source, not a redesign
34
37
  2. **Clarify only what blocks you.** If the metric, grain, time window, or filters
35
38
  are ambiguous *and* the choice changes the answer, ask one focused question.
36
39
  Otherwise state the assumption and proceed.
@@ -40,6 +40,7 @@ Build dashboards and reports **incrementally** — one chart at a time, validati
40
40
  ## Companion Skills
41
41
 
42
42
  - **`{{ s_skill_design_dashboard }}`** — chart-type, layout, and color decisions
43
+ - **`{{ s_skill_replicate_dashboard }}`** — reproducing an existing dashboard from a screenshot or export
43
44
  - **`{{ s_skill_design_report }}`** — narrative reports (text + charts)
44
45
  - **`{{ s_skill_review }}`** — self-review checklist run at delivery
45
46
  - **`{{ s_skill_troubleshooting }}`** — when something breaks
@@ -268,7 +269,7 @@ Named presets (prefer these over raw D3 strings):
268
269
  | Writing SQL without checking column names | Check INFORMATION_SCHEMA via `{{ s_execute_query }}` first |
269
270
  | Hardcoding values during exploration | Use `{{ variables }}` from the start so the query cache survives |
270
271
  | Skipping validation between changes | `{{ s_validate_dashboard }}` after every YAML edit |
271
- | Too many charts (>8) | Split into multiple dashboards |
272
+ | Too many charts (>8) the user didn't ask for | Split into multiple dashboards |
272
273
  | Using chart-type names as keys in layout | `table:` or `bar:` as keys cause parse errors — use descriptive names like `revenue_table` |
273
274
  | Referencing query names in layout | Layout references charts, not queries — create a chart that wraps the query |
274
275
  | KPI query returns multiple rows | Use `SUM()`/`COUNT()`/`AVG()` to aggregate to 1 row |
@@ -289,13 +290,13 @@ Named presets (prefer these over raw D3 strings):
289
290
  | "I know what the columns are called" | You don't. Check INFORMATION_SCHEMA with `{{ s_execute_query }}`. |
290
291
  | "Validation is slow, I'll do it at the end" | Validation is instant. Skipping it costs more time debugging later. |
291
292
  | "It's just a quick chart, no need for variables" | Variables cost nothing and enable caching. Use them. |
292
- | "User wants 15 charts on one dashboard" | 8 max. Propose splitting into focused dashboards. |
293
+ | "15 charts is too many, I'll trim their list" | An explicit user ask wins. Suggest a split once, then build all 15. The 8-max default applies when the design is yours to choose. |
293
294
 
294
295
  ## Red Flags — STOP
295
296
 
296
297
  - About to write SQL without having explored the actual schema first
297
298
  - Building more than one new chart before validating the previous one
298
- - Dashboard has more than 8 visualizations
299
+ - Dashboard has more than 8 visualizations the user didn't ask for
299
300
  - A chart exists without a clear question it answers
300
301
  - Using hardcoded values that should be variables
301
302
  - Delivering without running the self-review checklist