diff --git a/AGENTS.md b/AGENTS.md
index 17016b2..9be33f2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -27,12 +27,16 @@ src/FlexRender.Core/ # Core library (0 external dependencies)
Units/ # Unit, UnitParser, PaddingValues, PaddingParser, MarginValue, MarginValues
Loaders/ # FileResourceLoader, Base64ResourceLoader, EmbeddedResourceLoader
Parsing/Ast/ # Template, CanvasSettings, TemplateElement, TextElement, FlexElement, TableElement, TableColumn, TableRow, etc.
+ Parsing/Nodes/ # Format-neutral node model (TemplateMapping, TemplateSequence, TemplateScalar, TemplateNode)
+ Parsing/Engine/ # Shared parsing engine (TemplateEngine, ElementParsers, ChartParsers, ShapeParsers, KnownProperties, NodePropertyHelpers)
TemplateEngine/ # TemplateProcessor, ExpressionLexer, ExpressionEvaluator, InlineExpressionParser, InlineExpressionEvaluator, FilterRegistry
Filters/ # ITemplateFilter, CurrencyFilter, NumberFilter, UpperFilter, LowerFilter, TrimFilter, TruncateFilter, FormatFilter
Values/ # TemplateValue hierarchy (StringValue, NumberValue, etc.)
-src/FlexRender.Yaml/ # YAML template parser (-> Core + YamlDotNet)
- Parsing/ # TemplateParser
+src/FlexRender.Yaml/ # YAML facade: YamlDotNet -> neutral nodes -> Core engine (-> Core + YamlDotNet)
+ Parsing/ # TemplateParser (facade), YamlNodeConverter
+src/FlexRender.Xml/ # XML facade: XDocument -> neutral nodes -> Core engine (-> Core only); RenderXml extension
+ Parsing/ # XmlTemplateParser, XmlNodeConverter
src/FlexRender.Http/ # HTTP resource loader (-> Core)
src/FlexRender.Skia.Render/ # SkiaSharp renderer (-> Core + SkiaSharp)
Abstractions/ # ISkiaRenderer, IFontLoader, IImageLoader, IFontManager
@@ -89,6 +93,8 @@ FlexRender.Yaml FlexRender.Http FlexRender.Skia.Render FlexRender.ImageSharp.
FlexRender.MetaPackage (references all)
```
+Note: `FlexRender.Xml` depends only on `FlexRender.Core` -- the shared parsing engine (`TemplateEngine` + element/chart/shape parsers) lives in Core and operates on the format-neutral node model. `FlexRender.Yaml` = Core + YamlDotNet; `FlexRender.Xml` = Core only. Neither format package depends on the other.
+
## SkiaSharp Native Assets on Linux
SkiaSharp is a managed C# binding over the native Skia engine. On Linux (including CI runners and Docker), the native `libSkiaSharp.so` library is not bundled automatically and must be provided via a separate NuGet package.
@@ -249,16 +255,16 @@ All new features and non-trivial changes must be developed in separate branches.
- **Do NOT merge into `main`** -- leave the feature branch as-is after completing work. Merging is done manually by the maintainer or via GitHub PR
- **Do NOT use git worktrees** -- work directly in the repository checkout. Worktrees add unnecessary complexity and cause issues with stash conflicts and asset path resolution
-### Git LFS & Image URLs
+### Image URLs
-All binary assets (PNG images, fonts) are stored in Git LFS. When referencing images in README or documentation:
+Binary assets (PNG images, fonts) are stored **directly in Git** (Git LFS was removed). When referencing images in README or documentation:
-- **Use `media.githubusercontent.com`** for LFS-tracked files:
+- **Use `raw.githubusercontent.com`**:
```
- https://media.githubusercontent.com/media/RoboNET/FlexRender/main/examples/output/receipt.png
+ https://raw.githubusercontent.com/RoboNET/FlexRender/main/examples/output/receipt.png
```
-- **Do NOT use `raw.githubusercontent.com`** -- it returns the LFS pointer file (text), not the actual image content
-- LFS-tracked paths: `examples/output/*.png`, `examples/assets/fonts/*.ttf`, `examples/assets/placeholder/*.png`, `examples/visual-docs/output/*.png`
+- **Do NOT use `media.githubusercontent.com/media/...`** -- that is the LFS delivery path and now returns 404, since the files are no longer LFS-tracked
+- Asset paths: `examples/output/*.png`, `examples/assets/fonts/*.ttf`, `examples/assets/placeholder/*.png`, `examples/visual-docs/output/*.png`
### Commits
@@ -464,8 +470,8 @@ When debugging or testing the WASM playground (`src/FlexRender.Playground/`), us
### Add new element type
1. Create AST model in `Parsing/Ast/` (sealed class extending `TemplateElement`)
-2. Add parser function in `TemplateParser.cs` -- register in `_elementParsers` dictionary
-3. Register all YAML properties in `KnownProperties.cs` (for YAML validation and typo suggestions)
+2. Add parser function in `Parsing/Engine/ElementParsers.cs` (Core) -- register in `TemplateEngine._elementParsers` dictionary
+3. Register all properties in `Parsing/Engine/KnownProperties.cs` (Core; for validation and typo suggestions)
4. Add flex-item property support via `switch` pattern matching in layout engine
5. Add rendering in `SkiaRenderer.RenderNode()` or create a provider
6. Write tests for each step
diff --git a/FlexRender.slnx b/FlexRender.slnx
index 75fba12..7734293 100644
--- a/FlexRender.slnx
+++ b/FlexRender.slnx
@@ -16,6 +16,7 @@
+
diff --git a/README.md b/README.md
index 7850e41..641dd19 100644
--- a/README.md
+++ b/README.md
@@ -32,16 +32,16 @@ A .NET library for rendering images from YAML templates with a full CSS flexbox
| Receipt | Dynamic Receipt | Ticket | Label |
|---------|-----------------|--------|-------|
-|  |  |  |  |
+|  |  |  |  |
| Table Invoice | Expressions | Visual Effects |
|---------------|-------------|----------------|
-|  |  |  |
+|  |  |  |
All Features Showcase (click to expand)
-
+
@@ -50,7 +50,7 @@ A .NET library for rendering images from YAML templates with a full CSS flexbox
| Skia | ImageSharp | SVG |
|------|------------|-----|
-|  |  | [SVG output](examples/output/showcase-capabilities.svg) |
+|  |  | [SVG output](examples/output/showcase-capabilities.svg) |
| Native rendering, gradients, shadows, SVG elements | Pure .NET, zero native deps | Vector output, scalable |
diff --git a/docs/superpowers/plans/2026-03-10-content-options-autocomplete.md b/docs/superpowers/plans/2026-03-10-content-options-autocomplete.md
new file mode 100644
index 0000000..a921e46
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-10-content-options-autocomplete.md
@@ -0,0 +1,514 @@
+# Playground Improvements Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Three Playground improvements: (1) context-aware autocomplete for content `options:` block, (2) enable QR code rendering, (3) surface render errors for missing images/resources.
+
+**Architecture:** Extend JSON schema with NDC option definitions and wire autocomplete to use them. Add QR module reference and `.WithQr()` call. After render, check `GetLastError()` and display the message in the errors pane.
+
+**Tech Stack:** JSON Schema, JavaScript (ES modules), Monaco Editor API, C# (.NET 10)
+
+---
+
+## File Structure
+
+- **Modify:** `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json` — add `ndcOptions` and `charsetStyle` definitions
+- **Modify:** `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs` — extend context detection and suggestions for options/charsets
+- **Modify:** `src/FlexRender.Playground/FlexRender.Playground.csproj` — add QR module reference
+- **Modify:** `src/FlexRender.Playground/PlaygroundApi.cs` — add `.WithQr()` to builder
+- **Modify:** `src/FlexRender.Playground/wwwroot/main.js` — check `GetLastError()` after empty render result
+
+---
+
+## Chunk 1: Schema Definitions
+
+### Task 1: Add ndcOptions and charsetStyle definitions to JSON schema
+
+**Files:**
+- Modify: `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json`
+
+- [ ] **Step 1: Add `charsetStyle` definition**
+
+Add before the closing `}` of `"definitions"` (before line 900):
+
+```json
+"charsetStyle": {
+ "description": "Per-charset style configuration for NDC content parser.",
+ "type": "object",
+ "properties": {
+ "font": {
+ "description": "Font registration name (e.g., \"bold\", \"default\").",
+ "type": "string"
+ },
+ "font_family": {
+ "description": "Explicit font family for this charset.",
+ "type": "string"
+ },
+ "font_style": {
+ "description": "Font style.",
+ "type": "string",
+ "enum": ["bold", "italic", "bold-italic"]
+ },
+ "font_size": {
+ "description": "Explicit font size in pixels.",
+ "type": "integer",
+ "minimum": 1
+ },
+ "color": {
+ "description": "Text color (hex format, e.g., \"#333\").",
+ "type": "string"
+ },
+ "encoding": {
+ "description": "Character encoding (e.g., \"none\", \"qwerty-jcuken\").",
+ "type": "string"
+ },
+ "uppercase": {
+ "description": "Convert text to uppercase.",
+ "type": "boolean",
+ "default": false
+ }
+ },
+ "additionalProperties": false
+}
+```
+
+- [ ] **Step 2: Add `ndcOptions` definition**
+
+Add after `charsetStyle`:
+
+```json
+"ndcOptions": {
+ "description": "Options for the NDC content parser.",
+ "type": "object",
+ "properties": {
+ "input_encoding": {
+ "description": "Byte encoding for binary data.",
+ "type": "string",
+ "enum": ["latin1", "iso-8859-1", "utf-8", "utf8", "ascii"],
+ "default": "latin1"
+ },
+ "columns": {
+ "description": "Max characters per line (receipt width).",
+ "type": "integer",
+ "minimum": 1,
+ "default": 40
+ },
+ "font_family": {
+ "description": "Font family for all text (e.g., \"JetBrains Mono\").",
+ "type": "string"
+ },
+ "char_width_ratio": {
+ "description": "Character width as fraction of font size for monospace fonts.",
+ "type": "number",
+ "minimum": 0.1,
+ "default": 0.6
+ },
+ "charsets": {
+ "description": "Per-charset style mappings keyed by designator character.",
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/charsetStyle"
+ }
+ }
+ },
+ "additionalProperties": false
+}
+```
+
+- [ ] **Step 3: Update `contentElement.properties.options` to reference `ndcOptions`**
+
+Replace the current `options` property in `contentElement` (line 894-897):
+
+```json
+"options": {
+ "description": "Format-specific rendering options. Properties depend on the chosen format.",
+ "oneOf": [
+ { "$ref": "#/definitions/ndcOptions" },
+ { "type": "object", "description": "No options for this format.", "properties": {}, "additionalProperties": false }
+ ]
+}
+```
+
+- [ ] **Step 4: Verify schema is valid JSON**
+
+Run: `python3 -c "import json; json.load(open('src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json'))"`
+Expected: no output (valid JSON)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json
+git commit -m "feat: add ndcOptions and charsetStyle definitions to JSON schema"
+```
+
+---
+
+## Chunk 2: Autocomplete Context Detection
+
+### Task 2: Extend detectContext to recognize options and charset contexts
+
+**Files:**
+- Modify: `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs`
+
+The autocomplete must handle these YAML structures:
+
+```yaml
+- type: content
+ format: ndc
+ options: # <-- context: content-options, format: ndc
+ input_encoding: latin1
+ columns: 44
+ charsets: # <-- still content-options (charsets is a key here)
+ I: # <-- charset designator key, not suggested
+ font_style: # <-- context: content-charset-item
+ color: "#333"
+```
+
+- [ ] **Step 1: Extend `detectContext` to return `content-options` context**
+
+Add detection for `options:` inside a content element. When walking backwards, if we encounter `options:` at a parent indent, continue walking to find `type: content` and `format:` value. Return `{ type: 'content-options', format: 'ndc' }`.
+
+In `detectContext`, add this check after the `if (trimmed === 'canvas:')` block (around line 150):
+
+```javascript
+if (trimmed === 'options:') {
+ // Walk further back to find the parent content element and its format
+ const parentInfo = findContentParent(lines, i, lineIndent);
+ if (parentInfo) {
+ return { type: 'content-options', format: parentInfo.format };
+ }
+}
+```
+
+- [ ] **Step 2: Detect `content-charset-item` context**
+
+When inside a charset entry (two indentation levels below `charsets:`), detect that we're editing charset style properties. Add to `detectContext` right after the `options:` check:
+
+```javascript
+// Check if we're inside a charsets > designator block
+if (lineIndent < currentIndent) {
+ // Look for pattern: grandparent is "charsets:", parent is a designator key
+ const isDesignatorKey = trimmed.match(/^\w+:$/);
+ if (isDesignatorKey) {
+ // Check if grandparent is "charsets:" inside content options
+ for (let k = i - 1; k >= 0; k--) {
+ const prev = lines[k];
+ const prevTrimmed = prev.trim();
+ if (!prevTrimmed || prevTrimmed.startsWith('#')) continue;
+ const prevIndent = prev.match(/^(\s*)/)[1].length;
+ if (prevIndent < lineIndent) {
+ if (prevTrimmed === 'charsets:') {
+ return { type: 'content-charset-item' };
+ }
+ break;
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 3: Add `findContentParent` helper function**
+
+Add after `detectContext`:
+
+```javascript
+function findContentParent(lines, fromIndex, optionsIndent) {
+ let format = null;
+ for (let i = fromIndex - 1; i >= 0; i--) {
+ const line = lines[i];
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith('#')) continue;
+ const indent = line.match(/^(\s*)/)[1].length;
+ if (indent >= optionsIndent) {
+ // Sibling or child of options — check for format:
+ const formatMatch = trimmed.match(/^format:\s*(\w+)/);
+ if (formatMatch) format = formatMatch[1];
+ continue;
+ }
+ // Parent level — should be the element with type: content
+ const typeMatch = trimmed.match(/^-?\s*type:\s*(\w+)/);
+ if (typeMatch) {
+ if (typeMatch[1] === 'content') return { format };
+ return null; // Not a content element
+ }
+ // If we hit a sibling property at element level, keep scanning for type
+ if (indent < optionsIndent) {
+ const formatMatch = trimmed.match(/^-?\s*format:\s*(\w+)/);
+ if (formatMatch) format = formatMatch[1];
+ }
+ if (indent === 0) break; // Reached root
+ }
+ return null;
+}
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs
+git commit -m "feat: detect content-options and charset-item contexts in autocomplete"
+```
+
+---
+
+## Chunk 3: Wire Suggestions to Schema Definitions
+
+### Task 3: Suggest properties and values for content options
+
+**Files:**
+- Modify: `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs`
+
+- [ ] **Step 1: Handle `content-options` and `content-charset-item` in the completion provider switch**
+
+In the `provideCompletionItems` switch block (after `case 'template':`, around line 129), add:
+
+```javascript
+case 'content-options': {
+ const formatDef = context.format
+ ? defs[context.format + 'Options']
+ : null;
+ const props = formatDef?.properties || {};
+ return makeSuggestions(monaco, props, range, 'content-options');
+}
+case 'content-charset-item': {
+ const props = defs.charsetStyle?.properties || {};
+ return makeSuggestions(monaco, props, range, 'content-charset-item');
+}
+```
+
+- [ ] **Step 2: Update `findPropertyDef` to resolve options properties**
+
+In `findPropertyDef` (around line 295), add context detection for options and charset blocks. Before the flex item fallback (line 316), add:
+
+```javascript
+// Check if inside content options or charset item
+const optionsContext = detectContentOptionsContext(lines);
+if (optionsContext === 'charset-item') {
+ const charsetDef = defs.charsetStyle;
+ if (charsetDef?.properties?.[cleanKey]) return charsetDef.properties[cleanKey];
+} else if (optionsContext) {
+ // optionsContext is the format name
+ const optDef = defs[optionsContext + 'Options'];
+ if (optDef?.properties?.[cleanKey]) return optDef.properties[cleanKey];
+}
+```
+
+- [ ] **Step 3: Add `detectContentOptionsContext` helper**
+
+Add after `findPropertyDef`:
+
+```javascript
+function detectContentOptionsContext(lines) {
+ let inOptions = false;
+ let optionsIndent = -1;
+ let charsetsIndent = -1;
+
+ for (let i = lines.length - 2; i >= 0; i--) {
+ const line = lines[i];
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith('#')) continue;
+ const indent = line.match(/^(\s*)/)[1].length;
+
+ // Check if we're inside a charset designator block
+ if (charsetsIndent < 0 && trimmed.match(/^\w+:$/) && !inOptions) {
+ // Possible designator — check parent
+ for (let k = i - 1; k >= 0; k--) {
+ const prev = lines[k];
+ const prevTrimmed = prev.trim();
+ if (!prevTrimmed || prevTrimmed.startsWith('#')) continue;
+ const prevIndent = prev.match(/^(\s*)/)[1].length;
+ if (prevIndent < indent && prevTrimmed === 'charsets:') {
+ return 'charset-item';
+ }
+ if (prevIndent < indent) break;
+ }
+ }
+
+ if (trimmed === 'options:') {
+ // Find format from sibling properties
+ const parentInfo = findContentParent(lines, i, indent);
+ return parentInfo?.format || null;
+ }
+
+ if (indent === 0) break;
+ }
+ return null;
+}
+```
+
+- [ ] **Step 4: Update `suggestValues` to resolve enum values for options properties**
+
+The existing logic in `suggestValues` already calls `findPropertyDef` which will now return the correct property definition with enum values (e.g., `input_encoding` enum, `font_style` enum, `uppercase` boolean). No changes needed — just verify it works.
+
+- [ ] **Step 5: Add sort order for content-options context**
+
+In `getSortOrder` (line 363), add entries for the new contexts:
+
+```javascript
+'content-options': { input_encoding: '0', columns: '1', font_family: '2', char_width_ratio: '3', charsets: '4' },
+'content-charset-item': { font: '0', font_family: '1', font_style: '2', font_size: '3', color: '4', encoding: '5', uppercase: '6' },
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs
+git commit -m "feat: wire content options and charset suggestions to schema definitions"
+```
+
+---
+
+## Chunk 4: Manual Verification
+
+### Task 4: Verify in browser
+
+- [ ] **Step 1: Start dev server and test**
+
+Run: `dotnet run --project src/FlexRender.Playground`
+
+Open browser, create a template with:
+
+```yaml
+layout:
+ - type: content
+ source: "test.ndc"
+ format: ndc
+ options:
+ | <-- trigger autocomplete here, expect: input_encoding, columns, font_family, char_width_ratio, charsets
+```
+
+Verify:
+1. Inside `options:` with `format: ndc` → suggests NDC options
+2. After `input_encoding:` → suggests `latin1`, `iso-8859-1`, `utf-8`, `utf8`, `ascii`
+3. Inside `charsets: > X:` → suggests charset style properties (`font`, `font_style`, etc.)
+4. After `font_style:` → suggests `bold`, `italic`, `bold-italic`
+5. After `uppercase:` → suggests `true`, `false`
+6. Hovering over option keys shows descriptions
+7. With `format: markdown` → `options:` suggests nothing (empty properties)
+8. Without `format:` → `options:` suggests nothing
+
+- [ ] **Step 2: Verify publish output**
+
+Run: `dotnet publish src/FlexRender.Playground -c Release -o /tmp/playground-verify`
+Expected: builds without errors, schema included in output
+
+- [ ] **Step 3: Final commit if any fixes needed**
+
+---
+
+## Chunk 5: Enable QR Code Rendering
+
+### Task 5: Add QR module to Playground
+
+**Files:**
+- Modify: `src/FlexRender.Playground/FlexRender.Playground.csproj`
+- Modify: `src/FlexRender.Playground/PlaygroundApi.cs`
+
+- [ ] **Step 1: Add QR project reference to Playground csproj**
+
+In `FlexRender.Playground.csproj`, add to the `` with other ProjectReferences:
+
+```xml
+
+```
+
+Note: Reference `FlexRender.QrCode.Skia.Render` directly (not the meta-package `FlexRender.QrCode` which also pulls in ImageSharp and Svg renderers that aren't needed in WASM).
+
+- [ ] **Step 2: Add `using` and `.WithQr()` call in PlaygroundApi.cs**
+
+Add using directive at top of `PlaygroundApi.cs`:
+
+```csharp
+using FlexRender.QrCode;
+```
+
+Change the builder initialization (line 43-45) from:
+
+```csharp
+var builder = new FlexRenderBuilder()
+ .WithNdc()
+ .WithSkia();
+```
+
+To:
+
+```csharp
+var builder = new FlexRenderBuilder()
+ .WithNdc()
+ .WithSkia(skia => skia.WithQr());
+```
+
+- [ ] **Step 3: Verify it builds**
+
+Run: `dotnet build src/FlexRender.Playground/FlexRender.Playground.csproj`
+Expected: Build succeeded
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/FlexRender.Playground/FlexRender.Playground.csproj src/FlexRender.Playground/PlaygroundApi.cs
+git commit -m "feat: enable QR code rendering in Playground"
+```
+
+---
+
+## Chunk 6: Surface Render Errors for Missing Images
+
+### Task 6: Show GetLastError() in the errors pane when render returns empty
+
+**Files:**
+- Modify: `src/FlexRender.Playground/wwwroot/main.js`
+
+Currently when `RenderToPng` catches an exception, it stores the error in `_lastError` and returns an empty byte array. The JS side shows "Render returned empty — check console" but never calls `GetLastError()`.
+
+- [ ] **Step 1: After empty render result, check GetLastError()**
+
+In `main.js`, find the block that handles empty render result (around line 1137-1139):
+
+```javascript
+} else {
+ statusText.textContent = 'Render returned empty \u2014 check console';
+}
+```
+
+Replace with:
+
+```javascript
+} else {
+ const lastError = api.GetLastError();
+ if (lastError) {
+ errorsPane.textContent = lastError;
+ statusBar.classList.add('error');
+ statusText.textContent = 'Error';
+ switchToTab('errors');
+ } else {
+ statusText.textContent = 'Render returned empty';
+ }
+}
+```
+
+- [ ] **Step 2: Verify locally**
+
+Run: `dotnet run --project src/FlexRender.Playground`
+
+Create a template referencing a non-existent image:
+
+```yaml
+canvas:
+ width: 200
+ height: 200
+layout:
+ - type: image
+ src: "nonexistent.png"
+ width: 100
+ height: 100
+```
+
+Expected: Error message appears in the Errors tab instead of silent empty render.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/FlexRender.Playground/wwwroot/main.js
+git commit -m "feat: surface render errors for missing images in Playground"
+```
diff --git a/docs/superpowers/plans/2026-06-12-shapes-phase1.md b/docs/superpowers/plans/2026-06-12-shapes-phase1.md
new file mode 100644
index 0000000..93d4993
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-12-shapes-phase1.md
@@ -0,0 +1,3538 @@
+# Shapes (Phase 1) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add declarative shape primitives — `rect`, `circle`, `ellipse` (flex box shapes with fill/gradient/stroke/opacity), and a `draw` element holding absolute-coordinate shapes (`line`, `polyline`, `rect`, `circle`, `path`) — so LLM agents can produce custom graphics without hand-written SVG.
+
+**Architecture:** New AST element classes in `FlexRender.Core/Parsing/Ast/` (`RectElement`, `CircleElement`, `EllipseElement`, `DrawElement`) follow the existing `SeparatorElement` pattern (override `Type`, `ResolveExpressions`, `Materialize`, `CloneWithSubstitution`). A pure, renderer-agnostic, hand-written `PathDataParser` (no regex) lives in Core. Parsing extends `FlexRender.Yaml` (`ElementParsers`, `TemplateParser` dispatch, `KnownProperties`). Layout treats shapes as leaf boxes with explicit dimensions (like `SeparatorElement`). Rendering extends `FlexRender.Skia.Render` via the existing `switch (element)` dispatch in `RenderingEngine.DrawElement`. The object-form gradient is converted to FlexRender's existing CSS-gradient string at parse time, reusing `GradientParser`.
+
+**Tech Stack:** .NET 10, C# latest, xUnit, SkiaSharp, YamlDotNet. AOT-safe (no reflection, no `dynamic`, no regex for path parsing), `sealed` classes, `ArgumentNullException.ThrowIfNull`, switch-based dispatch, XML docs on all public API.
+
+---
+
+## Conventions used throughout this plan
+
+- All commands are run from the repo root `/Users/robonet/Projects/SkiaLayout`.
+- Branch is already `feature/charts-and-shapes`. Do NOT create worktrees. Do NOT merge to `main`.
+- Build: `dotnet build FlexRender.slnx`. Test: `dotnet test FlexRender.slnx`.
+- NEVER pipe `dotnet` output through `tail`/`head`/`grep`. Run commands directly.
+- Commit messages use Conventional Commits, no attribution/Co-Authored-By lines.
+- After every code edit, the build must be warning-free (`TreatWarningsAsErrors=true`).
+
+## File structure (created/modified across all tasks)
+
+Created:
+- `src/FlexRender.Core/Parsing/Ast/RectElement.cs`
+- `src/FlexRender.Core/Parsing/Ast/CircleElement.cs`
+- `src/FlexRender.Core/Parsing/Ast/EllipseElement.cs`
+- `src/FlexRender.Core/Parsing/Ast/DrawShapes.cs` (shape DTOs for `draw`)
+- `src/FlexRender.Core/Parsing/Ast/DrawElement.cs`
+- `src/FlexRender.Core/Parsing/PathDataParser.cs` (pure tokenizer)
+- `src/FlexRender.Yaml/Parsing/ShapeParsers.cs` (parse box shapes + draw shapes + gradient object)
+- `src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs` (Skia drawing for shapes + draw)
+- Test files (see each task)
+
+Modified:
+- `src/FlexRender.Core/Parsing/Ast/TemplateElement.cs` (add `ElementType` enum members)
+- `src/FlexRender.Core/Configuration/ResourceLimits.cs` (add `MaxShapesPerDraw`)
+- `src/FlexRender.Core/Layout/LayoutEngine.cs` (layout the new leaf elements)
+- `src/FlexRender.Core/Layout/IntrinsicMeasurer.cs` (intrinsic size for new leaf elements)
+- `src/FlexRender.Yaml/Parsing/TemplateParser.cs` (register `rect`/`circle`/`ellipse`/`draw`)
+- `src/FlexRender.Yaml/Parsing/KnownProperties.cs` (new property sets + registry entries)
+- `src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs` (dispatch new elements)
+- Docs: `llms.txt`, `llms-full.txt`, `docs/wiki/Element-Reference.md`, `docs/wiki/Visual-Reference.md`,
+ `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json`,
+ `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs`
+
+---
+
+## Task 1: ResourceLimits.MaxShapesPerDraw
+
+**Files:**
+- Modify: `src/FlexRender.Core/Configuration/ResourceLimits.cs`
+- Test: `tests/FlexRender.Tests/Configuration/ResourceLimitsShapesTests.cs` (create)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Configuration/ResourceLimitsShapesTests.cs`:
+
+```csharp
+using System;
+using FlexRender.Configuration;
+using Xunit;
+
+namespace FlexRender.Tests.Configuration;
+
+///
+/// Tests for the limit.
+///
+public sealed class ResourceLimitsShapesTests
+{
+ [Fact]
+ public void MaxShapesPerDraw_DefaultsTo1000()
+ {
+ var limits = new ResourceLimits();
+ Assert.Equal(1000, limits.MaxShapesPerDraw);
+ }
+
+ [Fact]
+ public void MaxShapesPerDraw_AcceptsPositiveValue()
+ {
+ var limits = new ResourceLimits { MaxShapesPerDraw = 50 };
+ Assert.Equal(50, limits.MaxShapesPerDraw);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void MaxShapesPerDraw_RejectsNonPositive(int value)
+ {
+ var limits = new ResourceLimits();
+ Assert.Throws(() => limits.MaxShapesPerDraw = value);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~ResourceLimitsShapesTests"`
+Expected: BUILD FAILURE — `ResourceLimits` does not contain a definition for `MaxShapesPerDraw`.
+
+- [ ] **Step 3: Add the property**
+
+In `src/FlexRender.Core/Configuration/ResourceLimits.cs`, add a backing field next to the others (after `private int _maxFlexLines = 1000;`):
+
+```csharp
+ private int _maxShapesPerDraw = 1000;
+```
+
+Then add this property after the `MaxImageSize` property (before the closing brace of the class):
+
+```csharp
+ ///
+ /// Maximum number of shapes allowed in a single 'draw' element.
+ /// Prevents resource exhaustion from templates with a huge shape list.
+ ///
+ /// Default: 1000.
+ /// Thrown when value is zero or negative.
+ public int MaxShapesPerDraw
+ {
+ get => _maxShapesPerDraw;
+ set
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value);
+ _maxShapesPerDraw = value;
+ }
+ }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~ResourceLimitsShapesTests"`
+Expected: PASS (4 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Core/Configuration/ResourceLimits.cs tests/FlexRender.Tests/Configuration/ResourceLimitsShapesTests.cs
+git commit -m "feat(core): add MaxShapesPerDraw resource limit"
+```
+
+---
+
+## Task 2: ElementType enum members
+
+**Files:**
+- Modify: `src/FlexRender.Core/Parsing/Ast/TemplateElement.cs`
+
+- [ ] **Step 1: Add the new enum members**
+
+In `src/FlexRender.Core/Parsing/Ast/TemplateElement.cs`, inside the `ElementType` enum, add these members after the `Content` member (insert a comma after `Content` first):
+
+```csharp
+ ///
+ /// A rectangle shape element (flex box drawn as a filled/stroked rect).
+ ///
+ Rect,
+
+ ///
+ /// A circle shape element (flex box drawn as a filled/stroked circle).
+ ///
+ Circle,
+
+ ///
+ /// An ellipse shape element (flex box drawn as a filled/stroked ellipse).
+ ///
+ Ellipse,
+
+ ///
+ /// A free-form drawing element holding absolute-coordinate shapes.
+ ///
+ Draw
+```
+
+The enum tail must read:
+
+```csharp
+ ///
+ /// A content element that expands formatted text into a subtree.
+ ///
+ Content,
+
+ ///
+ /// A rectangle shape element (flex box drawn as a filled/stroked rect).
+ ///
+ Rect,
+ // ... (Circle, Ellipse, Draw as above)
+```
+
+- [ ] **Step 2: Verify the build compiles**
+
+Run: `dotnet build FlexRender.slnx`
+Expected: BUILD SUCCEEDED (no behavior change yet; this only extends the enum).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/FlexRender.Core/Parsing/Ast/TemplateElement.cs
+git commit -m "feat(ast): add Rect, Circle, Ellipse, Draw element types"
+```
+
+---
+
+## Task 3: PathDataParser (hand-written tokenizer, no regex)
+
+This is the highest-risk component. Build it pure (Core, renderer-agnostic) with thorough edge-case tests before wiring anything into Skia.
+
+**Files:**
+- Create: `src/FlexRender.Core/Parsing/PathDataParser.cs`
+- Test: `tests/FlexRender.Tests/Parsing/PathDataParserTests.cs`
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `tests/FlexRender.Tests/Parsing/PathDataParserTests.cs`:
+
+```csharp
+using System.Collections.Generic;
+using FlexRender.Parsing;
+using Xunit;
+
+namespace FlexRender.Tests.Parsing;
+
+///
+/// Edge-case tests for the hand-written absolute-only SVG-style path tokenizer.
+///
+public sealed class PathDataParserTests
+{
+ [Fact]
+ public void Parse_MoveAndLine_ProducesTwoCommands()
+ {
+ var commands = PathDataParser.Parse("M 0 0 L 100 50");
+
+ Assert.Equal(2, commands.Count);
+ Assert.Equal(PathCommandKind.MoveTo, commands[0].Kind);
+ Assert.Equal(0f, commands[0].Points[0].X);
+ Assert.Equal(0f, commands[0].Points[0].Y);
+ Assert.Equal(PathCommandKind.LineTo, commands[1].Kind);
+ Assert.Equal(100f, commands[1].Points[0].X);
+ Assert.Equal(50f, commands[1].Points[0].Y);
+ }
+
+ [Fact]
+ public void Parse_QuadraticAndCubicAndClose_ProducesAllCommands()
+ {
+ var commands = PathDataParser.Parse("M 0 0 Q 150 0 200 50 C 10 20 30 40 50 60 Z");
+
+ Assert.Equal(4, commands.Count);
+ Assert.Equal(PathCommandKind.MoveTo, commands[0].Kind);
+ Assert.Equal(PathCommandKind.QuadTo, commands[1].Kind);
+ Assert.Equal(2, commands[1].Points.Count);
+ Assert.Equal(PathCommandKind.CubicTo, commands[2].Kind);
+ Assert.Equal(3, commands[2].Points.Count);
+ Assert.Equal(PathCommandKind.Close, commands[3].Kind);
+ Assert.Empty(commands[3].Points);
+ }
+
+ [Fact]
+ public void Parse_CommaSeparatedCoordinates_ParsesCorrectly()
+ {
+ var commands = PathDataParser.Parse("M0,0 L100,50");
+
+ Assert.Equal(2, commands.Count);
+ Assert.Equal(100f, commands[1].Points[0].X);
+ Assert.Equal(50f, commands[1].Points[0].Y);
+ }
+
+ [Fact]
+ public void Parse_NegativeAndDecimalCoordinates_ParsesCorrectly()
+ {
+ var commands = PathDataParser.Parse("M -1.5 -2.25 L 3.0 -4");
+
+ Assert.Equal(-1.5f, commands[0].Points[0].X);
+ Assert.Equal(-2.25f, commands[0].Points[0].Y);
+ Assert.Equal(3.0f, commands[1].Points[0].X);
+ Assert.Equal(-4f, commands[1].Points[0].Y);
+ }
+
+ [Fact]
+ public void Parse_ImplicitRepeatedLineTo_AfterSingleCommandLetter()
+ {
+ // SVG semantics: "L 10 10 20 20" means two LineTo commands.
+ var commands = PathDataParser.Parse("M 0 0 L 10 10 20 20");
+
+ Assert.Equal(3, commands.Count);
+ Assert.Equal(PathCommandKind.LineTo, commands[1].Kind);
+ Assert.Equal(10f, commands[1].Points[0].X);
+ Assert.Equal(PathCommandKind.LineTo, commands[2].Kind);
+ Assert.Equal(20f, commands[2].Points[0].X);
+ Assert.Equal(20f, commands[2].Points[0].Y);
+ }
+
+ [Fact]
+ public void Parse_LowercaseCommands_TreatedAsAbsolute()
+ {
+ // Lowercase (relative) letters are accepted but treated as absolute,
+ // matching the spec's "absolute only" constraint without erroring on case.
+ var commands = PathDataParser.Parse("m 0 0 l 100 50");
+
+ Assert.Equal(PathCommandKind.MoveTo, commands[0].Kind);
+ Assert.Equal(PathCommandKind.LineTo, commands[1].Kind);
+ Assert.Equal(100f, commands[1].Points[0].X);
+ }
+
+ [Fact]
+ public void Parse_ExtraWhitespace_Ignored()
+ {
+ var commands = PathDataParser.Parse(" M 0 0\tL\n100 50 ");
+ Assert.Equal(2, commands.Count);
+ }
+
+ [Fact]
+ public void Parse_EmptyString_ReturnsEmptyList()
+ {
+ Assert.Empty(PathDataParser.Parse(""));
+ Assert.Empty(PathDataParser.Parse(" "));
+ }
+
+ [Fact]
+ public void Parse_UnknownCommand_ThrowsWithCommandAndPosition()
+ {
+ var ex = Assert.Throws(() => PathDataParser.Parse("M 0 0 X 1 1"));
+ Assert.Contains("'X'", ex.Message);
+ Assert.Contains("position", ex.Message);
+ }
+
+ [Fact]
+ public void Parse_MissingCoordinate_ThrowsWithCommand()
+ {
+ var ex = Assert.Throws(() => PathDataParser.Parse("M 0 0 L 10"));
+ Assert.Contains("'L'", ex.Message);
+ }
+
+ [Fact]
+ public void Parse_DataBeforeFirstCommand_Throws()
+ {
+ var ex = Assert.Throws(() => PathDataParser.Parse("10 20 L 30 40"));
+ Assert.Contains("position", ex.Message);
+ }
+
+ [Fact]
+ public void Parse_NullArgument_Throws()
+ {
+ Assert.Throws(() => PathDataParser.Parse(null!));
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~PathDataParserTests"`
+Expected: BUILD FAILURE — `PathDataParser`, `PathCommandKind`, `PathParseException` not defined.
+
+- [ ] **Step 3: Implement PathDataParser**
+
+Create `src/FlexRender.Core/Parsing/PathDataParser.cs`:
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+
+namespace FlexRender.Parsing;
+
+///
+/// The kind of a parsed path command.
+///
+public enum PathCommandKind
+{
+ /// Move the pen to a point (M).
+ MoveTo,
+
+ /// Draw a straight line to a point (L).
+ LineTo,
+
+ /// Draw a quadratic Bézier curve (Q): one control point, one end point.
+ QuadTo,
+
+ /// Draw a cubic Bézier curve (C): two control points, one end point.
+ CubicTo,
+
+ /// Close the current sub-path (Z).
+ Close
+}
+
+///
+/// A 2D point in absolute path coordinates.
+///
+/// The X coordinate.
+/// The Y coordinate.
+public readonly record struct PathPoint(float X, float Y);
+
+///
+/// A single parsed path command with its associated points.
+///
+/// The command kind.
+/// The command's points (empty for ).
+public sealed record PathCommand(PathCommandKind Kind, IReadOnlyList Points);
+
+///
+/// Thrown when path data ('d' attribute) cannot be parsed.
+///
+public sealed class PathParseException : Exception
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The error message.
+ public PathParseException(string message) : base(message)
+ {
+ }
+}
+
+///
+/// Hand-written tokenizer for SVG-style path data restricted to absolute commands
+/// M, L, Q, C, Z. AOT-safe — no regex, no backtracking.
+///
+///
+/// Lowercase command letters are accepted but treated as absolute (the spec restricts
+/// drawing to absolute coordinates). Numbers may be separated by whitespace and/or commas.
+/// Implicit repeated commands are supported per SVG semantics (e.g. "L 1 1 2 2" is two
+/// line-to commands).
+///
+public static class PathDataParser
+{
+ ///
+ /// Parses path data into an ordered list of absolute commands.
+ ///
+ /// The path data string (e.g. "M 0 0 L 100 50 Z").
+ /// The parsed commands in order. Empty when is blank.
+ /// Thrown when is null.
+ /// Thrown on malformed input, naming the command and position.
+ public static IReadOnlyList Parse(string data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+
+ var commands = new List();
+ var i = 0;
+ var length = data.Length;
+ char currentCommand = '\0';
+
+ while (i < length)
+ {
+ SkipSeparators(data, ref i);
+ if (i >= length)
+ break;
+
+ var c = data[i];
+ var upper = char.ToUpperInvariant(c);
+
+ if (upper is 'M' or 'L' or 'Q' or 'C' or 'Z')
+ {
+ currentCommand = upper;
+ i++;
+
+ if (currentCommand == 'Z')
+ {
+ commands.Add(new PathCommand(PathCommandKind.Close, Array.Empty()));
+ currentCommand = '\0';
+ }
+ continue;
+ }
+
+ // Not a command letter: must be a coordinate continuing the current command.
+ if (currentCommand == '\0')
+ {
+ throw new PathParseException(
+ $"Unexpected character '{c}' at position {i}: path data must begin with a command letter (M, L, Q, C, Z).");
+ }
+
+ if (!IsCoordinateStart(c))
+ {
+ throw new PathParseException(
+ $"Unexpected character '{c}' at position {i} while reading command '{currentCommand}'.");
+ }
+
+ var (kind, pointCount) = currentCommand switch
+ {
+ 'M' => (PathCommandKind.MoveTo, 1),
+ 'L' => (PathCommandKind.LineTo, 1),
+ 'Q' => (PathCommandKind.QuadTo, 2),
+ 'C' => (PathCommandKind.CubicTo, 3),
+ _ => throw new PathParseException(
+ $"Internal error: unexpected command '{currentCommand}' at position {i}.")
+ };
+
+ var points = new PathPoint[pointCount];
+ for (var p = 0; p < pointCount; p++)
+ {
+ var x = ReadNumber(data, ref i, currentCommand);
+ var y = ReadNumber(data, ref i, currentCommand);
+ points[p] = new PathPoint(x, y);
+ }
+
+ commands.Add(new PathCommand(kind, points));
+
+ // After an initial MoveTo, repeated coordinates imply LineTo (SVG semantics).
+ if (currentCommand == 'M')
+ {
+ currentCommand = 'L';
+ }
+ }
+
+ return commands;
+ }
+
+ private static bool IsCoordinateStart(char c)
+ => c is '-' or '+' or '.' || (c >= '0' && c <= '9');
+
+ private static void SkipSeparators(string data, ref int i)
+ {
+ while (i < data.Length)
+ {
+ var c = data[i];
+ if (c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == ',')
+ {
+ i++;
+ continue;
+ }
+ break;
+ }
+ }
+
+ private static float ReadNumber(string data, ref int i, char command)
+ {
+ SkipSeparators(data, ref i);
+
+ var start = i;
+ var length = data.Length;
+
+ if (i < length && (data[i] == '-' || data[i] == '+'))
+ i++;
+
+ var hasDigits = false;
+ while (i < length && data[i] >= '0' && data[i] <= '9')
+ {
+ i++;
+ hasDigits = true;
+ }
+
+ if (i < length && data[i] == '.')
+ {
+ i++;
+ while (i < length && data[i] >= '0' && data[i] <= '9')
+ {
+ i++;
+ hasDigits = true;
+ }
+ }
+
+ // Exponent (e.g. 1e3, 2.5E-2)
+ if (i < length && (data[i] == 'e' || data[i] == 'E'))
+ {
+ i++;
+ if (i < length && (data[i] == '-' || data[i] == '+'))
+ i++;
+ while (i < length && data[i] >= '0' && data[i] <= '9')
+ i++;
+ }
+
+ if (!hasDigits)
+ {
+ throw new PathParseException(
+ $"Expected a number at position {start} while reading command '{command}'.");
+ }
+
+ var span = data.AsSpan(start, i - start);
+ if (!float.TryParse(span, NumberStyles.Float, CultureInfo.InvariantCulture, out var value))
+ {
+ throw new PathParseException(
+ $"Invalid number '{span.ToString()}' at position {start} while reading command '{command}'.");
+ }
+
+ return value;
+ }
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~PathDataParserTests"`
+Expected: PASS (12 test cases).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Core/Parsing/PathDataParser.cs tests/FlexRender.Tests/Parsing/PathDataParserTests.cs
+git commit -m "feat(parser): add hand-written absolute-only path data tokenizer"
+```
+
+---
+
+## Task 4: RectElement AST class
+
+**Files:**
+- Create: `src/FlexRender.Core/Parsing/Ast/RectElement.cs`
+- Test: `tests/FlexRender.Tests/Parsing/Ast/RectElementTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Parsing/Ast/RectElementTests.cs`:
+
+```csharp
+using FlexRender.Parsing.Ast;
+using Xunit;
+
+namespace FlexRender.Tests.Parsing.Ast;
+
+///
+/// Tests for the AST class.
+///
+public sealed class RectElementTests
+{
+ [Fact]
+ public void Type_IsRect()
+ {
+ var rect = new RectElement();
+ Assert.Equal(ElementType.Rect, rect.Type);
+ }
+
+ [Fact]
+ public void Defaults_AreEmpty()
+ {
+ var rect = new RectElement();
+ Assert.Null(rect.Fill.Value);
+ Assert.Null(rect.Stroke.Value);
+ Assert.Equal(0f, rect.StrokeWidth.Value);
+ Assert.Null(rect.Radius.Value);
+ }
+
+ [Fact]
+ public void CloneWithSubstitution_CopiesShapeProperties()
+ {
+ var rect = new RectElement
+ {
+ Fill = "#4A90D9",
+ Stroke = "#333333",
+ StrokeWidth = 2f,
+ Radius = "4",
+ Width = "100",
+ Height = "50"
+ };
+
+ var clone = (RectElement)rect.CloneWithSubstitution(s => s);
+
+ Assert.Equal("#4A90D9", clone.Fill.Value);
+ Assert.Equal("#333333", clone.Stroke.Value);
+ Assert.Equal(2f, clone.StrokeWidth.Value);
+ Assert.Equal("4", clone.Radius.Value);
+ Assert.Equal("100", clone.Width.Value);
+ Assert.Equal("50", clone.Height.Value);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~RectElementTests"`
+Expected: BUILD FAILURE — `RectElement` not defined.
+
+- [ ] **Step 3: Implement RectElement**
+
+Create `src/FlexRender.Core/Parsing/Ast/RectElement.cs`:
+
+```csharp
+using System;
+using FlexRender.TemplateEngine;
+
+namespace FlexRender.Parsing.Ast;
+
+///
+/// A rectangle shape element. Participates in flex layout as a box with explicit
+/// width/height and is drawn as a filled and/or stroked rectangle with optional rounded corners.
+///
+public sealed class RectElement : TemplateElement
+{
+ ///
+ public override ElementType Type => ElementType.Rect;
+
+ ///
+ /// Fill: a solid color (e.g. "#4A90D9") or a gradient string produced from the YAML
+ /// gradient object form (a CSS-style "linear-gradient(...)" / "radial-gradient(...)").
+ /// Null means no fill.
+ ///
+ public ExprValue Fill { get; set; }
+
+ /// Stroke color in hex format. Null means no stroke.
+ public ExprValue Stroke { get; set; }
+
+ /// Stroke width in pixels. Zero means no stroke.
+ public ExprValue StrokeWidth { get; set; }
+
+ /// Corner radius (px, em). Null means square corners.
+ public ExprValue Radius { get; set; }
+
+ ///
+ public override TemplateElement CloneWithSubstitution(Func substitutor)
+ {
+ ArgumentNullException.ThrowIfNull(substitutor);
+
+ var clone = new RectElement
+ {
+ Fill = substitutor(Fill.Value)!,
+ Stroke = Stroke,
+ StrokeWidth = StrokeWidth,
+ Radius = Radius
+ };
+ CopyBasePropertiesTo(clone, substitutor);
+ return clone;
+ }
+
+ ///
+ public override void ResolveExpressions(Func resolver, ObjectValue data)
+ {
+ base.ResolveExpressions(resolver, data);
+ Fill = Fill.Resolve(resolver, data);
+ Stroke = Stroke.Resolve(resolver, data);
+ StrokeWidth = StrokeWidth.Resolve(resolver, data);
+ Radius = Radius.Resolve(resolver, data);
+ }
+
+ ///
+ public override void Materialize()
+ {
+ base.Materialize();
+ Fill = Fill.Materialize(nameof(Fill));
+ Stroke = Stroke.Materialize(nameof(Stroke), ValueKind.Color);
+ StrokeWidth = StrokeWidth.Materialize(nameof(StrokeWidth));
+ Radius = Radius.Materialize(nameof(Radius), ValueKind.Size);
+ }
+}
+```
+
+Note: `Fill` is materialized with `ValueKind.Any` (not `Color`) because it may hold a gradient string.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~RectElementTests"`
+Expected: PASS (3 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Core/Parsing/Ast/RectElement.cs tests/FlexRender.Tests/Parsing/Ast/RectElementTests.cs
+git commit -m "feat(ast): add RectElement shape"
+```
+
+---
+
+## Task 5: CircleElement AST class
+
+**Files:**
+- Create: `src/FlexRender.Core/Parsing/Ast/CircleElement.cs`
+- Test: `tests/FlexRender.Tests/Parsing/Ast/CircleElementTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Parsing/Ast/CircleElementTests.cs`:
+
+```csharp
+using FlexRender.Parsing.Ast;
+using Xunit;
+
+namespace FlexRender.Tests.Parsing.Ast;
+
+///
+/// Tests for the AST class.
+///
+public sealed class CircleElementTests
+{
+ [Fact]
+ public void Type_IsCircle()
+ {
+ var circle = new CircleElement();
+ Assert.Equal(ElementType.Circle, circle.Type);
+ }
+
+ [Fact]
+ public void CloneWithSubstitution_CopiesShapeProperties()
+ {
+ var circle = new CircleElement
+ {
+ Fill = "#e74c3c",
+ Stroke = "#000000",
+ StrokeWidth = 1.5f,
+ Width = "40",
+ Height = "40"
+ };
+
+ var clone = (CircleElement)circle.CloneWithSubstitution(s => s);
+
+ Assert.Equal("#e74c3c", clone.Fill.Value);
+ Assert.Equal("#000000", clone.Stroke.Value);
+ Assert.Equal(1.5f, clone.StrokeWidth.Value);
+ Assert.Equal("40", clone.Width.Value);
+ Assert.Equal("40", clone.Height.Value);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~CircleElementTests"`
+Expected: BUILD FAILURE — `CircleElement` not defined.
+
+- [ ] **Step 3: Implement CircleElement**
+
+Create `src/FlexRender.Core/Parsing/Ast/CircleElement.cs`:
+
+```csharp
+using System;
+using FlexRender.TemplateEngine;
+
+namespace FlexRender.Parsing.Ast;
+
+///
+/// A circle shape element. Participates in flex layout as a square box (the YAML 'size'
+/// shorthand sets both Width and Height) and is drawn as a filled and/or stroked circle
+/// inscribed in the box. When Width and Height differ, the smaller dimension is used as the diameter.
+///
+public sealed class CircleElement : TemplateElement
+{
+ ///
+ public override ElementType Type => ElementType.Circle;
+
+ ///
+ /// Fill: a solid color (e.g. "#e74c3c") or a gradient string produced from the YAML
+ /// gradient object form. Null means no fill.
+ ///
+ public ExprValue Fill { get; set; }
+
+ /// Stroke color in hex format. Null means no stroke.
+ public ExprValue Stroke { get; set; }
+
+ /// Stroke width in pixels. Zero means no stroke.
+ public ExprValue StrokeWidth { get; set; }
+
+ ///
+ public override TemplateElement CloneWithSubstitution(Func substitutor)
+ {
+ ArgumentNullException.ThrowIfNull(substitutor);
+
+ var clone = new CircleElement
+ {
+ Fill = substitutor(Fill.Value)!,
+ Stroke = Stroke,
+ StrokeWidth = StrokeWidth
+ };
+ CopyBasePropertiesTo(clone, substitutor);
+ return clone;
+ }
+
+ ///
+ public override void ResolveExpressions(Func resolver, ObjectValue data)
+ {
+ base.ResolveExpressions(resolver, data);
+ Fill = Fill.Resolve(resolver, data);
+ Stroke = Stroke.Resolve(resolver, data);
+ StrokeWidth = StrokeWidth.Resolve(resolver, data);
+ }
+
+ ///
+ public override void Materialize()
+ {
+ base.Materialize();
+ Fill = Fill.Materialize(nameof(Fill));
+ Stroke = Stroke.Materialize(nameof(Stroke), ValueKind.Color);
+ StrokeWidth = StrokeWidth.Materialize(nameof(StrokeWidth));
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~CircleElementTests"`
+Expected: PASS (2 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Core/Parsing/Ast/CircleElement.cs tests/FlexRender.Tests/Parsing/Ast/CircleElementTests.cs
+git commit -m "feat(ast): add CircleElement shape"
+```
+
+---
+
+## Task 6: EllipseElement AST class
+
+**Files:**
+- Create: `src/FlexRender.Core/Parsing/Ast/EllipseElement.cs`
+- Test: `tests/FlexRender.Tests/Parsing/Ast/EllipseElementTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Parsing/Ast/EllipseElementTests.cs`:
+
+```csharp
+using FlexRender.Parsing.Ast;
+using Xunit;
+
+namespace FlexRender.Tests.Parsing.Ast;
+
+///
+/// Tests for the AST class.
+///
+public sealed class EllipseElementTests
+{
+ [Fact]
+ public void Type_IsEllipse()
+ {
+ var ellipse = new EllipseElement();
+ Assert.Equal(ElementType.Ellipse, ellipse.Type);
+ }
+
+ [Fact]
+ public void CloneWithSubstitution_CopiesShapeProperties()
+ {
+ var ellipse = new EllipseElement
+ {
+ Fill = "#2ecc71",
+ Stroke = "#111111",
+ StrokeWidth = 3f,
+ Width = "120",
+ Height = "60"
+ };
+
+ var clone = (EllipseElement)ellipse.CloneWithSubstitution(s => s);
+
+ Assert.Equal("#2ecc71", clone.Fill.Value);
+ Assert.Equal("#111111", clone.Stroke.Value);
+ Assert.Equal(3f, clone.StrokeWidth.Value);
+ Assert.Equal("120", clone.Width.Value);
+ Assert.Equal("60", clone.Height.Value);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~EllipseElementTests"`
+Expected: BUILD FAILURE — `EllipseElement` not defined.
+
+- [ ] **Step 3: Implement EllipseElement**
+
+Create `src/FlexRender.Core/Parsing/Ast/EllipseElement.cs`:
+
+```csharp
+using System;
+using FlexRender.TemplateEngine;
+
+namespace FlexRender.Parsing.Ast;
+
+///
+/// An ellipse shape element. Participates in flex layout as a box with explicit
+/// width/height and is drawn as a filled and/or stroked ellipse inscribed in the box.
+///
+public sealed class EllipseElement : TemplateElement
+{
+ ///
+ public override ElementType Type => ElementType.Ellipse;
+
+ ///
+ /// Fill: a solid color or a gradient string produced from the YAML gradient object form.
+ /// Null means no fill.
+ ///
+ public ExprValue Fill { get; set; }
+
+ /// Stroke color in hex format. Null means no stroke.
+ public ExprValue Stroke { get; set; }
+
+ /// Stroke width in pixels. Zero means no stroke.
+ public ExprValue StrokeWidth { get; set; }
+
+ ///
+ public override TemplateElement CloneWithSubstitution(Func substitutor)
+ {
+ ArgumentNullException.ThrowIfNull(substitutor);
+
+ var clone = new EllipseElement
+ {
+ Fill = substitutor(Fill.Value)!,
+ Stroke = Stroke,
+ StrokeWidth = StrokeWidth
+ };
+ CopyBasePropertiesTo(clone, substitutor);
+ return clone;
+ }
+
+ ///
+ public override void ResolveExpressions(Func resolver, ObjectValue data)
+ {
+ base.ResolveExpressions(resolver, data);
+ Fill = Fill.Resolve(resolver, data);
+ Stroke = Stroke.Resolve(resolver, data);
+ StrokeWidth = StrokeWidth.Resolve(resolver, data);
+ }
+
+ ///
+ public override void Materialize()
+ {
+ base.Materialize();
+ Fill = Fill.Materialize(nameof(Fill));
+ Stroke = Stroke.Materialize(nameof(Stroke), ValueKind.Color);
+ StrokeWidth = StrokeWidth.Materialize(nameof(StrokeWidth));
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~EllipseElementTests"`
+Expected: PASS (2 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Core/Parsing/Ast/EllipseElement.cs tests/FlexRender.Tests/Parsing/Ast/EllipseElementTests.cs
+git commit -m "feat(ast): add EllipseElement shape"
+```
+
+---
+
+## Task 7: DrawShapes DTOs
+
+The `draw` element holds a list of absolute-coordinate shapes. These are renderer-agnostic
+immutable DTOs in Core. The pre-parsed path commands are stored on the `DrawPath` DTO so the
+renderer never re-parses.
+
+**Files:**
+- Create: `src/FlexRender.Core/Parsing/Ast/DrawShapes.cs`
+- Test: `tests/FlexRender.Tests/Parsing/Ast/DrawShapesTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Parsing/Ast/DrawShapesTests.cs`:
+
+```csharp
+using System.Collections.Generic;
+using FlexRender.Parsing;
+using FlexRender.Parsing.Ast;
+using Xunit;
+
+namespace FlexRender.Tests.Parsing.Ast;
+
+///
+/// Tests for the immutable draw-shape DTOs.
+///
+public sealed class DrawShapesTests
+{
+ [Fact]
+ public void DrawLine_StoresCoordinatesAndStroke()
+ {
+ var line = new DrawLine(0f, 100f, 400f, 50f, "#333333", 2f);
+ Assert.Equal(0f, line.X1);
+ Assert.Equal(100f, line.Y1);
+ Assert.Equal(400f, line.X2);
+ Assert.Equal(50f, line.Y2);
+ Assert.Equal("#333333", line.Stroke);
+ Assert.Equal(2f, line.StrokeWidth);
+ }
+
+ [Fact]
+ public void DrawPolyline_StoresPointsAndStroke()
+ {
+ var points = new List { new(0f, 10f), new(50f, 40f) };
+ var polyline = new DrawPolyline(points, "#4A90D9", 1f, fill: null);
+ Assert.Equal(2, polyline.Points.Count);
+ Assert.Equal("#4A90D9", polyline.Stroke);
+ }
+
+ [Fact]
+ public void DrawRect_StoresGeometryFillStrokeRadius()
+ {
+ var rect = new DrawRect(10f, 10f, 80f, 40f, "#eeeeee", stroke: null, strokeWidth: 0f, radius: 4f);
+ Assert.Equal(10f, rect.X);
+ Assert.Equal(80f, rect.Width);
+ Assert.Equal("#eeeeee", rect.Fill);
+ Assert.Equal(4f, rect.Radius);
+ }
+
+ [Fact]
+ public void DrawCircle_StoresCenterRadiusFill()
+ {
+ var circle = new DrawCircle(200f, 75f, 30f, "#e74c3c", stroke: null, strokeWidth: 0f);
+ Assert.Equal(200f, circle.Cx);
+ Assert.Equal(75f, circle.Cy);
+ Assert.Equal(30f, circle.R);
+ Assert.Equal("#e74c3c", circle.Fill);
+ }
+
+ [Fact]
+ public void DrawPath_StoresCommandsFillStroke()
+ {
+ var commands = PathDataParser.Parse("M 0 0 L 100 50 Z");
+ var path = new DrawPath(commands, "#2ecc71", stroke: null, strokeWidth: 0f);
+ Assert.Equal(3, path.Commands.Count);
+ Assert.Equal("#2ecc71", path.Fill);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~DrawShapesTests"`
+Expected: BUILD FAILURE — `DrawLine`, `DrawPolyline`, `DrawRect`, `DrawCircle`, `DrawPath`, `DrawShape` not defined.
+
+- [ ] **Step 3: Implement DrawShapes**
+
+Create `src/FlexRender.Core/Parsing/Ast/DrawShapes.cs`:
+
+```csharp
+using System.Collections.Generic;
+
+namespace FlexRender.Parsing.Ast;
+
+///
+/// Base type for a single shape inside a .
+/// All coordinates are absolute, relative to the draw element's top-left corner.
+///
+public abstract record DrawShape;
+
+///
+/// A straight line segment.
+///
+/// Start X.
+/// Start Y.
+/// End X.
+/// End Y.
+/// Stroke color (hex). Null means default black.
+/// Stroke width in pixels.
+public sealed record DrawLine(
+ float X1, float Y1, float X2, float Y2, string? Stroke, float StrokeWidth) : DrawShape;
+
+///
+/// A connected sequence of line segments through the given points.
+///
+/// The vertices in order.
+/// Stroke color (hex). Null means default black.
+/// Stroke width in pixels.
+/// Optional fill color (hex) for the enclosed area. Null means no fill.
+public sealed record DrawPolyline(
+ IReadOnlyList Points, string? Stroke, float StrokeWidth, string? Fill) : DrawShape;
+
+///
+/// A rectangle, optionally rounded.
+///
+/// Top-left X.
+/// Top-left Y.
+/// Width in pixels.
+/// Height in pixels.
+/// Optional fill color (hex). Null means no fill.
+/// Optional stroke color (hex). Null means no stroke.
+/// Stroke width in pixels.
+/// Corner radius in pixels.
+public sealed record DrawRect(
+ float X, float Y, float Width, float Height,
+ string? Fill, string? Stroke, float StrokeWidth, float Radius) : DrawShape;
+
+///
+/// A circle centred at (Cx, Cy) with radius R.
+///
+/// Centre X.
+/// Centre Y.
+/// Radius in pixels.
+/// Optional fill color (hex). Null means no fill.
+/// Optional stroke color (hex). Null means no stroke.
+/// Stroke width in pixels.
+public sealed record DrawCircle(
+ float Cx, float Cy, float R,
+ string? Fill, string? Stroke, float StrokeWidth) : DrawShape;
+
+///
+/// A free-form path made of pre-parsed absolute commands.
+///
+/// The parsed path commands (M/L/Q/C/Z).
+/// Optional fill color (hex). Null means no fill.
+/// Optional stroke color (hex). Null means no stroke.
+/// Stroke width in pixels.
+public sealed record DrawPath(
+ IReadOnlyList Commands,
+ string? Fill, string? Stroke, float StrokeWidth) : DrawShape;
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~DrawShapesTests"`
+Expected: PASS (5 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Core/Parsing/Ast/DrawShapes.cs tests/FlexRender.Tests/Parsing/Ast/DrawShapesTests.cs
+git commit -m "feat(ast): add draw shape DTOs"
+```
+
+---
+
+## Task 8: DrawElement AST class
+
+**Files:**
+- Create: `src/FlexRender.Core/Parsing/Ast/DrawElement.cs`
+- Test: `tests/FlexRender.Tests/Parsing/Ast/DrawElementTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Parsing/Ast/DrawElementTests.cs`:
+
+```csharp
+using System.Collections.Generic;
+using FlexRender.Parsing.Ast;
+using Xunit;
+
+namespace FlexRender.Tests.Parsing.Ast;
+
+///
+/// Tests for the AST class.
+///
+public sealed class DrawElementTests
+{
+ [Fact]
+ public void Type_IsDraw()
+ {
+ var draw = new DrawElement(new List());
+ Assert.Equal(ElementType.Draw, draw.Type);
+ }
+
+ [Fact]
+ public void Shapes_AreExposedInOrder()
+ {
+ var shapes = new List
+ {
+ new DrawLine(0f, 0f, 10f, 10f, "#000000", 1f),
+ new DrawCircle(5f, 5f, 3f, "#ff0000", null, 0f)
+ };
+ var draw = new DrawElement(shapes) { Width = "400", Height = "200" };
+
+ Assert.Equal(2, draw.Shapes.Count);
+ Assert.IsType(draw.Shapes[0]);
+ Assert.IsType(draw.Shapes[1]);
+ Assert.Equal("400", draw.Width.Value);
+ }
+
+ [Fact]
+ public void CloneWithSubstitution_PreservesShapes()
+ {
+ var shapes = new List { new DrawLine(0f, 0f, 10f, 10f, "#000000", 1f) };
+ var draw = new DrawElement(shapes) { Width = "400", Height = "200" };
+
+ var clone = (DrawElement)draw.CloneWithSubstitution(s => s);
+
+ Assert.Single(clone.Shapes);
+ Assert.Equal("400", clone.Width.Value);
+ Assert.Equal("200", clone.Height.Value);
+ }
+
+ [Fact]
+ public void Constructor_NullShapes_Throws()
+ {
+ Assert.Throws(() => new DrawElement(null!));
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~DrawElementTests"`
+Expected: BUILD FAILURE — `DrawElement` not defined.
+
+- [ ] **Step 3: Implement DrawElement**
+
+Create `src/FlexRender.Core/Parsing/Ast/DrawElement.cs`:
+
+```csharp
+using System;
+using System.Collections.Generic;
+using FlexRender.TemplateEngine;
+
+namespace FlexRender.Parsing.Ast;
+
+///
+/// A free-form drawing element. Participates in flex layout as a box with explicit
+/// width/height; inside, an ordered list of absolute-coordinate shapes is painted
+/// in list order (painter's algorithm).
+///
+///
+/// Shapes use absolute coordinates relative to the element's top-left corner.
+/// The shape list is fixed at parse time and is not expression-resolvable.
+///
+public sealed class DrawElement : TemplateElement
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The ordered shapes to paint.
+ /// Thrown when is null.
+ public DrawElement(IReadOnlyList shapes)
+ {
+ ArgumentNullException.ThrowIfNull(shapes);
+ Shapes = shapes;
+ }
+
+ ///
+ public override ElementType Type => ElementType.Draw;
+
+ ///
+ /// The ordered list of shapes painted inside this element.
+ ///
+ public IReadOnlyList Shapes { get; }
+
+ ///
+ public override TemplateElement CloneWithSubstitution(Func substitutor)
+ {
+ ArgumentNullException.ThrowIfNull(substitutor);
+
+ var clone = new DrawElement(Shapes);
+ CopyBasePropertiesTo(clone, substitutor);
+ return clone;
+ }
+}
+```
+
+Note: `DrawElement` does not override `ResolveExpressions`/`Materialize` because it has no
+`ExprValue` properties of its own beyond the base; the base implementations suffice.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~DrawElementTests"`
+Expected: PASS (4 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Core/Parsing/Ast/DrawElement.cs tests/FlexRender.Tests/Parsing/Ast/DrawElementTests.cs
+git commit -m "feat(ast): add DrawElement"
+```
+
+---
+
+## Task 9: Layout for shape leaf elements (intrinsic + layout)
+
+Shapes are leaf boxes like `SeparatorElement`. They need intrinsic measurement and layout entries.
+
+**Files:**
+- Modify: `src/FlexRender.Core/Layout/IntrinsicMeasurer.cs`
+- Modify: `src/FlexRender.Core/Layout/LayoutEngine.cs`
+- Test: `tests/FlexRender.Tests/Layout/ShapeLayoutTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Layout/ShapeLayoutTests.cs`:
+
+```csharp
+using FlexRender.Configuration;
+using FlexRender.Layout;
+using FlexRender.Parsing.Ast;
+using Xunit;
+
+namespace FlexRender.Tests.Layout;
+
+///
+/// Layout tests for the shape leaf elements (rect, circle, ellipse, draw).
+///
+public sealed class ShapeLayoutTests
+{
+ private static LayoutEngine CreateEngine() => new(new ResourceLimits());
+
+ [Fact]
+ public void Rect_WithExplicitSize_ProducesThatSize()
+ {
+ var rect = new RectElement { Width = "100", Height = "50", Fill = "#ff0000" };
+ var template = new Template
+ {
+ Canvas = new CanvasSettings { Width = 300, Fixed = FixedDimension.Width }
+ };
+ template.AddElement(rect);
+
+ var engine = CreateEngine();
+ var root = engine.ComputeLayout(template);
+ var node = root.Children[0];
+
+ Assert.Equal(100f, node.Width);
+ Assert.Equal(50f, node.Height);
+ }
+
+ [Fact]
+ public void Draw_WithExplicitSize_ProducesThatSize()
+ {
+ var draw = new DrawElement(new[] { (DrawShape)new DrawLine(0f, 0f, 10f, 10f, "#000", 1f) })
+ {
+ Width = "400",
+ Height = "200"
+ };
+ var template = new Template
+ {
+ Canvas = new CanvasSettings { Width = 400, Fixed = FixedDimension.Width }
+ };
+ template.AddElement(draw);
+
+ var engine = CreateEngine();
+ var root = engine.ComputeLayout(template);
+ var node = root.Children[0];
+
+ Assert.Equal(400f, node.Width);
+ Assert.Equal(200f, node.Height);
+ }
+}
+```
+
+Note: Verify the exact entrypoint name (`ComputeLayout(Template)` returning a root `LayoutNode`)
+against `LayoutEngine`; if the public method differs, mirror the call used by existing layout
+tests in `tests/FlexRender.Tests/Layout/`. (The two-pass engine wires intrinsics internally for
+the `Template` overload.)
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~ShapeLayoutTests"`
+Expected: FAIL — shapes fall through to the default leaf branch and produce `ContainerWidth`/default height instead of explicit dimensions.
+
+- [ ] **Step 3a: Add intrinsic measurement**
+
+In `src/FlexRender.Core/Layout/IntrinsicMeasurer.cs`, extend the dispatch switch in
+`MeasureIntrinsic` (currently mapping `SeparatorElement` etc.). Add these cases before the
+`FlexElement flex =>` case:
+
+```csharp
+ RectElement rect => MeasureBoxShapeIntrinsic(rect, rect.Width.Value, rect.Height.Value),
+ CircleElement circle => MeasureBoxShapeIntrinsic(circle, circle.Width.Value, circle.Height.Value),
+ EllipseElement ellipse => MeasureBoxShapeIntrinsic(ellipse, ellipse.Width.Value, ellipse.Height.Value),
+ DrawElement draw => MeasureBoxShapeIntrinsic(draw, draw.Width.Value, draw.Height.Value),
+```
+
+Then add this helper method (place it after `MeasureSeparatorIntrinsic`):
+
+```csharp
+ ///
+ /// Measures intrinsic size for a leaf shape element (rect, circle, ellipse, draw)
+ /// using its explicit width/height. Defaults to zero when a dimension is unspecified.
+ ///
+ private static IntrinsicSize MeasureBoxShapeIntrinsic(TemplateElement element, string? width, string? height)
+ {
+ var w = ParseAbsolutePixelValue(width, 0f);
+ var h = ParseAbsolutePixelValue(height, 0f);
+ var intrinsic = new IntrinsicSize(w, w, h, h);
+ return ApplyPaddingBorderAndMargin(intrinsic, element);
+ }
+```
+
+- [ ] **Step 3b: Add layout entries**
+
+In `src/FlexRender.Core/Layout/LayoutEngine.cs`, in `LayoutElement`'s `element switch`, add these
+cases before the `SeparatorElement separator =>` line:
+
+```csharp
+ RectElement rect => LayoutBoxShapeElement(rect, rect.Width.Value, rect.Height.Value, context),
+ CircleElement circle => LayoutBoxShapeElement(circle, circle.Width.Value, circle.Height.Value, context),
+ EllipseElement ellipse => LayoutBoxShapeElement(ellipse, ellipse.Width.Value, ellipse.Height.Value, context),
+ DrawElement draw => LayoutBoxShapeElement(draw, draw.Width.Value, draw.Height.Value, context),
+```
+
+Then add this method right after `LayoutSeparatorElement`:
+
+```csharp
+ ///
+ /// Lays out a leaf shape element (rect, circle, ellipse, draw) using its explicit
+ /// width/height plus padding and border. Width defaults to the container width and
+ /// height to zero when unspecified.
+ ///
+ private static LayoutNode LayoutBoxShapeElement(TemplateElement element, string? width, string? height, LayoutContext context)
+ {
+ var padding = PaddingParser.Parse(element.Padding.Value, context.ContainerWidth, context.FontSize).ClampNegatives();
+ var border = BorderParser.Resolve(element, context.ContainerWidth, context.FontSize);
+
+ var contentWidth = context.ResolveWidth(width) ?? context.ContainerWidth;
+ var contentHeight = context.ResolveHeight(height) ?? 0f;
+
+ var totalWidth = contentWidth + padding.Horizontal + border.Horizontal;
+ var totalHeight = contentHeight + padding.Vertical + border.Vertical;
+
+ return new LayoutNode(element, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize };
+ }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~ShapeLayoutTests"`
+Expected: PASS (2 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Core/Layout/IntrinsicMeasurer.cs src/FlexRender.Core/Layout/LayoutEngine.cs tests/FlexRender.Tests/Layout/ShapeLayoutTests.cs
+git commit -m "feat(layout): measure and lay out shape leaf elements"
+```
+
+---
+
+## Task 10: Gradient object form -> CSS gradient string converter
+
+The YAML `fill` object form is converted to the existing CSS gradient string so the renderer can
+reuse `GradientParser`. This converter lives in `FlexRender.Yaml` as a static helper.
+
+**Files:**
+- Create: `src/FlexRender.Yaml/Parsing/ShapeParsers.cs` (start with the gradient converter only)
+- Test: `tests/FlexRender.Tests/Parsing/GradientObjectParseTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Parsing/GradientObjectParseTests.cs`:
+
+```csharp
+using System.IO;
+using FlexRender.Parsing;
+using YamlDotNet.RepresentationModel;
+using Xunit;
+
+namespace FlexRender.Tests.Parsing;
+
+///
+/// Tests for converting the YAML gradient object form to a CSS gradient string.
+///
+public sealed class GradientObjectParseTests
+{
+ private static YamlMappingNode ParseMapping(string yaml)
+ {
+ var stream = new YamlStream();
+ using var reader = new StringReader(yaml);
+ stream.Load(reader);
+ return (YamlMappingNode)stream.Documents[0].RootNode;
+ }
+
+ [Fact]
+ public void LinearGradient_WithAngleAndColors_ProducesCssString()
+ {
+ var node = ParseMapping("""
+ gradient: linear
+ colors: ["#ff0000", "#0000ff"]
+ angle: 45
+ """);
+
+ var css = ShapeParsers.ConvertGradientObjectToCss(node);
+
+ Assert.Equal("linear-gradient(45deg, #ff0000, #0000ff)", css);
+ }
+
+ [Fact]
+ public void LinearGradient_WithoutAngle_DefaultsToZeroDeg()
+ {
+ var node = ParseMapping("""
+ gradient: linear
+ colors: ["#fff", "#000"]
+ """);
+
+ var css = ShapeParsers.ConvertGradientObjectToCss(node);
+
+ Assert.Equal("linear-gradient(0deg, #fff, #000)", css);
+ }
+
+ [Fact]
+ public void RadialGradient_IgnoresAngle()
+ {
+ var node = ParseMapping("""
+ gradient: radial
+ colors: ["#fff", "#000"]
+ angle: 90
+ """);
+
+ var css = ShapeParsers.ConvertGradientObjectToCss(node);
+
+ Assert.Equal("radial-gradient(#fff, #000)", css);
+ }
+
+ [Fact]
+ public void Gradient_WithFewerThanTwoColors_Throws()
+ {
+ var node = ParseMapping("""
+ gradient: linear
+ colors: ["#fff"]
+ """);
+
+ Assert.Throws(() => ShapeParsers.ConvertGradientObjectToCss(node));
+ }
+
+ [Fact]
+ public void Gradient_WithUnknownType_Throws()
+ {
+ var node = ParseMapping("""
+ gradient: conic
+ colors: ["#fff", "#000"]
+ """);
+
+ Assert.Throws(() => ShapeParsers.ConvertGradientObjectToCss(node));
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~GradientObjectParseTests"`
+Expected: BUILD FAILURE — `ShapeParsers.ConvertGradientObjectToCss` not defined.
+
+- [ ] **Step 3: Implement ShapeParsers with the gradient converter**
+
+Create `src/FlexRender.Yaml/Parsing/ShapeParsers.cs`:
+
+```csharp
+using System.Globalization;
+using System.Text;
+using FlexRender.Parsing.Ast;
+using YamlDotNet.RepresentationModel;
+using static FlexRender.Parsing.YamlPropertyHelpers;
+
+namespace FlexRender.Parsing;
+
+///
+/// Parsers for shape elements (rect, circle, ellipse, draw), their fill gradients,
+/// and the absolute-coordinate draw shapes.
+///
+internal static class ShapeParsers
+{
+ ///
+ /// Converts a YAML gradient object mapping into FlexRender's CSS-style gradient string
+ /// so the existing gradient renderer can consume it.
+ ///
+ /// The gradient mapping (keys: gradient, colors, angle).
+ /// A "linear-gradient(...)" or "radial-gradient(...)" string.
+ /// Thrown on unknown type or fewer than two colors.
+ internal static string ConvertGradientObjectToCss(YamlMappingNode node)
+ {
+ var type = (GetStringValue(node, "gradient") ?? "linear").Trim().ToLowerInvariant();
+
+ if (!TryGetSequence(node, "colors", out var colorsNode))
+ {
+ throw new TemplateParseException("Gradient fill requires a 'colors' list.");
+ }
+
+ var colors = new List(colorsNode.Children.Count);
+ foreach (var child in colorsNode.Children)
+ {
+ if (child is YamlScalarNode scalar && !string.IsNullOrWhiteSpace(scalar.Value))
+ {
+ colors.Add(scalar.Value.Trim());
+ }
+ }
+
+ if (colors.Count < 2)
+ {
+ throw new TemplateParseException("Gradient fill requires at least two colors.");
+ }
+
+ var joinedColors = string.Join(", ", colors);
+
+ switch (type)
+ {
+ case "linear":
+ var angle = GetFloatValue(node, "angle", 0f);
+ var angleStr = angle.ToString(CultureInfo.InvariantCulture);
+ return $"linear-gradient({angleStr}deg, {joinedColors})";
+
+ case "radial":
+ return $"radial-gradient({joinedColors})";
+
+ default:
+ throw new TemplateParseException(
+ $"Unknown gradient type '{type}'. Expected 'linear' or 'radial'.");
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~GradientObjectParseTests"`
+Expected: PASS (5 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Yaml/Parsing/ShapeParsers.cs tests/FlexRender.Tests/Parsing/GradientObjectParseTests.cs
+git commit -m "feat(parser): convert gradient object form to css gradient string"
+```
+
+---
+
+## Task 11: Parse box shapes (rect/circle/ellipse) + register in parser/KnownProperties
+
+**Files:**
+- Modify: `src/FlexRender.Yaml/Parsing/ShapeParsers.cs`
+- Modify: `src/FlexRender.Yaml/Parsing/TemplateParser.cs`
+- Modify: `src/FlexRender.Yaml/Parsing/KnownProperties.cs`
+- Test: `tests/FlexRender.Tests/Parsing/ShapeParserTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Parsing/ShapeParserTests.cs`:
+
+```csharp
+using FlexRender.Parsing;
+using FlexRender.Parsing.Ast;
+using Xunit;
+
+namespace FlexRender.Tests.Parsing;
+
+///
+/// Tests for parsing rect/circle/ellipse shape elements from YAML.
+///
+public sealed class ShapeParserTests
+{
+ private readonly TemplateParser _parser = new();
+
+ [Fact]
+ public void Parse_Rect_SolidFillStrokeRadius()
+ {
+ var yaml = """
+ canvas:
+ width: 300
+ layout:
+ - type: rect
+ width: 100
+ height: 50
+ fill: "#4A90D9"
+ stroke: "#333333"
+ stroke-width: 2
+ radius: 4
+ """;
+
+ var template = _parser.Parse(yaml);
+ var rect = Assert.IsType(template.Elements[0]);
+
+ Assert.Equal("#4A90D9", rect.Fill.Value);
+ Assert.Equal("#333333", rect.Stroke.Value);
+ Assert.Equal(2f, rect.StrokeWidth.Value);
+ Assert.Equal("4", rect.Radius.Value);
+ Assert.Equal("100", rect.Width.Value);
+ Assert.Equal("50", rect.Height.Value);
+ }
+
+ [Fact]
+ public void Parse_Rect_GradientObjectFill_ConvertsToCss()
+ {
+ var yaml = """
+ canvas:
+ width: 300
+ layout:
+ - type: rect
+ width: 100
+ height: 100
+ fill:
+ gradient: linear
+ colors: ["#f00", "#00f"]
+ angle: 45
+ """;
+
+ var template = _parser.Parse(yaml);
+ var rect = Assert.IsType(template.Elements[0]);
+
+ Assert.Equal("linear-gradient(45deg, #f00, #00f)", rect.Fill.Value);
+ }
+
+ [Fact]
+ public void Parse_Circle_SizeShorthand_SetsWidthAndHeight()
+ {
+ var yaml = """
+ canvas:
+ width: 300
+ layout:
+ - type: circle
+ size: 40
+ fill: "#e74c3c"
+ """;
+
+ var template = _parser.Parse(yaml);
+ var circle = Assert.IsType(template.Elements[0]);
+
+ Assert.Equal("40", circle.Width.Value);
+ Assert.Equal("40", circle.Height.Value);
+ Assert.Equal("#e74c3c", circle.Fill.Value);
+ }
+
+ [Fact]
+ public void Parse_Ellipse_WidthHeightFill()
+ {
+ var yaml = """
+ canvas:
+ width: 300
+ layout:
+ - type: ellipse
+ width: 120
+ height: 60
+ fill: "#2ecc71"
+ """;
+
+ var template = _parser.Parse(yaml);
+ var ellipse = Assert.IsType(template.Elements[0]);
+
+ Assert.Equal("120", ellipse.Width.Value);
+ Assert.Equal("60", ellipse.Height.Value);
+ Assert.Equal("#2ecc71", ellipse.Fill.Value);
+ }
+
+ [Fact]
+ public void Parse_Rect_UnknownProperty_SuggestsCorrection()
+ {
+ var yaml = """
+ canvas:
+ width: 300
+ layout:
+ - type: rect
+ fil: "#fff"
+ """;
+
+ var ex = Assert.Throws(() => _parser.Parse(yaml));
+ Assert.Contains("'fil'", ex.Message);
+ Assert.Contains("fill", ex.Message);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~ShapeParserTests"`
+Expected: FAIL — `Unknown element type: 'rect'` (not yet registered).
+
+- [ ] **Step 3a: Add box-shape parsers to ShapeParsers**
+
+Append these methods inside the `ShapeParsers` class in `src/FlexRender.Yaml/Parsing/ShapeParsers.cs`
+(after `ConvertGradientObjectToCss`):
+
+```csharp
+ ///
+ /// Reads the 'fill' property which may be a solid color string, a gradient string,
+ /// or a gradient object mapping. Returns an string (CSS for gradients).
+ ///
+ private static ExprValue ParseFill(YamlMappingNode node)
+ {
+ var fillKey = new YamlScalarNode("fill");
+ if (!node.Children.TryGetValue(fillKey, out var fillNode))
+ {
+ return default;
+ }
+
+ switch (fillNode)
+ {
+ case YamlScalarNode scalar when scalar.Value is not null:
+ return ContainsExpression(scalar.Value)
+ ? ExprValue.Expression(scalar.Value)
+ : scalar.Value;
+
+ case YamlMappingNode mapping:
+ return ConvertGradientObjectToCss(mapping);
+
+ default:
+ return default;
+ }
+ }
+
+ ///
+ /// Parses a 'rect' shape element.
+ ///
+ /// The YAML mapping for the element.
+ /// The parsed .
+ internal static TemplateElement ParseRectElement(YamlMappingNode node)
+ {
+ var rect = new RectElement
+ {
+ Fill = ParseFill(node),
+ Stroke = GetExprStringValueOptional(node, "stroke"),
+ StrokeWidth = GetExprFloatValue(node, "stroke-width", 0f),
+ Radius = GetExprStringValueOptional(node, "radius"),
+ Rotate = GetExprStringValue(node, "rotate", "none"),
+ Background = GetStringValue(node, "background")!,
+ Padding = GetExprStringValue(node, "padding", "0"),
+ Margin = GetExprStringValue(node, "margin", "0")
+ };
+
+ ElementParsers.ApplyFlexItemProperties(node, rect);
+ return rect;
+ }
+
+ ///
+ /// Parses a 'circle' shape element. The 'size' shorthand sets both width and height.
+ ///
+ /// The YAML mapping for the element.
+ /// The parsed .
+ internal static TemplateElement ParseCircleElement(YamlMappingNode node)
+ {
+ var circle = new CircleElement
+ {
+ Fill = ParseFill(node),
+ Stroke = GetExprStringValueOptional(node, "stroke"),
+ StrokeWidth = GetExprFloatValue(node, "stroke-width", 0f),
+ Rotate = GetExprStringValue(node, "rotate", "none"),
+ Background = GetStringValue(node, "background")!,
+ Padding = GetExprStringValue(node, "padding", "0"),
+ Margin = GetExprStringValue(node, "margin", "0")
+ };
+
+ ElementParsers.ApplyFlexItemProperties(node, circle);
+
+ // 'size' shorthand: applied after ApplyFlexItemProperties so it overrides width/height.
+ var size = GetStringValue(node, "size");
+ if (size is not null)
+ {
+ circle.Width = size;
+ circle.Height = size;
+ }
+
+ return circle;
+ }
+
+ ///
+ /// Parses an 'ellipse' shape element.
+ ///
+ /// The YAML mapping for the element.
+ /// The parsed .
+ internal static TemplateElement ParseEllipseElement(YamlMappingNode node)
+ {
+ var ellipse = new EllipseElement
+ {
+ Fill = ParseFill(node),
+ Stroke = GetExprStringValueOptional(node, "stroke"),
+ StrokeWidth = GetExprFloatValue(node, "stroke-width", 0f),
+ Rotate = GetExprStringValue(node, "rotate", "none"),
+ Background = GetStringValue(node, "background")!,
+ Padding = GetExprStringValue(node, "padding", "0"),
+ Margin = GetExprStringValue(node, "margin", "0")
+ };
+
+ ElementParsers.ApplyFlexItemProperties(node, ellipse);
+ return ellipse;
+ }
+```
+
+Note: `ApplyFlexItemProperties` is `internal static` on `ElementParsers` — confirm it is callable
+from `ShapeParsers` (same assembly, same namespace `FlexRender.Parsing`). It is.
+
+- [ ] **Step 3b: Register the parsers in TemplateParser**
+
+In `src/FlexRender.Yaml/Parsing/TemplateParser.cs`, add these entries to the `_elementParsers`
+dictionary initializer (after `["content"] = ElementParsers.ParseContentElement`):
+
+```csharp
+ ["rect"] = ShapeParsers.ParseRectElement,
+ ["circle"] = ShapeParsers.ParseCircleElement,
+ ["ellipse"] = ShapeParsers.ParseEllipseElement,
+```
+
+(The `draw` entry is added in Task 12.)
+
+- [ ] **Step 3c: Register known properties**
+
+In `src/FlexRender.Yaml/Parsing/KnownProperties.cs`, add three property sets after the `Content`
+set:
+
+```csharp
+ ///
+ /// Known properties for the 'rect' element type.
+ ///
+ internal static readonly HashSet Rect = BuildSet(FlexItemProperties,
+ [
+ "fill", "stroke", "stroke-width", "radius",
+ "background", "rotate", "padding", "margin"
+ ]);
+
+ ///
+ /// Known properties for the 'circle' element type.
+ ///
+ internal static readonly HashSet Circle = BuildSet(FlexItemProperties,
+ [
+ "fill", "stroke", "stroke-width", "size",
+ "background", "rotate", "padding", "margin"
+ ]);
+
+ ///
+ /// Known properties for the 'ellipse' element type.
+ ///
+ internal static readonly HashSet Ellipse = BuildSet(FlexItemProperties,
+ [
+ "fill", "stroke", "stroke-width",
+ "background", "rotate", "padding", "margin"
+ ]);
+```
+
+Then add registry entries to the `Registry` dictionary (after `["content"] = Content`):
+
+```csharp
+ ["rect"] = Rect,
+ ["circle"] = Circle,
+ ["ellipse"] = Ellipse,
+```
+
+(The `draw` set + registry entry is added in Task 12.)
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~ShapeParserTests"`
+Expected: PASS (5 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Yaml/Parsing/ShapeParsers.cs src/FlexRender.Yaml/Parsing/TemplateParser.cs src/FlexRender.Yaml/Parsing/KnownProperties.cs tests/FlexRender.Tests/Parsing/ShapeParserTests.cs
+git commit -m "feat(parser): parse rect, circle, ellipse shapes with gradient fill"
+```
+
+---
+
+## Task 12: Parse the draw element + shapes + MaxShapesPerDraw enforcement
+
+**Files:**
+- Modify: `src/FlexRender.Yaml/Parsing/ShapeParsers.cs`
+- Modify: `src/FlexRender.Yaml/Parsing/TemplateParser.cs`
+- Modify: `src/FlexRender.Yaml/Parsing/KnownProperties.cs`
+- Test: `tests/FlexRender.Tests/Parsing/DrawParserTests.cs`
+
+The `draw` parser needs the resource limit. `TemplateParser` already holds a `ResourceLimits _limits`
+field. Register `draw` via an instance lambda that passes `_limits.MaxShapesPerDraw`.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Parsing/DrawParserTests.cs`:
+
+```csharp
+using FlexRender.Configuration;
+using FlexRender.Parsing;
+using FlexRender.Parsing.Ast;
+using Xunit;
+
+namespace FlexRender.Tests.Parsing;
+
+///
+/// Tests for parsing the 'draw' element and its absolute-coordinate shapes.
+///
+public sealed class DrawParserTests
+{
+ private readonly TemplateParser _parser = new();
+
+ [Fact]
+ public void Parse_Draw_AllShapeKinds()
+ {
+ var yaml = """
+ canvas:
+ width: 400
+ layout:
+ - type: draw
+ width: 400
+ height: 200
+ shapes:
+ - line: {x1: 0, y1: 100, x2: 400, y2: 50, stroke: "#333", stroke-width: 2}
+ - polyline: {points: [[0, 10], [50, 40], [100, 20]], stroke: "#4A90D9"}
+ - rect: {x: 10, y: 10, width: 80, height: 40, fill: "#eee", radius: 4}
+ - circle: {cx: 200, cy: 75, r: 30, fill: "#e74c3c"}
+ - path: {d: "M 0 0 L 100 50 Q 150 0 200 50 Z", fill: "#2ecc71"}
+ """;
+
+ var template = _parser.Parse(yaml);
+ var draw = Assert.IsType(template.Elements[0]);
+
+ Assert.Equal(5, draw.Shapes.Count);
+
+ var line = Assert.IsType(draw.Shapes[0]);
+ Assert.Equal(0f, line.X1);
+ Assert.Equal(400f, line.X2);
+ Assert.Equal("#333", line.Stroke);
+ Assert.Equal(2f, line.StrokeWidth);
+
+ var polyline = Assert.IsType(draw.Shapes[1]);
+ Assert.Equal(3, polyline.Points.Count);
+ Assert.Equal(50f, polyline.Points[1].X);
+ Assert.Equal(40f, polyline.Points[1].Y);
+
+ var rect = Assert.IsType(draw.Shapes[2]);
+ Assert.Equal(80f, rect.Width);
+ Assert.Equal("#eee", rect.Fill);
+ Assert.Equal(4f, rect.Radius);
+
+ var circle = Assert.IsType(draw.Shapes[3]);
+ Assert.Equal(200f, circle.Cx);
+ Assert.Equal(30f, circle.R);
+
+ var path = Assert.IsType(draw.Shapes[4]);
+ Assert.Equal("#2ecc71", path.Fill);
+ Assert.True(path.Commands.Count >= 4);
+ }
+
+ [Fact]
+ public void Parse_Draw_NoShapes_ProducesEmptyList()
+ {
+ var yaml = """
+ canvas:
+ width: 400
+ layout:
+ - type: draw
+ width: 400
+ height: 200
+ """;
+
+ var template = _parser.Parse(yaml);
+ var draw = Assert.IsType(template.Elements[0]);
+ Assert.Empty(draw.Shapes);
+ }
+
+ [Fact]
+ public void Parse_Draw_MalformedPath_ThrowsWithCommand()
+ {
+ var yaml = """
+ canvas:
+ width: 400
+ layout:
+ - type: draw
+ width: 400
+ height: 200
+ shapes:
+ - path: {d: "M 0 0 X 1 1"}
+ """;
+
+ var ex = Assert.Throws(() => _parser.Parse(yaml));
+ Assert.Contains("'X'", ex.Message);
+ }
+
+ [Fact]
+ public void Parse_Draw_ExceedsShapeLimit_Throws()
+ {
+ var limits = new ResourceLimits { MaxShapesPerDraw = 2 };
+ var parser = new TemplateParser(limits);
+
+ var yaml = """
+ canvas:
+ width: 400
+ layout:
+ - type: draw
+ width: 400
+ height: 200
+ shapes:
+ - line: {x1: 0, y1: 0, x2: 1, y2: 1}
+ - line: {x1: 0, y1: 0, x2: 1, y2: 1}
+ - line: {x1: 0, y1: 0, x2: 1, y2: 1}
+ """;
+
+ var ex = Assert.Throws(() => parser.Parse(yaml));
+ Assert.Contains("shapes", ex.Message);
+ }
+
+ [Fact]
+ public void Parse_Draw_UnknownProperty_Throws()
+ {
+ var yaml = """
+ canvas:
+ width: 400
+ layout:
+ - type: draw
+ width: 400
+ shaps: []
+ """;
+
+ Assert.Throws(() => _parser.Parse(yaml));
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~DrawParserTests"`
+Expected: FAIL — `Unknown element type: 'draw'`.
+
+- [ ] **Step 3a: Add the draw parser to ShapeParsers**
+
+Append these methods inside `ShapeParsers` in `src/FlexRender.Yaml/Parsing/ShapeParsers.cs`:
+
+```csharp
+ ///
+ /// Parses a 'draw' element and its ordered shape list.
+ ///
+ /// The YAML mapping for the element.
+ /// Maximum shapes allowed (resource limit).
+ /// The parsed .
+ /// Thrown on malformed shapes or shape-count overflow.
+ internal static TemplateElement ParseDrawElement(YamlMappingNode node, int maxShapes)
+ {
+ var shapes = new List();
+
+ if (TryGetSequence(node, "shapes", out var shapesNode))
+ {
+ if (shapesNode.Children.Count > maxShapes)
+ {
+ throw new TemplateParseException(
+ $"Draw element has {shapesNode.Children.Count} shapes, exceeding the maximum of {maxShapes}.");
+ }
+
+ foreach (var item in shapesNode.Children)
+ {
+ if (item is YamlMappingNode shapeMapping)
+ {
+ shapes.Add(ParseDrawShape(shapeMapping));
+ }
+ }
+ }
+
+ var draw = new DrawElement(shapes)
+ {
+ Rotate = GetExprStringValue(node, "rotate", "none"),
+ Background = GetStringValue(node, "background")!,
+ Padding = GetExprStringValue(node, "padding", "0"),
+ Margin = GetExprStringValue(node, "margin", "0")
+ };
+
+ ElementParsers.ApplyFlexItemProperties(node, draw);
+ return draw;
+ }
+
+ ///
+ /// Parses a single shape mapping (one of line/polyline/rect/circle/path).
+ ///
+ private static DrawShape ParseDrawShape(YamlMappingNode shapeMapping)
+ {
+ if (TryGetMapping(shapeMapping, "line", out var lineNode))
+ return ParseDrawLine(lineNode);
+ if (TryGetMapping(shapeMapping, "polyline", out var polylineNode))
+ return ParseDrawPolyline(polylineNode);
+ if (TryGetMapping(shapeMapping, "rect", out var rectNode))
+ return ParseDrawRect(rectNode);
+ if (TryGetMapping(shapeMapping, "circle", out var circleNode))
+ return ParseDrawCircle(circleNode);
+ if (TryGetMapping(shapeMapping, "path", out var pathNode))
+ return ParseDrawPath(pathNode);
+
+ throw new TemplateParseException(
+ "Each draw shape must be one of: line, polyline, rect, circle, path.");
+ }
+
+ private static DrawShape ParseDrawLine(YamlMappingNode node) => new DrawLine(
+ GetFloatValue(node, "x1", 0f),
+ GetFloatValue(node, "y1", 0f),
+ GetFloatValue(node, "x2", 0f),
+ GetFloatValue(node, "y2", 0f),
+ GetStringValue(node, "stroke"),
+ GetFloatValue(node, "stroke-width", 1f));
+
+ private static DrawShape ParseDrawPolyline(YamlMappingNode node)
+ {
+ var points = ParsePoints(node);
+ return new DrawPolyline(
+ points,
+ GetStringValue(node, "stroke"),
+ GetFloatValue(node, "stroke-width", 1f),
+ GetStringValue(node, "fill"));
+ }
+
+ private static DrawShape ParseDrawRect(YamlMappingNode node) => new DrawRect(
+ GetFloatValue(node, "x", 0f),
+ GetFloatValue(node, "y", 0f),
+ GetFloatValue(node, "width", 0f),
+ GetFloatValue(node, "height", 0f),
+ GetStringValue(node, "fill"),
+ GetStringValue(node, "stroke"),
+ GetFloatValue(node, "stroke-width", 0f),
+ GetFloatValue(node, "radius", 0f));
+
+ private static DrawShape ParseDrawCircle(YamlMappingNode node) => new DrawCircle(
+ GetFloatValue(node, "cx", 0f),
+ GetFloatValue(node, "cy", 0f),
+ GetFloatValue(node, "r", 0f),
+ GetStringValue(node, "fill"),
+ GetStringValue(node, "stroke"),
+ GetFloatValue(node, "stroke-width", 0f));
+
+ private static DrawShape ParseDrawPath(YamlMappingNode node)
+ {
+ var d = GetStringValue(node, "d") ?? string.Empty;
+ IReadOnlyList commands;
+ try
+ {
+ commands = PathDataParser.Parse(d);
+ }
+ catch (PathParseException ex)
+ {
+ throw new TemplateParseException($"Invalid path data: {ex.Message}", ex);
+ }
+
+ return new DrawPath(
+ commands,
+ GetStringValue(node, "fill"),
+ GetStringValue(node, "stroke"),
+ GetFloatValue(node, "stroke-width", 0f));
+ }
+
+ ///
+ /// Parses a 'points' sequence of [x, y] pairs.
+ ///
+ private static List ParsePoints(YamlMappingNode node)
+ {
+ var points = new List();
+ if (!TryGetSequence(node, "points", out var pointsNode))
+ return points;
+
+ foreach (var item in pointsNode.Children)
+ {
+ if (item is YamlSequenceNode pair && pair.Children.Count >= 2 &&
+ pair.Children[0] is YamlScalarNode xs && pair.Children[1] is YamlScalarNode ys &&
+ float.TryParse(xs.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var x) &&
+ float.TryParse(ys.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var y))
+ {
+ points.Add(new PathPoint(x, y));
+ }
+ }
+ return points;
+ }
+```
+
+Note: `GetFloatValue` and `GetStringValue(node, key)` (the nullable overload) come from
+`YamlPropertyHelpers` (already `using static`). `PathPoint`, `PathCommand`, `PathParseException`,
+`PathDataParser` are in `FlexRender.Parsing` (same namespace). The `using` directives at the top
+of the file already include `System.Globalization` and `FlexRender.Parsing.Ast`.
+
+- [ ] **Step 3b: Register the draw parser**
+
+In `src/FlexRender.Yaml/Parsing/TemplateParser.cs`, add to the `_elementParsers` initializer
+(after the `ellipse` entry from Task 11):
+
+```csharp
+ ["draw"] = node => ShapeParsers.ParseDrawElement(node, _limits.MaxShapesPerDraw),
+```
+
+- [ ] **Step 3c: Register draw known properties**
+
+In `src/FlexRender.Yaml/Parsing/KnownProperties.cs`, add the set (after `Ellipse`):
+
+```csharp
+ ///
+ /// Known properties for the 'draw' element type.
+ ///
+ internal static readonly HashSet Draw = BuildSet(FlexItemProperties,
+ [
+ "shapes",
+ "background", "rotate", "padding", "margin"
+ ]);
+```
+
+And the registry entry (after `["ellipse"] = Ellipse`):
+
+```csharp
+ ["draw"] = Draw,
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~DrawParserTests"`
+Expected: PASS (5 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Yaml/Parsing/ShapeParsers.cs src/FlexRender.Yaml/Parsing/TemplateParser.cs src/FlexRender.Yaml/Parsing/KnownProperties.cs tests/FlexRender.Tests/Parsing/DrawParserTests.cs
+git commit -m "feat(parser): parse draw element with absolute shapes and shape limit"
+```
+
+---
+
+## Task 13: Skia rendering — box shapes (rect/circle/ellipse)
+
+**Files:**
+- Create: `src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs`
+- Modify: `src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs`
+- Test: snapshot in Task 15 (rendering wired here; visual verification follows)
+
+This task wires drawing into the existing dispatch. We verify with a non-snapshot unit test that
+the shape elements no longer fall through to the default branch (i.e., a render of a rect does not
+throw and produces non-background pixels). Snapshot golden images come in Task 15.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Rendering/ShapeRenderSmokeTests.cs`:
+
+```csharp
+using FlexRender.Configuration;
+using FlexRender.Parsing;
+using FlexRender.Rendering;
+using FlexRender.TemplateEngine;
+using SkiaSharp;
+using Xunit;
+
+namespace FlexRender.Tests.Rendering;
+
+///
+/// Smoke tests confirming shape elements render visible (non-background) pixels.
+///
+public sealed class ShapeRenderSmokeTests
+{
+ [Fact]
+ public async Task Rect_RendersFilledPixels()
+ {
+ var yaml = """
+ canvas:
+ width: 120
+ height: 80
+ background: "#ffffff"
+ layout:
+ - type: rect
+ width: 100
+ height: 50
+ fill: "#ff0000"
+ margin: "10"
+ """;
+
+ var parser = new TemplateParser();
+ var template = parser.Parse(yaml);
+
+ using var renderer = new SkiaRenderer(new ResourceLimits(), deterministicRendering: true);
+ var size = await renderer.MeasureAsync(template, new ObjectValue());
+ using var bitmap = new SKBitmap((int)System.Math.Ceiling(size.Width), (int)System.Math.Ceiling(size.Height), SKColorType.Rgba8888, SKAlphaType.Premul);
+ await renderer.Render(bitmap, template, new ObjectValue(), default, default);
+
+ // Center of the rect should be red, not white.
+ var center = bitmap.GetPixel(bitmap.Width / 2, bitmap.Height / 2);
+ Assert.True(center.Red > 200 && center.Green < 80 && center.Blue < 80,
+ $"Expected red center pixel, got {center}.");
+ }
+}
+```
+
+Note: Verify the exact `SkiaRenderer` constructor used by tests. `SnapshotTestBase` constructs it
+as `new SkiaRenderer(new ResourceLimits(), new QrProvider(), new BarcodeProvider(), imageLoader: null, deterministicRendering: true)`. If a parameterless-limits-only `SkiaRenderer(ResourceLimits, deterministicRendering:)` overload does not exist, use the same constructor form as `SnapshotTestBase` (pass `new QrProvider(), new BarcodeProvider(), imageLoader: null`).
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~ShapeRenderSmokeTests"`
+Expected: FAIL — rect falls through the renderer's default switch arm and draws nothing; center pixel stays white.
+
+- [ ] **Step 3a: Implement ShapeRenderer (box shapes)**
+
+Create `src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs`:
+
+```csharp
+using FlexRender.Parsing.Ast;
+using SkiaSharp;
+
+namespace FlexRender.Rendering;
+
+///
+/// Draws shape elements (rect, circle, ellipse) and the draw element's absolute shapes
+/// onto an .
+///
+internal static class ShapeRenderer
+{
+ ///
+ /// Draws a rectangle shape (fill and/or stroke, optional rounded corners).
+ ///
+ /// The target canvas.
+ /// The rectangle element.
+ /// Box X.
+ /// Box Y.
+ /// Box width.
+ /// Box height.
+ /// Effective font size for em-relative radius.
+ /// Whether to antialias.
+ internal static void DrawRect(SKCanvas canvas, RectElement rect, float x, float y, float width, float height, float fontSize, bool antialias)
+ {
+ var radius = ResolveRadius(rect.Radius.Value, fontSize);
+
+ if (!string.IsNullOrEmpty(rect.Fill.Value))
+ {
+ using var fillPaint = CreateFillPaint(rect.Fill.Value, x, y, width, height, antialias);
+ if (fillPaint is not null)
+ {
+ DrawRoundOrSharpRect(canvas, x, y, width, height, radius, fillPaint);
+ }
+ }
+
+ if (HasStroke(rect.Stroke.Value, rect.StrokeWidth.Value))
+ {
+ using var strokePaint = CreateStrokePaint(rect.Stroke.Value!, rect.StrokeWidth.Value, antialias);
+ DrawRoundOrSharpRect(canvas, x, y, width, height, radius, strokePaint);
+ }
+ }
+
+ ///
+ /// Draws a circle shape inscribed in the box (diameter = min(width, height)).
+ ///
+ internal static void DrawCircle(SKCanvas canvas, CircleElement circle, float x, float y, float width, float height, bool antialias)
+ {
+ var diameter = System.Math.Min(width, height);
+ var r = diameter / 2f;
+ var cx = x + width / 2f;
+ var cy = y + height / 2f;
+
+ if (!string.IsNullOrEmpty(circle.Fill.Value))
+ {
+ using var fillPaint = CreateFillPaint(circle.Fill.Value, x, y, width, height, antialias);
+ if (fillPaint is not null)
+ canvas.DrawCircle(cx, cy, r, fillPaint);
+ }
+
+ if (HasStroke(circle.Stroke.Value, circle.StrokeWidth.Value))
+ {
+ using var strokePaint = CreateStrokePaint(circle.Stroke.Value!, circle.StrokeWidth.Value, antialias);
+ canvas.DrawCircle(cx, cy, r, strokePaint);
+ }
+ }
+
+ ///
+ /// Draws an ellipse shape inscribed in the box.
+ ///
+ internal static void DrawEllipse(SKCanvas canvas, EllipseElement ellipse, float x, float y, float width, float height, bool antialias)
+ {
+ var cx = x + width / 2f;
+ var cy = y + height / 2f;
+ var rx = width / 2f;
+ var ry = height / 2f;
+
+ if (!string.IsNullOrEmpty(ellipse.Fill.Value))
+ {
+ using var fillPaint = CreateFillPaint(ellipse.Fill.Value, x, y, width, height, antialias);
+ if (fillPaint is not null)
+ canvas.DrawOval(cx, cy, rx, ry, fillPaint);
+ }
+
+ if (HasStroke(ellipse.Stroke.Value, ellipse.StrokeWidth.Value))
+ {
+ using var strokePaint = CreateStrokePaint(ellipse.Stroke.Value!, ellipse.StrokeWidth.Value, antialias);
+ canvas.DrawOval(cx, cy, rx, ry, strokePaint);
+ }
+ }
+
+ private static void DrawRoundOrSharpRect(SKCanvas canvas, float x, float y, float width, float height, float radius, SKPaint paint)
+ {
+ if (radius > 0f)
+ canvas.DrawRoundRect(x, y, width, height, radius, radius, paint);
+ else
+ canvas.DrawRect(x, y, width, height, paint);
+ }
+
+ private static bool HasStroke(string? stroke, float strokeWidth)
+ => !string.IsNullOrEmpty(stroke) && strokeWidth > 0f;
+
+ private static float ResolveRadius(string? radius, float fontSize)
+ {
+ if (string.IsNullOrWhiteSpace(radius))
+ return 0f;
+
+ var unit = Layout.Units.UnitParser.Parse(radius);
+ return unit.Resolve(0f, fontSize) ?? 0f;
+ }
+
+ ///
+ /// Creates a fill paint that handles both solid colors and gradient strings.
+ /// Returns null when the fill value is empty.
+ ///
+ internal static SKPaint? CreateFillPaint(string? fill, float x, float y, float width, float height, bool antialias)
+ {
+ if (string.IsNullOrEmpty(fill))
+ return null;
+
+ if (GradientParser.IsGradient(fill) && GradientParser.TryParse(fill, out var gradient) && gradient is not null)
+ {
+ var shader = GradientParser.CreateShader(gradient, x, y, width, height);
+ if (shader is not null)
+ {
+ return new SKPaint { Shader = shader, Style = SKPaintStyle.Fill, IsAntialias = antialias };
+ }
+ }
+
+ return new SKPaint { Color = ColorParser.Parse(fill), Style = SKPaintStyle.Fill, IsAntialias = antialias };
+ }
+
+ ///
+ /// Creates a stroke paint with the given color and width.
+ ///
+ internal static SKPaint CreateStrokePaint(string stroke, float strokeWidth, bool antialias)
+ => new()
+ {
+ Color = ColorParser.Parse(stroke),
+ StrokeWidth = strokeWidth,
+ Style = SKPaintStyle.Stroke,
+ IsAntialias = antialias
+ };
+}
+```
+
+Note: `SKPaint.Shader` must be disposed with the paint. Disposing the `SKPaint` (via `using`) does
+not dispose the shader, so explicitly dispose the shader after drawing. To keep the API simple, the
+caller uses `using var fillPaint = ...`; the shader leak is avoided by wrapping shader disposal.
+Revise `CreateFillPaint`'s gradient branch to attach the shader and document that the caller must
+dispose. Implement instead with a paint that owns disposal by using `SKPaint` + manual shader dispose
+at call sites is error-prone — so the simplest correct approach: in `CreateFillPaint`, after creating
+the shader, set it on the paint and store the shader in the paint only; then at each call site, after
+`canvas.DrawX(...)`, call `fillPaint.Shader?.Dispose()` is NOT available. Therefore: keep the shader
+local. To do this cleanly, change `CreateFillPaint` gradient branch to:
+
+```csharp
+ if (GradientParser.IsGradient(fill) && GradientParser.TryParse(fill, out var gradient) && gradient is not null)
+ {
+ var shader = GradientParser.CreateShader(gradient, x, y, width, height);
+ if (shader is not null)
+ {
+ var paint = new SKPaint { Style = SKPaintStyle.Fill, IsAntialias = antialias };
+ paint.Shader = shader; // paint takes a reference; dispose shader after the paint
+ return paint;
+ }
+ }
+```
+
+And at each box-shape fill call site, after drawing, dispose the shader explicitly:
+
+```csharp
+ using var fillPaint = CreateFillPaint(rect.Fill.Value, x, y, width, height, antialias);
+ if (fillPaint is not null)
+ {
+ DrawRoundOrSharpRect(canvas, x, y, width, height, radius, fillPaint);
+ fillPaint.Shader?.Dispose();
+ }
+```
+
+Apply the same `fillPaint.Shader?.Dispose();` after `canvas.DrawCircle(...)` and
+`canvas.DrawOval(...)` in `DrawCircle`/`DrawEllipse`. (For solid-color paints `Shader` is null, so
+the call is a safe no-op.)
+
+- [ ] **Step 3b: Dispatch the box shapes in RenderingEngine**
+
+In `src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs`, in `DrawElement`'s `switch (element)`,
+add cases before the `case SeparatorElement separator:` arm:
+
+```csharp
+ case RectElement rect:
+ ShapeRenderer.DrawRect(canvas, rect, x, y, width, height, effectiveFontSize, renderOptions.Antialiasing);
+ break;
+
+ case CircleElement circle:
+ ShapeRenderer.DrawCircle(canvas, circle, x, y, width, height, renderOptions.Antialiasing);
+ break;
+
+ case EllipseElement ellipse:
+ ShapeRenderer.DrawEllipse(canvas, ellipse, x, y, width, height, renderOptions.Antialiasing);
+ break;
+```
+
+(The `DrawElement` case is added in Task 14.) Confirm `effectiveFontSize` and
+`renderOptions.Antialiasing` are in scope in `DrawElement` — they are (see the existing
+`SeparatorElement` arm and `effectiveFontSize` local at the top of `DrawElement`).
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~ShapeRenderSmokeTests"`
+Expected: PASS (1 test).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs tests/FlexRender.Tests/Rendering/ShapeRenderSmokeTests.cs
+git commit -m "feat(renderer): draw rect, circle, ellipse shapes via skia"
+```
+
+---
+
+## Task 14: Skia rendering — draw element shapes
+
+**Files:**
+- Modify: `src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs`
+- Modify: `src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs`
+- Test: `tests/FlexRender.Tests/Rendering/DrawRenderSmokeTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Rendering/DrawRenderSmokeTests.cs`:
+
+```csharp
+using FlexRender.Configuration;
+using FlexRender.Parsing;
+using FlexRender.Rendering;
+using FlexRender.TemplateEngine;
+using SkiaSharp;
+using Xunit;
+
+namespace FlexRender.Tests.Rendering;
+
+///
+/// Smoke tests confirming the draw element paints its shapes.
+///
+public sealed class DrawRenderSmokeTests
+{
+ [Fact]
+ public async Task Draw_FilledCircle_RendersColoredPixels()
+ {
+ var yaml = """
+ canvas:
+ width: 200
+ height: 150
+ background: "#ffffff"
+ layout:
+ - type: draw
+ width: 200
+ height: 150
+ shapes:
+ - circle: {cx: 100, cy: 75, r: 40, fill: "#0000ff"}
+ """;
+
+ var parser = new TemplateParser();
+ var template = parser.Parse(yaml);
+
+ using var renderer = new SkiaRenderer(new ResourceLimits(), deterministicRendering: true);
+ var size = await renderer.MeasureAsync(template, new ObjectValue());
+ using var bitmap = new SKBitmap((int)System.Math.Ceiling(size.Width), (int)System.Math.Ceiling(size.Height), SKColorType.Rgba8888, SKAlphaType.Premul);
+ await renderer.Render(bitmap, template, new ObjectValue(), default, default);
+
+ var center = bitmap.GetPixel(100, 75);
+ Assert.True(center.Blue > 200 && center.Red < 80 && center.Green < 80,
+ $"Expected blue center pixel, got {center}.");
+ }
+}
+```
+
+Note: same `SkiaRenderer` constructor caveat as Task 13.
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~DrawRenderSmokeTests"`
+Expected: FAIL — `DrawElement` falls through; center stays white.
+
+- [ ] **Step 3a: Add DrawShapes rendering to ShapeRenderer**
+
+Append to `src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs` (inside the `ShapeRenderer` class).
+Add `using FlexRender.Parsing;` at the top of the file (for `PathCommand`/`PathCommandKind`):
+
+```csharp
+ ///
+ /// Draws all shapes of a draw element, clipped to the element box and offset to (x, y).
+ ///
+ /// The target canvas.
+ /// The draw element.
+ /// Box X (origin for absolute shape coordinates).
+ /// Box Y.
+ /// Box width.
+ /// Box height.
+ /// Whether to antialias.
+ internal static void DrawShapes(SKCanvas canvas, DrawElement draw, float x, float y, float width, float height, bool antialias)
+ {
+ canvas.Save();
+ canvas.ClipRect(new SKRect(x, y, x + width, y + height));
+ canvas.Translate(x, y);
+
+ foreach (var shape in draw.Shapes)
+ {
+ switch (shape)
+ {
+ case DrawLine line:
+ DrawShapeLine(canvas, line, antialias);
+ break;
+ case DrawPolyline polyline:
+ DrawShapePolyline(canvas, polyline, antialias);
+ break;
+ case DrawRect rect:
+ DrawShapeRect(canvas, rect, antialias);
+ break;
+ case DrawCircle circle:
+ DrawShapeCircle(canvas, circle, antialias);
+ break;
+ case DrawPath path:
+ DrawShapePath(canvas, path, antialias);
+ break;
+ }
+ }
+
+ canvas.Restore();
+ }
+
+ private static void DrawShapeLine(SKCanvas canvas, DrawLine line, bool antialias)
+ {
+ using var paint = CreateStrokePaint(line.Stroke ?? "#000000", line.StrokeWidth, antialias);
+ canvas.DrawLine(line.X1, line.Y1, line.X2, line.Y2, paint);
+ }
+
+ private static void DrawShapePolyline(SKCanvas canvas, DrawPolyline polyline, bool antialias)
+ {
+ if (polyline.Points.Count < 2)
+ return;
+
+ using var path = new SKPath();
+ path.MoveTo(polyline.Points[0].X, polyline.Points[0].Y);
+ for (var i = 1; i < polyline.Points.Count; i++)
+ path.LineTo(polyline.Points[i].X, polyline.Points[i].Y);
+
+ if (!string.IsNullOrEmpty(polyline.Fill))
+ {
+ using var fillPaint = new SKPaint { Color = ColorParser.Parse(polyline.Fill), Style = SKPaintStyle.Fill, IsAntialias = antialias };
+ canvas.DrawPath(path, fillPaint);
+ }
+
+ using var strokePaint = CreateStrokePaint(polyline.Stroke ?? "#000000", polyline.StrokeWidth, antialias);
+ canvas.DrawPath(path, strokePaint);
+ }
+
+ private static void DrawShapeRect(SKCanvas canvas, DrawRect rect, bool antialias)
+ {
+ if (!string.IsNullOrEmpty(rect.Fill))
+ {
+ using var fillPaint = new SKPaint { Color = ColorParser.Parse(rect.Fill), Style = SKPaintStyle.Fill, IsAntialias = antialias };
+ DrawRoundOrSharpRect(canvas, rect.X, rect.Y, rect.Width, rect.Height, rect.Radius, fillPaint);
+ }
+
+ if (HasStroke(rect.Stroke, rect.StrokeWidth))
+ {
+ using var strokePaint = CreateStrokePaint(rect.Stroke!, rect.StrokeWidth, antialias);
+ DrawRoundOrSharpRect(canvas, rect.X, rect.Y, rect.Width, rect.Height, rect.Radius, strokePaint);
+ }
+ }
+
+ private static void DrawShapeCircle(SKCanvas canvas, DrawCircle circle, bool antialias)
+ {
+ if (!string.IsNullOrEmpty(circle.Fill))
+ {
+ using var fillPaint = new SKPaint { Color = ColorParser.Parse(circle.Fill), Style = SKPaintStyle.Fill, IsAntialias = antialias };
+ canvas.DrawCircle(circle.Cx, circle.Cy, circle.R, fillPaint);
+ }
+
+ if (HasStroke(circle.Stroke, circle.StrokeWidth))
+ {
+ using var strokePaint = CreateStrokePaint(circle.Stroke!, circle.StrokeWidth, antialias);
+ canvas.DrawCircle(circle.Cx, circle.Cy, circle.R, strokePaint);
+ }
+ }
+
+ private static void DrawShapePath(SKCanvas canvas, DrawPath drawPath, bool antialias)
+ {
+ using var path = new SKPath();
+ foreach (var command in drawPath.Commands)
+ {
+ switch (command.Kind)
+ {
+ case PathCommandKind.MoveTo:
+ path.MoveTo(command.Points[0].X, command.Points[0].Y);
+ break;
+ case PathCommandKind.LineTo:
+ path.LineTo(command.Points[0].X, command.Points[0].Y);
+ break;
+ case PathCommandKind.QuadTo:
+ path.QuadTo(command.Points[0].X, command.Points[0].Y, command.Points[1].X, command.Points[1].Y);
+ break;
+ case PathCommandKind.CubicTo:
+ path.CubicTo(
+ command.Points[0].X, command.Points[0].Y,
+ command.Points[1].X, command.Points[1].Y,
+ command.Points[2].X, command.Points[2].Y);
+ break;
+ case PathCommandKind.Close:
+ path.Close();
+ break;
+ }
+ }
+
+ if (!string.IsNullOrEmpty(drawPath.Fill))
+ {
+ using var fillPaint = new SKPaint { Color = ColorParser.Parse(drawPath.Fill), Style = SKPaintStyle.Fill, IsAntialias = antialias };
+ canvas.DrawPath(path, fillPaint);
+ }
+
+ if (HasStroke(drawPath.Stroke, drawPath.StrokeWidth))
+ {
+ using var strokePaint = CreateStrokePaint(drawPath.Stroke!, drawPath.StrokeWidth, antialias);
+ canvas.DrawPath(path, strokePaint);
+ }
+ }
+```
+
+Note: `HasStroke` and `CreateStrokePaint` already exist from Task 13. The `using FlexRender.Parsing.Ast;`
+is already at the top of the file from Task 13. Add `using FlexRender.Parsing;` for the path command types.
+
+- [ ] **Step 3b: Dispatch DrawElement in RenderingEngine**
+
+In `src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs`, in `DrawElement`'s `switch (element)`,
+add this case after the `EllipseElement` arm from Task 13:
+
+```csharp
+ case DrawElement drawEl:
+ ShapeRenderer.DrawShapes(canvas, drawEl, x, y, width, height, renderOptions.Antialiasing);
+ break;
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~DrawRenderSmokeTests"`
+Expected: PASS (1 test).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs tests/FlexRender.Tests/Rendering/DrawRenderSmokeTests.cs
+git commit -m "feat(renderer): draw line, polyline, rect, circle, path inside draw element"
+```
+
+---
+
+## Task 15: Snapshot tests (golden images)
+
+**Files:**
+- Create: `tests/FlexRender.Tests/Snapshots/ShapeSnapshotTests.cs`
+- Generated: `tests/FlexRender.Tests/Snapshots/golden/shapes_box_basic.png`,
+ `tests/FlexRender.Tests/Snapshots/golden/shapes_gradient.png`,
+ `tests/FlexRender.Tests/Snapshots/golden/draw_overlap.png`
+
+- [ ] **Step 1: Write the snapshot tests**
+
+Create `tests/FlexRender.Tests/Snapshots/ShapeSnapshotTests.cs`:
+
+```csharp
+using FlexRender.Parsing.Ast;
+using Xunit;
+
+namespace FlexRender.Tests.Snapshots;
+
+///
+/// Visual snapshot tests for shape primitives and the draw element.
+/// Run with UPDATE_SNAPSHOTS=true to regenerate golden images.
+///
+public sealed class ShapeSnapshotTests : SnapshotTestBase
+{
+ private static Template CreateTemplate(int width, int height)
+ => new()
+ {
+ Canvas = new CanvasSettings
+ {
+ Fixed = FixedDimension.Both,
+ Width = width,
+ Height = height,
+ Background = "#ffffff"
+ }
+ };
+
+ [Fact]
+ public async Task Shapes_BoxBasic_RectCircleEllipse()
+ {
+ var template = CreateTemplate(260, 90);
+
+ var yaml = """
+ canvas:
+ width: 260
+ height: 90
+ fixed: both
+ background: "#ffffff"
+ layout:
+ - type: flex
+ direction: row
+ gap: "10"
+ padding: "10"
+ align: center
+ children:
+ - type: rect
+ width: 70
+ height: 50
+ fill: "#4A90D9"
+ stroke: "#1f3a5f"
+ stroke-width: 2
+ radius: 6
+ - type: circle
+ size: 50
+ fill: "#e74c3c"
+ - type: ellipse
+ width: 80
+ height: 50
+ fill: "#2ecc71"
+ stroke: "#145a32"
+ stroke-width: 2
+ """;
+
+ var parsed = Parser.Parse(yaml);
+ await AssertSnapshot("shapes_box_basic", parsed, new ObjectValue());
+ }
+
+ [Fact]
+ public async Task Shapes_Gradient_LinearAndRadial()
+ {
+ var yaml = """
+ canvas:
+ width: 220
+ height: 110
+ fixed: both
+ background: "#ffffff"
+ layout:
+ - type: flex
+ direction: row
+ gap: "10"
+ padding: "10"
+ children:
+ - type: rect
+ width: 90
+ height: 90
+ fill:
+ gradient: linear
+ colors: ["#ff0000", "#0000ff"]
+ angle: 45
+ - type: circle
+ size: 90
+ fill:
+ gradient: radial
+ colors: ["#ffffff", "#222222"]
+ """;
+
+ var parsed = Parser.Parse(yaml);
+ await AssertSnapshot("shapes_gradient", parsed, new ObjectValue());
+ }
+
+ [Fact]
+ public async Task Draw_Overlap_PaintersOrder()
+ {
+ var yaml = """
+ canvas:
+ width: 200
+ height: 160
+ fixed: both
+ background: "#ffffff"
+ layout:
+ - type: draw
+ width: 200
+ height: 160
+ shapes:
+ - rect: {x: 20, y: 20, width: 120, height: 80, fill: "#cccccc", radius: 8}
+ - line: {x1: 0, y1: 80, x2: 200, y2: 40, stroke: "#333333", stroke-width: 3}
+ - polyline: {points: [[10, 140], [60, 110], [110, 130], [160, 100]], stroke: "#4A90D9", stroke-width: 2}
+ - circle: {cx: 130, cy: 70, r: 35, fill: "#e74c3c"}
+ - path: {d: "M 20 150 L 80 110 Q 120 90 160 120 Z", fill: "#2ecc71"}
+ """;
+
+ var parsed = Parser.Parse(yaml);
+ await AssertSnapshot("draw_overlap", parsed, new ObjectValue());
+ }
+}
+```
+
+Note: `CreateTemplate` is unused if all three tests parse YAML directly; remove it to avoid an
+unused-private-method warning (`TreatWarningsAsErrors=true`). Delete the `CreateTemplate` method
+from the file before building.
+
+- [ ] **Step 2: Run tests to verify they fail (no golden yet)**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~ShapeSnapshotTests"`
+Expected: FAIL — "Golden image not found" for all three tests (actual images written to `Snapshots/output/`).
+
+- [ ] **Step 3: Generate golden images**
+
+Run: `UPDATE_SNAPSHOTS=true dotnet test FlexRender.slnx --filter "FullyQualifiedName~ShapeSnapshotTests"`
+Expected: PASS (update mode writes goldens, asserts nothing). Three PNGs appear in
+`tests/FlexRender.Tests/Snapshots/golden/`.
+
+- [ ] **Step 4: Re-run without update to verify goldens match**
+
+Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~ShapeSnapshotTests"`
+Expected: PASS (3 tests).
+
+Manual check: open the three PNGs in `tests/FlexRender.Tests/Snapshots/golden/` and confirm the
+shapes look correct (rounded blue rect with border, red circle, green ellipse; gradients smooth;
+draw shapes overlap in list order).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add tests/FlexRender.Tests/Snapshots/ShapeSnapshotTests.cs tests/FlexRender.Tests/Snapshots/golden/shapes_box_basic.png tests/FlexRender.Tests/Snapshots/golden/shapes_gradient.png tests/FlexRender.Tests/Snapshots/golden/draw_overlap.png
+git commit -m "test(renderer): add shape and draw snapshot golden images"
+```
+
+---
+
+## Task 16: Full build + full test suite gate
+
+**Files:** none (verification only).
+
+- [ ] **Step 1: Build the whole solution**
+
+Run: `dotnet build FlexRender.slnx`
+Expected: BUILD SUCCEEDED, zero warnings (`TreatWarningsAsErrors=true`).
+
+- [ ] **Step 2: Run the entire test suite**
+
+Run: `dotnet test FlexRender.slnx`
+Expected: PASS — all pre-existing tests plus the new ones (1264+ → 1264+ ~ +40). No regressions.
+
+- [ ] **Step 3: If any pre-existing snapshot/test fails**
+
+Investigate; new element enum members or dispatch arms must not change existing rendering.
+Do not regenerate unrelated goldens. Fix the cause, re-run.
+
+- [ ] **Step 4: Commit (only if a fix was needed)**
+
+```bash
+git add -A
+git commit -m "fix: resolve regressions surfaced by full suite after shapes"
+```
+
+If no fix was needed, skip this commit.
+
+---
+
+## Task 17: Docs — llms.txt and llms-full.txt
+
+**Files:**
+- Modify: `llms.txt`
+- Modify: `llms-full.txt`
+
+These are documentation; no test. Mirror the style of the existing `separator`/`svg` entries.
+
+- [ ] **Step 1: Inspect existing element documentation**
+
+Run: `dotnet --version` (no-op placeholder). Then open `llms.txt` and locate the element-type list
+and the `separator` description. Open `llms-full.txt` and locate the per-element property reference
+(search for "separator").
+
+- [ ] **Step 2: Add shape entries to llms.txt**
+
+In `llms.txt`, in the element-type overview list, add `rect`, `circle`, `ellipse`, `draw` alongside
+the existing element types. Add a concise block (matching the file's existing format) such as:
+
+```
+- rect/circle/ellipse: shape boxes. Props: fill (color or gradient object), stroke, stroke-width, opacity, radius (rect only), and for circle the `size` shorthand. Participate in flex layout.
+- draw: free-form drawing box. Holds `shapes:` (absolute coords): line {x1,y1,x2,y2,stroke,stroke-width}, polyline {points:[[x,y],...],stroke,fill}, rect {x,y,width,height,fill,stroke,radius}, circle {cx,cy,r,fill,stroke}, path {d:"M L Q C Z absolute",fill,stroke}. Max shapes = ResourceLimits.MaxShapesPerDraw (1000). Gradient fill object: {gradient: linear|radial, colors: [..], angle: deg}.
+```
+
+Place these entries in the same section and ordering style as the existing elements.
+
+- [ ] **Step 3: Add shape reference to llms-full.txt**
+
+In `llms-full.txt`, add a full per-element subsection for `rect`, `circle`, `ellipse`, and `draw`,
+matching the structure used for `separator`/`table`/`svg`. Include:
+- Property tables (fill including gradient object form; stroke; stroke-width; opacity; radius for rect; size for circle).
+- The `draw` shape grammar for line/polyline/rect/circle/path, noting absolute coordinates and the supported path commands M/L/Q/C/Z (absolute only).
+- The `MaxShapesPerDraw` limit (default 1000) in the resource-limits table.
+- A YAML example for each element type (use the examples from this plan's Task 11/12 tests).
+
+- [ ] **Step 4: Verify docs build (smoke)**
+
+Run: `dotnet build FlexRender.slnx`
+Expected: BUILD SUCCEEDED (docs are text; this just confirms nothing referencing them broke).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add llms.txt llms-full.txt
+git commit -m "docs: document rect, circle, ellipse, draw shapes in llms files"
+```
+
+---
+
+## Task 18: Docs — wiki Element-Reference and Visual-Reference
+
+**Files:**
+- Modify: `docs/wiki/Element-Reference.md`
+- Modify: `docs/wiki/Visual-Reference.md`
+
+- [ ] **Step 1: Add element entries to Element-Reference.md**
+
+Open `docs/wiki/Element-Reference.md`. Find the table of contents / element list and the existing
+`separator` section. Add four new sections — `rect`, `circle`, `ellipse`, `draw` — each with:
+- Description.
+- A property table (columns: Property, Type, Default, Description).
+- A minimal YAML example.
+
+Property rows for box shapes:
+
+| Property | Type | Default | Description |
+|---|---|---|---|
+| `fill` | string or object | none | Solid color or gradient object `{gradient, colors, angle}` |
+| `stroke` | string | none | Stroke color (hex) |
+| `stroke-width` | number | 0 | Stroke width in px |
+| `opacity` | number | 1.0 | 0..1, inherited base property |
+| `radius` | unit | none | Corner radius (rect only) |
+| `size` | unit | none | Sets width and height (circle only) |
+
+For `draw`, document `shapes:` and the five shape kinds with their property keys, noting absolute
+coordinates relative to the element box and the supported path commands (M, L, Q, C, Z — absolute only).
+
+- [ ] **Step 2: Add visuals to Visual-Reference.md**
+
+Open `docs/wiki/Visual-Reference.md`. Following the existing pattern (a short caption + a YAML snippet
++ a reference to a rendered image), add entries for shapes and draw. Use the YAML from Task 15's
+snapshot tests. If the file references images by path, point to the golden PNGs generated in Task 15
+(`tests/FlexRender.Tests/Snapshots/golden/shapes_box_basic.png`, `shapes_gradient.png`,
+`draw_overlap.png`) using the repo's documented image-URL convention from AGENTS.md
+(`media.githubusercontent.com/.../` for committed binaries). Match whatever convention the
+existing entries already use.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add docs/wiki/Element-Reference.md docs/wiki/Visual-Reference.md
+git commit -m "docs(wiki): add rect, circle, ellipse, draw to element and visual references"
+```
+
+---
+
+## Task 19: Docs — Playground JSON schema + autocomplete
+
+**Files:**
+- Modify: `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json`
+- Modify: `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs` (only if needed)
+
+The schema uses `definitions/element` with an `allOf` list of `if/then` `$ref` branches and an
+`enum` of element-type names. New element schemas plug into both.
+
+- [ ] **Step 1: Add the type enum and dispatch branches**
+
+In `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json`:
+
+1. In the element `type` enum (the line listing `["text", "flex", ..., "content"]`), add
+ `"rect"`, `"circle"`, `"ellipse"`, `"draw"`.
+
+2. In the `allOf` list of `if/then` branches (after the `content` branch), add four branches:
+
+```json
+ {
+ "if": {
+ "properties": { "type": { "const": "rect" } },
+ "required": ["type"]
+ },
+ "then": { "$ref": "#/definitions/rectElement" }
+ },
+ {
+ "if": {
+ "properties": { "type": { "const": "circle" } },
+ "required": ["type"]
+ },
+ "then": { "$ref": "#/definitions/circleElement" }
+ },
+ {
+ "if": {
+ "properties": { "type": { "const": "ellipse" } },
+ "required": ["type"]
+ },
+ "then": { "$ref": "#/definitions/ellipseElement" }
+ },
+ {
+ "if": {
+ "properties": { "type": { "const": "draw" } },
+ "required": ["type"]
+ },
+ "then": { "$ref": "#/definitions/drawElement" }
+ }
+```
+
+(Match the exact `if/then` shape of the existing branches — confirm whether existing branches use
+`"required": ["type"]`; mirror them precisely.)
+
+- [ ] **Step 2: Add the element definitions**
+
+In the same file, under `definitions`, add four definitions following the structure of
+`separatorElement` (each uses `allOf` referencing `flexItemProperties` and lists its own properties).
+Example for `rectElement`:
+
+```json
+ "rectElement": {
+ "allOf": [
+ { "$ref": "#/definitions/flexItemProperties" }
+ ],
+ "properties": {
+ "type": { "const": "rect" },
+ "fill": {
+ "description": "Solid color (hex) or gradient object.",
+ "oneOf": [
+ { "type": "string" },
+ { "$ref": "#/definitions/gradientFill" }
+ ]
+ },
+ "stroke": { "type": "string", "description": "Stroke color (hex)." },
+ "stroke-width": { "type": "number", "description": "Stroke width in px." },
+ "radius": { "type": ["number", "string"], "description": "Corner radius (px/em)." }
+ }
+ },
+ "circleElement": {
+ "allOf": [
+ { "$ref": "#/definitions/flexItemProperties" }
+ ],
+ "properties": {
+ "type": { "const": "circle" },
+ "fill": {
+ "oneOf": [
+ { "type": "string" },
+ { "$ref": "#/definitions/gradientFill" }
+ ]
+ },
+ "stroke": { "type": "string" },
+ "stroke-width": { "type": "number" },
+ "size": { "type": ["number", "string"], "description": "Sets width and height (diameter)." }
+ }
+ },
+ "ellipseElement": {
+ "allOf": [
+ { "$ref": "#/definitions/flexItemProperties" }
+ ],
+ "properties": {
+ "type": { "const": "ellipse" },
+ "fill": {
+ "oneOf": [
+ { "type": "string" },
+ { "$ref": "#/definitions/gradientFill" }
+ ]
+ },
+ "stroke": { "type": "string" },
+ "stroke-width": { "type": "number" }
+ }
+ },
+ "drawElement": {
+ "allOf": [
+ { "$ref": "#/definitions/flexItemProperties" }
+ ],
+ "properties": {
+ "type": { "const": "draw" },
+ "shapes": {
+ "type": "array",
+ "description": "Ordered absolute-coordinate shapes.",
+ "items": { "$ref": "#/definitions/drawShape" }
+ }
+ }
+ },
+ "gradientFill": {
+ "type": "object",
+ "properties": {
+ "gradient": { "type": "string", "enum": ["linear", "radial"] },
+ "colors": { "type": "array", "items": { "type": "string" }, "minItems": 2 },
+ "angle": { "type": "number", "description": "Linear gradient angle in degrees." }
+ },
+ "required": ["gradient", "colors"]
+ },
+ "drawShape": {
+ "type": "object",
+ "properties": {
+ "line": {
+ "type": "object",
+ "properties": {
+ "x1": { "type": "number" }, "y1": { "type": "number" },
+ "x2": { "type": "number" }, "y2": { "type": "number" },
+ "stroke": { "type": "string" }, "stroke-width": { "type": "number" }
+ }
+ },
+ "polyline": {
+ "type": "object",
+ "properties": {
+ "points": { "type": "array", "items": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 } },
+ "stroke": { "type": "string" }, "stroke-width": { "type": "number" }, "fill": { "type": "string" }
+ }
+ },
+ "rect": {
+ "type": "object",
+ "properties": {
+ "x": { "type": "number" }, "y": { "type": "number" },
+ "width": { "type": "number" }, "height": { "type": "number" },
+ "fill": { "type": "string" }, "stroke": { "type": "string" },
+ "stroke-width": { "type": "number" }, "radius": { "type": "number" }
+ }
+ },
+ "circle": {
+ "type": "object",
+ "properties": {
+ "cx": { "type": "number" }, "cy": { "type": "number" }, "r": { "type": "number" },
+ "fill": { "type": "string" }, "stroke": { "type": "string" }, "stroke-width": { "type": "number" }
+ }
+ },
+ "path": {
+ "type": "object",
+ "properties": {
+ "d": { "type": "string", "description": "Absolute path data: M L Q C Z." },
+ "fill": { "type": "string" }, "stroke": { "type": "string" }, "stroke-width": { "type": "number" }
+ }
+ }
+ }
+ }
+```
+
+Confirm comma placement so the JSON stays valid. If existing element definitions set
+`additionalProperties: false`, mirror that on the new definitions for consistency. (The new shape
+elements allow flex-item properties via the `allOf` ref, so do NOT set `additionalProperties: false`
+on the box-shape elements unless the existing `separatorElement` does and still validates with the
+ref — mirror `separatorElement` exactly.)
+
+- [ ] **Step 3: Validate the JSON parses**
+
+Run: `node -e "JSON.parse(require('fs').readFileSync('src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json','utf8')); console.log('valid')"`
+Expected: prints `valid`. (If `node` is unavailable in the sandbox, instead run
+`python3 -c "import json;json.load(open('src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json'));print('valid')"`.)
+
+- [ ] **Step 4: Autocomplete (only if needed)**
+
+`yaml-autocomplete.mjs` derives element types and properties from the schema's
+`layout.items.$ref` and the element definitions. Open it and confirm it reads element types from the
+schema dynamically (the `elementTypes` derivation near the top). If it does, the new schema entries
+are picked up automatically and no change is needed — verify by reading the relevant lines. If it
+hard-codes any element list or snippet map (e.g. the `element:` snippet object near the bottom),
+add `rect`, `circle`, `ellipse`, `draw` there with minimal snippets, e.g.:
+
+```js
+ rect: { type: '0', width: '1', height: '2', fill: '3' },
+ circle: { type: '0', size: '1', fill: '2' },
+ ellipse: { type: '0', width: '1', height: '2', fill: '3' },
+ draw: { type: '0', width: '1', height: '2', shapes: '3' },
+```
+
+Only edit if a hard-coded list exists; otherwise leave the file unchanged and note "no change needed".
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs
+git commit -m "docs(playground): add shape and draw schemas to template json"
+```
+
+(If `yaml-autocomplete.mjs` was not modified, omit it from the `git add`.)
+
+---
+
+## Task 20: Docs — template SKILL.md (marketplace repo, conditional)
+
+**Files:**
+- Modify (external repo, if present locally): `flexrender/skills/template/SKILL.md`
+
+Per AGENTS.md, the `template` skill lives in the separate `RoboNET/FlexRender-Marketplace` repo and
+is NOT part of this checkout (confirmed: no `flexrender/skills/` directory exists here).
+
+- [ ] **Step 1: Check for a local skill file**
+
+Run: `test -f flexrender/skills/template/SKILL.md && echo present || echo absent`
+Expected: `absent` (the skill is in the marketplace repo).
+
+- [ ] **Step 2: Record the follow-up if absent**
+
+If absent, no file change is made in this repo. Instead, ensure the PR description (Task 21) lists a
+required follow-up: "Update `flexrender/skills/template/SKILL.md` in `RoboNET/FlexRender-Marketplace`
+to document the new `rect`/`circle`/`ellipse`/`draw` elements, gradient fill object form, and the
+`MaxShapesPerDraw` limit (Element Types + Common Properties sections)."
+
+If present (unexpected), add the new element types, gradient object form, and `draw` shape grammar to
+the Element Types and Common Properties sections, then commit:
+
+```bash
+git add flexrender/skills/template/SKILL.md
+git commit -m "docs(skill): document shapes and draw in template skill"
+```
+
+- [ ] **Step 3: No-op confirmation**
+
+If absent, there is nothing to commit for this task. Proceed to Task 21.
+
+---
+
+## Task 21: Final verification and PR
+
+**Files:** none (verification + PR).
+
+- [ ] **Step 1: Clean build**
+
+Run: `dotnet build FlexRender.slnx`
+Expected: BUILD SUCCEEDED, zero warnings.
+
+- [ ] **Step 2: Full test suite**
+
+Run: `dotnet test FlexRender.slnx`
+Expected: PASS, no regressions.
+
+- [ ] **Step 3: Push the branch**
+
+```bash
+git push -u origin feature/charts-and-shapes
+```
+
+- [ ] **Step 4: Open the PR**
+
+```bash
+gh pr create --base main --head feature/charts-and-shapes \
+ --title "feat: shape primitives (rect, circle, ellipse, draw) — Phase 1" \
+ --body "Implements Phase 1 (Shapes) of the charts-and-shapes design.
+
+## Added
+- Box shapes \`rect\`, \`circle\`, \`ellipse\` (fill/gradient/stroke/opacity; rect radius; circle \`size\` shorthand).
+- Gradient fill object form (linear/radial; colors; angle) converted to FlexRender's CSS gradient string.
+- \`draw\` element with absolute-coordinate shapes: line, polyline, rect, circle, path (M/L/Q/C/Z, absolute only, hand-written tokenizer, no regex).
+- \`ResourceLimits.MaxShapesPerDraw\` (default 1000).
+- Parser + KnownProperties (typo suggestions), Skia rendering, layout, unit + snapshot tests.
+- Docs: llms.txt, llms-full.txt, wiki Element/Visual references, Playground JSON schema.
+
+## Follow-up (separate repo)
+- Update \`flexrender/skills/template/SKILL.md\` in RoboNET/FlexRender-Marketplace for the new elements, gradient object form, and MaxShapesPerDraw.
+
+## Follow-up (this repo, post-Phase-1)
+- Inner draw-shape property typo validation: properties inside a shape mapping (\`x1\`, \`cx\`, \`d\`, \`stroke-width\`, \`r\`, etc.) are not registered in \`KnownProperties\`, so a typo like \`strok\` is silently ignored instead of suggesting \`stroke\`. Add per-shape-kind known-property sets (line/polyline/rect/circle/path) and nested validation so inner typos surface, matching the typo-suggestion guarantee the top-level element properties already provide.
+- A shape mapping with multiple recognized keys (e.g. \`{line: {...}, circle: {...}}\`) silently uses the first; consider rejecting >1 shape key per entry.
+- Circle/ellipse stroke straddles the inscribed boundary (half the stroke width extends past the diameter). Standard SVG stroke behavior; document or add an inset option if exact-fit strokes are needed.
+- Path-command ceiling: \`ParseDrawPath\` calls \`PathDataParser.Parse(d)\` with the hard-coded default \`maxCommands\` (10000) rather than a value from \`ResourceLimits\`. Worst case per draw element is \`MaxShapesPerDraw\` × 10000 commands. Bounded, not unbounded, but inconsistent with every other configurable limit — add a \`ResourceLimits.MaxPathCommands\` and thread it through.
+- Silent stroke degradation: box \`rect\`/\`circle\`/\`ellipse\` and draw \`rect\`/\`circle\`/\`path\` default \`stroke-width\` to 0, and \`TryCreateStrokePaint\` requires width > 0, so \`stroke: "#333"\` with no \`stroke-width\` renders no stroke and no diagnostic (draw \`line\`/\`polyline\` default to 1). Inconsistent; decide on a default or warn.
+- \`each\` + shape integration test: prove a \`rect\`/\`circle\` inside an \`each\` loop with \`{{item.color}}\` in \`fill\`/\`stroke\` resolves per-iteration (relies on \`ResolveExpressions\`, same as \`TextElement.Color\`). Common LLM pattern (bar charts, grids) — lock it in.
+
+## Done (post-review hardening, in this branch)
+- Non-finite coordinate guard: structured draw-shape literal floats and polyline points now reject NaN/Infinity/overflow via \`GetFiniteFloatValue\`, matching \`PathDataParser\`'s finiteness check (commit fix(parser): reject non-finite coordinates in draw shapes).
+
+## Out of scope
+Charts (later phases), SVG backend for shapes.
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)"
+```
+
+- [ ] **Step 5: Confirm CI**
+
+Run: `gh pr checks` (after CI starts) — confirm checks pass or report failures.
+
+---
+
+## Self-Review
+
+**1. Spec coverage (Phase 1 scope):**
+- `rect`/`circle`/`ellipse` box shapes (fill/gradient/stroke/opacity, rect radius, circle `size`): Tasks 4, 5, 6 (AST), 11 (parse), 13 (render), 15 (snapshot). Opacity is the inherited base property (Tasks reuse `TemplateElement.Opacity`); `stroke-width` parsed in Task 11. ✓
+- Gradient fill object form (linear/radial, colors, angle): Task 10 (converter) + Task 11 (wired into fill parsing) + Task 15 (gradient snapshot). ✓
+- `draw` element with line/polyline/rect/circle/path, absolute coords, hand-written path tokenizer (no regex): Tasks 3 (tokenizer), 7 (DTOs), 8 (element), 12 (parse), 14 (render), 15 (snapshot). ✓
+- `MaxShapesPerDraw` (default 1000): Task 1 (limit) + Task 12 (enforcement test + parser). ✓
+- Parser support + KnownProperties typo suggestions: Tasks 11, 12 (registry + `ShapeParserTests.Parse_Rect_UnknownProperty_SuggestsCorrection`). ✓
+- Skia rendering: Tasks 13, 14. ✓
+- Unit tests (path edge cases, parsing, validation, limits): Tasks 3, 10, 11, 12. Snapshot tests: Task 15. ✓
+- Docs (llms.txt, llms-full.txt, Element-Reference, Visual-Reference, SKILL.md, Playground schema + autocomplete): Tasks 17, 18, 19, 20. ✓
+- Error handling: malformed `path.d` names offending command (Task 3 tests + Task 12 wrap to `TemplateParseException`); shape-count overflow (Task 12). ✓
+- Charts / SVG backend explicitly out of scope. ✓
+
+**2. Placeholder scan:** No "TBD"/"implement later"/"add validation"-style placeholders. Every code
+step contains complete, compilable code. The few "verify exact signature" notes (LayoutEngine
+entrypoint in Task 9; `SkiaRenderer` constructor in Tasks 13–14; schema branch shape in Task 19)
+are explicit verification instructions with a concrete fallback (mirror `SnapshotTestBase` / mirror
+`separatorElement`), not missing content.
+
+**3. Type consistency across tasks:**
+- `ElementType.Rect/Circle/Ellipse/Draw` defined in Task 2, used in Tasks 4–8.
+- `PathCommandKind`, `PathPoint`, `PathCommand`, `PathParseException`, `PathDataParser.Parse` defined
+ in Task 3, used in Tasks 7 (DTOs), 12 (parse), 14 (render). Names match exactly.
+- `DrawShape`/`DrawLine`/`DrawPolyline`/`DrawRect`/`DrawCircle`/`DrawPath` field names defined in
+ Task 7 are consumed identically in Task 12 (construction) and Task 14 (rendering): `DrawLine(X1,Y1,X2,Y2,Stroke,StrokeWidth)`,
+ `DrawPolyline(Points,Stroke,StrokeWidth,Fill)`, `DrawRect(X,Y,Width,Height,Fill,Stroke,StrokeWidth,Radius)`,
+ `DrawCircle(Cx,Cy,R,Fill,Stroke,StrokeWidth)`, `DrawPath(Commands,Fill,Stroke,StrokeWidth)`. ✓
+- `RectElement.Fill/Stroke/StrokeWidth/Radius`, `CircleElement.Fill/Stroke/StrokeWidth`,
+ `EllipseElement.Fill/Stroke/StrokeWidth` defined in Tasks 4–6 and used in Tasks 11, 13. ✓
+- `ShapeParsers.ConvertGradientObjectToCss` (Task 10) used by `ParseFill` (Task 11). ✓
+- `ShapeRenderer.DrawRect/DrawCircle/DrawEllipse/DrawShapes` (Tasks 13–14) dispatched from
+ `RenderingEngine.DrawElement` with matching parameter lists. ✓
+- `LayoutBoxShapeElement` / `MeasureBoxShapeIntrinsic` (Task 9) names match between definition and
+ switch usage. ✓
+
+**Domain checklist holds:** AOT-safe (no reflection/dynamic/Type.GetType; path parsing is a
+hand-written tokenizer, no regex); all new concrete classes are `sealed`; DTOs are `sealed record`;
+`PathPoint` is a `readonly record struct`; `ArgumentNullException.ThrowIfNull` guards on
+`PathDataParser.Parse`, `DrawElement` ctor, and all `CloneWithSubstitution` overrides; new element
+types use the existing switch-based dispatch in layout, intrinsic measurement, and rendering; all new
+YAML properties registered in `KnownProperties.cs`; XML docs on all public API; `MaxShapesPerDraw`
+added (never weakening existing limits).
diff --git a/docs/superpowers/plans/2026-06-13-charts-phase2.md b/docs/superpowers/plans/2026-06-13-charts-phase2.md
new file mode 100644
index 0000000..cc4698c
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-13-charts-phase2.md
@@ -0,0 +1,4303 @@
+# Charts (Phase 2) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a declarative `chart` element (chart-types `bar` vertical/`horizontal`, `line`, `area`, `pie`, `donut`) with themes/palettes, axes (nice ticks), grid, legend, title, and a "no data" placeholder — so LLM agents can render polished charts from data arrays with zero styling decisions.
+
+**Architecture:** A new `ChartElement` AST class in `FlexRender.Core/Parsing/Ast/` follows the Phase-1 shape pattern (leaf box; overrides `Type`, `ResolveExpressions`, `Materialize`, `CloneWithSubstitution`) and carries pre-resolved numeric series. Series-data binding resolves a `{{ expr }}` to an `ArrayValue` inside `ChartElement.ResolveExpressions` via `ExpressionEvaluator.Resolve` against the data context, converting `NumberValue` items to `double[]`. Pure, renderer-agnostic chart math (nice-tick axis scaling) and the static theme/palette tables live in a new `FlexRender.Core/Charts/` namespace, heavily unit-tested. Parsing extends `FlexRender.Yaml` (`ChartParsers.cs`, `TemplateParser` dispatch, `KnownProperties`). Layout treats the chart as a leaf box with explicit width/height (reusing `MeasureShapeIntrinsic`/`LayoutShapeElement`). A new `ChartRenderer` in `FlexRender.Skia.Render` computes the plot area (minus title/legend/axes), draws grid+axes+labels then per-type geometry then legend, dispatched from `RenderingEngine.DrawElement` via a `case ChartElement`. Label text uses SkiaSharp `SKFont`/`SKPaint` built from an `SKTypeface` obtained from `FontManager`.
+
+**Tech Stack:** .NET 10, C# latest, xUnit, SkiaSharp, YamlDotNet. AOT-safe (no reflection, no `dynamic`, no regex), `sealed` classes, `ArgumentNullException.ThrowIfNull`, switch-based dispatch, XML docs on all public API.
+
+---
+
+## Conventions used throughout this plan
+
+- All commands run from repo root `/Users/robonet/Projects/SkiaLayout`.
+- Branch is already `feature/charts-and-shapes`. Do NOT create worktrees. Do NOT merge to `main`.
+- Build: `dotnet build FlexRender.slnx`. Test (authoritative, net10.0): `dotnet test FlexRender.slnx --framework net10.0`. The net8.0 test host cannot launch in this environment — always pass `--framework net10.0` to test commands.
+- NEVER pipe `dotnet` output through `tail`/`head`/`grep`. Run commands directly.
+- Commit messages use Conventional Commits, NO attribution/Co-Authored-By lines. The signing key is missing — every commit uses `--no-gpg-sign`.
+- After every code edit the build must be warning-free (`TreatWarningsAsErrors=true`).
+- `ExprValue` materialize quirk (from Phase 1): for a string that may hold a color OR another form (e.g. a palette word or a label), materialize WITHOUT `ValueKind.Color` so validation does not reject non-hex strings. Series `Data` is never a color, palette/title/label are never colors.
+
+## File structure (created/modified across all tasks)
+
+Created:
+- `src/FlexRender.Core/Charts/ChartEnums.cs` (ChartType, LegendPosition, PieLabelMode)
+- `src/FlexRender.Core/Charts/AxisScale.cs` (nice-tick math)
+- `src/FlexRender.Core/Charts/ChartTheme.cs` + `ChartThemes.cs` (static theme data)
+- `src/FlexRender.Core/Charts/ChartPalette.cs` + `ChartPalettes.cs` (static palette data)
+- `src/FlexRender.Core/Parsing/Ast/ChartSeries.cs`
+- `src/FlexRender.Core/Parsing/Ast/ChartElement.cs`
+- `src/FlexRender.Yaml/Parsing/ChartParsers.cs`
+- `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`
+- Test files (see each task)
+
+Modified:
+- `src/FlexRender.Core/Parsing/Ast/TemplateElement.cs` (add `ElementType.Chart`)
+- `src/FlexRender.Core/Configuration/ResourceLimits.cs` (add `MaxSeriesPerChart`, `MaxDataPointsPerSeries`)
+- `src/FlexRender.Core/Layout/IntrinsicMeasurer.cs` (add `ChartElement` to shape-intrinsic switch arm)
+- `src/FlexRender.Core/Layout/LayoutEngine.cs` (add `ChartElement` to shape-layout switch arm)
+- `src/FlexRender.Yaml/Parsing/TemplateParser.cs` (register `chart`)
+- `src/FlexRender.Yaml/Parsing/KnownProperties.cs` (Chart property set + registry entry)
+- `src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs` (dispatch `ChartElement`)
+- Docs: `llms.txt`, `llms-full.txt`, `docs/wiki/Element-Reference.md`, `docs/wiki/Visual-Reference.md`,
+ `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json`,
+ `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs`
+
+## Highest-risk tasks (flagged for extra review)
+
+1. **Task 16 — series-data expression → array binding** (`ChartElement.ResolveExpressions`). Resolves `{{ expr }}` to an `ArrayValue` and converts to `double[]`; non-numeric items must raise a clear template error with element context. Verify against the real `ExpressionEvaluator.Resolve`/`TemplateContext` API.
+2. **Task 3–7 — axis nice-tick math** (`AxisScale`). Edge cases (crossing zero, negative-only, single point, identical values, empty) must be exhaustively unit-tested; this is renderer-agnostic and must be bullet-proof before any rendering.
+3. **Task 19+ — label/text rendering integration** in `ChartRenderer`. Uses raw `SKFont`/`SKPaint` from a `FontManager` typeface; must measure+draw axis/legend/title labels and degrade gracefully (skip labels) when no typeface is available.
+
+---
+
+## Task 1: ResourceLimits — MaxSeriesPerChart and MaxDataPointsPerSeries
+
+**Files:**
+- Modify: `src/FlexRender.Core/Configuration/ResourceLimits.cs`
+- Test: `tests/FlexRender.Tests/Configuration/ResourceLimitsChartsTests.cs` (create)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Configuration/ResourceLimitsChartsTests.cs`:
+
+```csharp
+using System;
+using FlexRender.Configuration;
+using Xunit;
+
+namespace FlexRender.Tests.Configuration;
+
+///
+/// Tests for the chart-related resource limits.
+///
+public sealed class ResourceLimitsChartsTests
+{
+ [Fact]
+ public void MaxSeriesPerChart_DefaultsTo50()
+ {
+ var limits = new ResourceLimits();
+ Assert.Equal(50, limits.MaxSeriesPerChart);
+ }
+
+ [Fact]
+ public void MaxDataPointsPerSeries_DefaultsTo10000()
+ {
+ var limits = new ResourceLimits();
+ Assert.Equal(10000, limits.MaxDataPointsPerSeries);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void MaxSeriesPerChart_RejectsNonPositive(int value)
+ {
+ var limits = new ResourceLimits();
+ Assert.Throws(() => limits.MaxSeriesPerChart = value);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void MaxDataPointsPerSeries_RejectsNonPositive(int value)
+ {
+ var limits = new ResourceLimits();
+ Assert.Throws(() => limits.MaxDataPointsPerSeries = value);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ResourceLimitsChartsTests"`
+Expected: BUILD FAILURE — `ResourceLimits` has no `MaxSeriesPerChart`/`MaxDataPointsPerSeries`.
+
+- [ ] **Step 3: Add the properties**
+
+In `src/FlexRender.Core/Configuration/ResourceLimits.cs`, add two backing fields next to `_maxShapesPerDraw`:
+
+```csharp
+ private int _maxSeriesPerChart = 50;
+ private int _maxDataPointsPerSeries = 10000;
+```
+
+Then add these two properties after the `MaxShapesPerDraw` property:
+
+```csharp
+ ///
+ /// Maximum number of data series allowed in a single 'chart' element.
+ /// Prevents resource exhaustion from templates with an unbounded series list.
+ ///
+ /// Default: 50.
+ /// Thrown when value is zero or negative.
+ public int MaxSeriesPerChart
+ {
+ get => _maxSeriesPerChart;
+ set
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value);
+ _maxSeriesPerChart = value;
+ }
+ }
+
+ ///
+ /// Maximum number of data points allowed in a single chart series.
+ /// Prevents resource exhaustion from templates with an enormous data array.
+ ///
+ /// Default: 10000.
+ /// Thrown when value is zero or negative.
+ public int MaxDataPointsPerSeries
+ {
+ get => _maxDataPointsPerSeries;
+ set
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value);
+ _maxDataPointsPerSeries = value;
+ }
+ }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ResourceLimitsChartsTests"`
+Expected: PASS (6 cases).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Core/Configuration/ResourceLimits.cs tests/FlexRender.Tests/Configuration/ResourceLimitsChartsTests.cs
+git commit --no-gpg-sign -m "feat(core): add MaxSeriesPerChart and MaxDataPointsPerSeries limits"
+```
+
+---
+
+## Task 2: ElementType.Chart enum member
+
+**Files:**
+- Modify: `src/FlexRender.Core/Parsing/Ast/TemplateElement.cs`
+
+- [ ] **Step 1: Add the enum member**
+
+In `src/FlexRender.Core/Parsing/Ast/TemplateElement.cs`, inside the `ElementType` enum, after the `Draw` member add a comma and:
+
+```csharp
+ ///
+ /// A chart element (bar, line, area, pie, donut).
+ ///
+ Chart
+```
+
+The enum tail must read `... Draw,` then the new `Chart` member.
+
+- [ ] **Step 2: Verify the build compiles**
+
+Run: `dotnet build FlexRender.slnx`
+Expected: BUILD SUCCEEDED (enum-only extension, no behaviour change).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/FlexRender.Core/Parsing/Ast/TemplateElement.cs
+git commit --no-gpg-sign -m "feat(ast): add Chart element type"
+```
+
+---
+
+## Task 3: AxisScale — nice-tick math (data-driven happy path)
+
+This is renderer-agnostic pure math. Build it with exhaustive edge-case tests BEFORE anything draws.
+
+**Files:**
+- Create: `src/FlexRender.Core/Charts/AxisScale.cs`
+- Test: `tests/FlexRender.Tests/Charts/AxisScaleTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Charts/AxisScaleTests.cs`:
+
+```csharp
+using System.Collections.Generic;
+using FlexRender.Charts;
+using Xunit;
+
+namespace FlexRender.Tests.Charts;
+
+///
+/// Tests for the renderer-agnostic nice-tick axis scaling math.
+///
+public sealed class AxisScaleTests
+{
+ [Fact]
+ public void Compute_SimplePositiveRange_ProducesNiceBounds()
+ {
+ var scale = AxisScale.Compute(0d, 48d, targetTicks: 5);
+
+ Assert.Equal(0d, scale.Min);
+ Assert.Equal(50d, scale.Max);
+ Assert.Equal(10d, scale.Step);
+ Assert.Equal(new[] { 0d, 10d, 20d, 30d, 40d, 50d }, scale.Ticks);
+ }
+
+ [Fact]
+ public void Compute_RangeNotStartingAtZero_StillIncludesZeroForBars()
+ {
+ // Bars/area need a zero baseline: min is clamped to 0 when data is all positive.
+ var scale = AxisScale.Compute(12d, 48d, targetTicks: 5);
+
+ Assert.Equal(0d, scale.Min);
+ Assert.True(scale.Max >= 48d);
+ }
+
+ [Fact]
+ public void Compute_NegativeOnly_ClampsMaxToZero()
+ {
+ var scale = AxisScale.Compute(-80d, -10d, targetTicks: 5);
+
+ Assert.True(scale.Min <= -80d);
+ Assert.Equal(0d, scale.Max);
+ }
+
+ [Fact]
+ public void Compute_CrossingZero_KeepsBothSides()
+ {
+ var scale = AxisScale.Compute(-30d, 70d, targetTicks: 5);
+
+ Assert.True(scale.Min <= -30d);
+ Assert.True(scale.Max >= 70d);
+ Assert.Contains(0d, scale.Ticks);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~AxisScaleTests"`
+Expected: BUILD FAILURE — `AxisScale` not defined.
+
+- [ ] **Step 3: Implement AxisScale**
+
+Create `src/FlexRender.Core/Charts/AxisScale.cs`:
+
+```csharp
+using System;
+using System.Collections.Generic;
+
+namespace FlexRender.Charts;
+
+///
+/// A computed numeric axis scale with "nice" rounded bounds and evenly spaced tick values.
+/// Renderer-agnostic; produced from raw data min/max by .
+///
+/// The lower bound of the axis (a nice rounded value).
+/// The upper bound of the axis (a nice rounded value).
+/// The spacing between adjacent ticks.
+/// The ordered tick values from to inclusive.
+public readonly record struct AxisScale(double Min, double Max, double Step, IReadOnlyList Ticks)
+{
+ ///
+ /// Computes a nice axis scale covering ...
+ /// All-positive data is anchored at zero (bar/area baseline); all-negative data is anchored
+ /// at zero above; data crossing zero keeps both sides and always includes a zero tick.
+ /// Identical or empty inputs collapse to a unit range so a chart can still draw.
+ ///
+ /// The smallest data value.
+ /// The largest data value.
+ /// The desired approximate number of tick intervals (default 5).
+ /// The computed .
+ public static AxisScale Compute(double dataMin, double dataMax, int targetTicks = 5)
+ {
+ if (targetTicks < 1)
+ targetTicks = 1;
+
+ // Normalize degenerate inputs.
+ if (!double.IsFinite(dataMin) || !double.IsFinite(dataMax))
+ {
+ dataMin = 0d;
+ dataMax = 1d;
+ }
+
+ if (dataMin > dataMax)
+ (dataMin, dataMax) = (dataMax, dataMin);
+
+ // Anchor at zero so bars/areas have a baseline.
+ if (dataMin > 0d)
+ dataMin = 0d;
+ if (dataMax < 0d)
+ dataMax = 0d;
+
+ // Identical values (e.g. single point, all-equal): expand to a unit range around the value.
+ if (dataMin == dataMax)
+ {
+ if (dataMin == 0d)
+ {
+ dataMax = 1d;
+ }
+ else if (dataMin > 0d)
+ {
+ dataMin = 0d;
+ }
+ else
+ {
+ dataMax = 0d;
+ }
+ }
+
+ var range = dataMax - dataMin;
+ var rawStep = range / targetTicks;
+ var step = NiceNumber(rawStep, round: true);
+ if (step <= 0d)
+ step = 1d;
+
+ var niceMin = Math.Floor(dataMin / step) * step;
+ var niceMax = Math.Ceiling(dataMax / step) * step;
+
+ var ticks = new List();
+ // Use an explicit count to avoid floating-point accumulation drift.
+ var count = (int)Math.Round((niceMax - niceMin) / step);
+ for (var i = 0; i <= count; i++)
+ {
+ ticks.Add(niceMin + (i * step));
+ }
+
+ return new AxisScale(niceMin, niceMax, step, ticks);
+ }
+
+ ///
+ /// Rounds a positive number to a "nice" value (1, 2, 5, or 10 times a power of ten),
+ /// the standard heuristic for readable axis ticks.
+ ///
+ /// The raw value to round (must be positive).
+ /// When true, rounds to the nearest nice value; otherwise rounds up.
+ /// The nice number.
+ private static double NiceNumber(double value, bool round)
+ {
+ if (value <= 0d)
+ return 1d;
+
+ var exponent = Math.Floor(Math.Log10(value));
+ var fraction = value / Math.Pow(10d, exponent);
+
+ double niceFraction;
+ if (round)
+ {
+ niceFraction = fraction < 1.5d ? 1d
+ : fraction < 3d ? 2d
+ : fraction < 7d ? 5d
+ : 10d;
+ }
+ else
+ {
+ niceFraction = fraction <= 1d ? 1d
+ : fraction <= 2d ? 2d
+ : fraction <= 5d ? 5d
+ : 10d;
+ }
+
+ return niceFraction * Math.Pow(10d, exponent);
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~AxisScaleTests"`
+Expected: PASS (4 cases).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Core/Charts/AxisScale.cs tests/FlexRender.Tests/Charts/AxisScaleTests.cs
+git commit --no-gpg-sign -m "feat(charts): add nice-tick axis scale math"
+```
+
+---
+
+## Task 4: AxisScale — edge cases (single point, identical, empty)
+
+Add the remaining edge-case coverage to the same test class so the math is bullet-proof.
+
+**Files:**
+- Modify: `tests/FlexRender.Tests/Charts/AxisScaleTests.cs`
+
+- [ ] **Step 1: Add the failing tests**
+
+Append these methods inside the `AxisScaleTests` class (before its closing brace):
+
+```csharp
+ [Fact]
+ public void Compute_SinglePointPositive_AnchorsAtZeroWithRange()
+ {
+ var scale = AxisScale.Compute(42d, 42d, targetTicks: 5);
+
+ Assert.Equal(0d, scale.Min);
+ Assert.True(scale.Max >= 42d);
+ Assert.True(scale.Step > 0d);
+ Assert.True(scale.Ticks.Count >= 2);
+ }
+
+ [Fact]
+ public void Compute_AllZeroValues_ProducesUnitRange()
+ {
+ var scale = AxisScale.Compute(0d, 0d, targetTicks: 5);
+
+ Assert.Equal(0d, scale.Min);
+ Assert.True(scale.Max > 0d);
+ Assert.True(scale.Step > 0d);
+ }
+
+ [Fact]
+ public void Compute_SinglePointNegative_AnchorsMaxAtZero()
+ {
+ var scale = AxisScale.Compute(-7d, -7d, targetTicks: 5);
+
+ Assert.True(scale.Min <= -7d);
+ Assert.Equal(0d, scale.Max);
+ }
+
+ [Fact]
+ public void Compute_NonFiniteInputs_FallBackToUnitRange()
+ {
+ var scale = AxisScale.Compute(double.NaN, double.PositiveInfinity, targetTicks: 5);
+
+ Assert.True(double.IsFinite(scale.Min));
+ Assert.True(double.IsFinite(scale.Max));
+ Assert.True(scale.Step > 0d);
+ Assert.True(scale.Max > scale.Min);
+ }
+
+ [Fact]
+ public void Compute_TicksAreMonotonicAndEvenlySpaced()
+ {
+ var scale = AxisScale.Compute(0d, 95d, targetTicks: 5);
+
+ for (var i = 1; i < scale.Ticks.Count; i++)
+ {
+ var delta = scale.Ticks[i] - scale.Ticks[i - 1];
+ Assert.True(System.Math.Abs(delta - scale.Step) < 1e-9, $"Tick spacing {delta} != step {scale.Step}");
+ }
+ }
+```
+
+- [ ] **Step 2: Run tests to verify they pass (implementation already handles these)**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~AxisScaleTests"`
+Expected: PASS (9 cases total). If any edge case fails, fix `AxisScale.Compute` before committing.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add tests/FlexRender.Tests/Charts/AxisScaleTests.cs
+git commit --no-gpg-sign -m "test(charts): exhaustive axis-scale edge cases"
+```
+
+---
+
+## Task 5: ChartEnums — ChartType, LegendPosition, PieLabelMode
+
+**Files:**
+- Create: `src/FlexRender.Core/Charts/ChartEnums.cs`
+- Test: `tests/FlexRender.Tests/Charts/ChartEnumsTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Charts/ChartEnumsTests.cs`:
+
+```csharp
+using FlexRender.Charts;
+using Xunit;
+
+namespace FlexRender.Tests.Charts;
+
+///
+/// Sanity tests for the chart enums.
+///
+public sealed class ChartEnumsTests
+{
+ [Theory]
+ [InlineData("Bar")]
+ [InlineData("Line")]
+ [InlineData("Area")]
+ [InlineData("Pie")]
+ [InlineData("Donut")]
+ public void ChartType_HasAllPhase2Members(string name)
+ {
+ Assert.True(System.Enum.TryParse(name, out _));
+ }
+
+ [Theory]
+ [InlineData("Top")]
+ [InlineData("Bottom")]
+ [InlineData("Left")]
+ [InlineData("Right")]
+ [InlineData("None")]
+ public void LegendPosition_HasAllMembers(string name)
+ {
+ Assert.True(System.Enum.TryParse(name, out _));
+ }
+
+ [Theory]
+ [InlineData("Percent")]
+ [InlineData("Value")]
+ [InlineData("None")]
+ public void PieLabelMode_HasAllMembers(string name)
+ {
+ Assert.True(System.Enum.TryParse(name, out _));
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartEnumsTests"`
+Expected: BUILD FAILURE — enums not defined.
+
+- [ ] **Step 3: Implement ChartEnums**
+
+Create `src/FlexRender.Core/Charts/ChartEnums.cs`:
+
+```csharp
+namespace FlexRender.Charts;
+
+///
+/// The kind of chart to render.
+///
+public enum ChartType
+{
+ /// Vertical (or horizontal) bar chart.
+ Bar,
+
+ /// Line chart.
+ Line,
+
+ /// Filled area chart.
+ Area,
+
+ /// Pie chart.
+ Pie,
+
+ /// Donut (ring) chart.
+ Donut
+}
+
+///
+/// Where the legend is placed relative to the plot area.
+///
+public enum LegendPosition
+{
+ /// Above the plot area.
+ Top,
+
+ /// Below the plot area.
+ Bottom,
+
+ /// Left of the plot area.
+ Left,
+
+ /// Right of the plot area.
+ Right,
+
+ /// No legend.
+ None
+}
+
+///
+/// How pie/donut slice labels are rendered.
+///
+public enum PieLabelMode
+{
+ /// Show each slice's percentage of the total.
+ Percent,
+
+ /// Show each slice's raw value.
+ Value,
+
+ /// Show no slice labels.
+ None
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartEnumsTests"`
+Expected: PASS (13 cases).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Core/Charts/ChartEnums.cs tests/FlexRender.Tests/Charts/ChartEnumsTests.cs
+git commit --no-gpg-sign -m "feat(charts): add ChartType, LegendPosition, PieLabelMode enums"
+```
+
+---
+
+## Task 6: ChartPalettes — named series-color ramps
+
+**Files:**
+- Create: `src/FlexRender.Core/Charts/ChartPalette.cs`
+- Create: `src/FlexRender.Core/Charts/ChartPalettes.cs`
+- Test: `tests/FlexRender.Tests/Charts/ChartPalettesTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Charts/ChartPalettesTests.cs`:
+
+```csharp
+using System.Collections.Generic;
+using FlexRender.Charts;
+using Xunit;
+
+namespace FlexRender.Tests.Charts;
+
+///
+/// Tests for named palette resolution and color cycling.
+///
+public sealed class ChartPalettesTests
+{
+ [Theory]
+ [InlineData("default")]
+ [InlineData("ocean")]
+ [InlineData("sunset")]
+ [InlineData("forest")]
+ [InlineData("mono")]
+ [InlineData("vivid")]
+ public void Resolve_KnownName_ReturnsNonEmptyPalette(string name)
+ {
+ var palette = ChartPalettes.Resolve(name);
+ Assert.NotNull(palette);
+ Assert.NotEmpty(palette!.Colors);
+ }
+
+ [Fact]
+ public void Resolve_UnknownName_ReturnsNull()
+ {
+ Assert.Null(ChartPalettes.Resolve("does-not-exist"));
+ }
+
+ [Fact]
+ public void Resolve_IsCaseInsensitive()
+ {
+ Assert.NotNull(ChartPalettes.Resolve("OCEAN"));
+ }
+
+ [Fact]
+ public void ColorAt_CyclesWhenIndexExceedsCount()
+ {
+ var palette = new ChartPalette(new[] { "#111111", "#222222" });
+ Assert.Equal("#111111", palette.ColorAt(0));
+ Assert.Equal("#222222", palette.ColorAt(1));
+ Assert.Equal("#111111", palette.ColorAt(2));
+ }
+
+ [Fact]
+ public void Default_IsNonEmpty()
+ {
+ Assert.NotEmpty(ChartPalettes.Default.Colors);
+ }
+
+ [Fact]
+ public void Constructor_NullColors_Throws()
+ {
+ Assert.Throws(() => new ChartPalette(null!));
+ }
+
+ [Fact]
+ public void Constructor_EmptyColors_Throws()
+ {
+ Assert.Throws(() => new ChartPalette(new List()));
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartPalettesTests"`
+Expected: BUILD FAILURE — `ChartPalette`/`ChartPalettes` not defined.
+
+- [ ] **Step 3: Implement ChartPalette and ChartPalettes**
+
+Create `src/FlexRender.Core/Charts/ChartPalette.cs`:
+
+```csharp
+using System;
+using System.Collections.Generic;
+
+namespace FlexRender.Charts;
+
+///
+/// An ordered ramp of hex series colors. Colors cycle when there are more series than colors.
+///
+public sealed class ChartPalette
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The ordered hex color strings. Must be non-null and non-empty.
+ /// Thrown when is null.
+ /// Thrown when is empty.
+ public ChartPalette(IReadOnlyList colors)
+ {
+ ArgumentNullException.ThrowIfNull(colors);
+ if (colors.Count == 0)
+ throw new ArgumentException("A palette must contain at least one color.", nameof(colors));
+ Colors = colors;
+ }
+
+ ///
+ /// Gets the ordered hex color strings.
+ ///
+ public IReadOnlyList Colors { get; }
+
+ ///
+ /// Returns the color for a series index, cycling through when the
+ /// index exceeds the palette size.
+ ///
+ /// The zero-based series index (must be non-negative).
+ /// The hex color string for the series.
+ public string ColorAt(int index)
+ {
+ var i = index % Colors.Count;
+ if (i < 0)
+ i += Colors.Count;
+ return Colors[i];
+ }
+}
+```
+
+Create `src/FlexRender.Core/Charts/ChartPalettes.cs`:
+
+```csharp
+using System;
+using System.Collections.Generic;
+
+namespace FlexRender.Charts;
+
+///
+/// Static registry of named series-color palettes (AOT-safe, no config files).
+///
+public static class ChartPalettes
+{
+ /// The default palette used when none is specified.
+ public static ChartPalette Default { get; } = new(new[]
+ {
+ "#4A90D9", "#E2725B", "#7FB069", "#F4C430", "#9B6DD6", "#54B8B1", "#E0719C", "#A0A0A0"
+ });
+
+ private static readonly ChartPalette Ocean = new(new[]
+ {
+ "#264653", "#2A9D8F", "#48BFE3", "#56CFE1", "#64DFDF", "#80FFDB"
+ });
+
+ private static readonly ChartPalette Sunset = new(new[]
+ {
+ "#003049", "#D62828", "#F77F00", "#FCBF49", "#EAE2B7"
+ });
+
+ private static readonly ChartPalette Forest = new(new[]
+ {
+ "#1B4332", "#2D6A4F", "#40916C", "#52B788", "#74C69D", "#95D5B2"
+ });
+
+ private static readonly ChartPalette Mono = new(new[]
+ {
+ "#222222", "#444444", "#666666", "#888888", "#AAAAAA", "#CCCCCC"
+ });
+
+ private static readonly ChartPalette Vivid = new(new[]
+ {
+ "#E63946", "#F1A208", "#2A9D8F", "#3A86FF", "#8338EC", "#FF006E"
+ });
+
+ private static readonly Dictionary Registry = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["default"] = Default,
+ ["ocean"] = Ocean,
+ ["sunset"] = Sunset,
+ ["forest"] = Forest,
+ ["mono"] = Mono,
+ ["vivid"] = Vivid
+ };
+
+ ///
+ /// Resolves a named palette case-insensitively.
+ ///
+ /// The palette name (e.g. "ocean").
+ /// The matching , or null when the name is unknown.
+ public static ChartPalette? Resolve(string? name)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ return null;
+ return Registry.TryGetValue(name, out var palette) ? palette : null;
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartPalettesTests"`
+Expected: PASS (12 cases).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Core/Charts/ChartPalette.cs src/FlexRender.Core/Charts/ChartPalettes.cs tests/FlexRender.Tests/Charts/ChartPalettesTests.cs
+git commit --no-gpg-sign -m "feat(charts): add named series palettes"
+```
+
+---
+
+## Task 7: ChartThemes — light/dark/minimal theme data
+
+**Files:**
+- Create: `src/FlexRender.Core/Charts/ChartTheme.cs`
+- Create: `src/FlexRender.Core/Charts/ChartThemes.cs`
+- Test: `tests/FlexRender.Tests/Charts/ChartThemesTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Charts/ChartThemesTests.cs`:
+
+```csharp
+using FlexRender.Charts;
+using Xunit;
+
+namespace FlexRender.Tests.Charts;
+
+///
+/// Tests for named chart theme resolution.
+///
+public sealed class ChartThemesTests
+{
+ [Theory]
+ [InlineData("light")]
+ [InlineData("dark")]
+ [InlineData("minimal")]
+ public void Resolve_KnownName_ReturnsTheme(string name)
+ {
+ var theme = ChartThemes.Resolve(name);
+ Assert.NotNull(theme);
+ }
+
+ [Fact]
+ public void Resolve_UnknownName_ReturnsNull()
+ {
+ Assert.Null(ChartThemes.Resolve("neon"));
+ }
+
+ [Fact]
+ public void Resolve_IsCaseInsensitive()
+ {
+ Assert.NotNull(ChartThemes.Resolve("DARK"));
+ }
+
+ [Fact]
+ public void Default_IsLight()
+ {
+ Assert.Same(ChartThemes.Resolve("light"), ChartThemes.Default);
+ }
+
+ [Fact]
+ public void LightTheme_HasNonEmptyColors()
+ {
+ var theme = ChartThemes.Default;
+ Assert.False(string.IsNullOrEmpty(theme.BackgroundColor));
+ Assert.False(string.IsNullOrEmpty(theme.GridColor));
+ Assert.False(string.IsNullOrEmpty(theme.AxisColor));
+ Assert.False(string.IsNullOrEmpty(theme.LabelColor));
+ Assert.True(theme.LabelSize > 0f);
+ Assert.True(theme.TitleSize > 0f);
+ Assert.True(theme.LineWidth > 0f);
+ Assert.True(theme.BarCornerRadius >= 0f);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartThemesTests"`
+Expected: BUILD FAILURE — `ChartTheme`/`ChartThemes` not defined.
+
+- [ ] **Step 3: Implement ChartTheme and ChartThemes**
+
+Create `src/FlexRender.Core/Charts/ChartTheme.cs`:
+
+```csharp
+namespace FlexRender.Charts;
+
+///
+/// Visual styling preset for a chart: colors, label/title sizes, line widths and bar rounding.
+/// Renderer-agnostic, immutable static data.
+///
+/// The chart background fill (hex). Empty means transparent.
+/// The grid-line color (hex).
+/// The axis-line color (hex).
+/// The axis/legend/slice label text color (hex).
+/// The title text color (hex).
+/// The label font size in pixels.
+/// The title font size in pixels.
+/// The series line width in pixels (line/area charts).
+/// The corner radius applied to bars in pixels.
+public sealed record ChartTheme(
+ string BackgroundColor,
+ string GridColor,
+ string AxisColor,
+ string LabelColor,
+ string TitleColor,
+ float LabelSize,
+ float TitleSize,
+ float LineWidth,
+ float BarCornerRadius);
+```
+
+Create `src/FlexRender.Core/Charts/ChartThemes.cs`:
+
+```csharp
+using System;
+using System.Collections.Generic;
+
+namespace FlexRender.Charts;
+
+///
+/// Static registry of named chart themes (AOT-safe, no config files).
+///
+public static class ChartThemes
+{
+ /// The light theme, used as the default.
+ public static ChartTheme Default { get; } = new(
+ BackgroundColor: "#FFFFFF",
+ GridColor: "#E6E6E6",
+ AxisColor: "#999999",
+ LabelColor: "#555555",
+ TitleColor: "#222222",
+ LabelSize: 12f,
+ TitleSize: 16f,
+ LineWidth: 2.5f,
+ BarCornerRadius: 3f);
+
+ private static readonly ChartTheme Dark = new(
+ BackgroundColor: "#1E1E1E",
+ GridColor: "#3A3A3A",
+ AxisColor: "#777777",
+ LabelColor: "#CCCCCC",
+ TitleColor: "#F0F0F0",
+ LabelSize: 12f,
+ TitleSize: 16f,
+ LineWidth: 2.5f,
+ BarCornerRadius: 3f);
+
+ private static readonly ChartTheme Minimal = new(
+ BackgroundColor: "#FFFFFF",
+ GridColor: "#F0F0F0",
+ AxisColor: "#CCCCCC",
+ LabelColor: "#666666",
+ TitleColor: "#333333",
+ LabelSize: 11f,
+ TitleSize: 15f,
+ LineWidth: 2f,
+ BarCornerRadius: 0f);
+
+ private static readonly Dictionary Registry = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["light"] = Default,
+ ["dark"] = Dark,
+ ["minimal"] = Minimal
+ };
+
+ ///
+ /// Resolves a named theme case-insensitively.
+ ///
+ /// The theme name (e.g. "dark").
+ /// The matching , or null when the name is unknown.
+ public static ChartTheme? Resolve(string? name)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ return null;
+ return Registry.TryGetValue(name, out var theme) ? theme : null;
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartThemesTests"`
+Expected: PASS (8 cases).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Core/Charts/ChartTheme.cs src/FlexRender.Core/Charts/ChartThemes.cs tests/FlexRender.Tests/Charts/ChartThemesTests.cs
+git commit --no-gpg-sign -m "feat(charts): add light/dark/minimal themes"
+```
+
+---
+
+## Task 8: ChartSeries — resolved-series DTO
+
+The series carries a YAML-time data form (inline array OR expression string) plus the resolved numeric `double[]` filled during expression resolution.
+
+**Files:**
+- Create: `src/FlexRender.Core/Parsing/Ast/ChartSeries.cs`
+- Test: `tests/FlexRender.Tests/Parsing/Ast/ChartSeriesTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Parsing/Ast/ChartSeriesTests.cs`:
+
+```csharp
+using System;
+using FlexRender.Parsing.Ast;
+using Xunit;
+
+namespace FlexRender.Tests.Parsing.Ast;
+
+///
+/// Tests for the DTO.
+///
+public sealed class ChartSeriesTests
+{
+ [Fact]
+ public void InlineData_StoresLabelAndValues()
+ {
+ var series = ChartSeries.FromInline("2024", new[] { 12d, 30d, 22d, 48d });
+
+ Assert.Equal("2024", series.Label);
+ Assert.Null(series.DataExpression);
+ Assert.Equal(new[] { 12d, 30d, 22d, 48d }, series.Data);
+ }
+
+ [Fact]
+ public void Expression_StoresExpressionAndEmptyData()
+ {
+ var series = ChartSeries.FromExpression("Sales", "{{ sales }}");
+
+ Assert.Equal("Sales", series.Label);
+ Assert.Equal("{{ sales }}", series.DataExpression);
+ Assert.Empty(series.Data);
+ }
+
+ [Fact]
+ public void WithData_ReplacesDataKeepingLabel()
+ {
+ var series = ChartSeries.FromExpression("Sales", "{{ sales }}");
+ var resolved = series.WithData(new[] { 1d, 2d, 3d });
+
+ Assert.Equal("Sales", resolved.Label);
+ Assert.Equal("{{ sales }}", resolved.DataExpression);
+ Assert.Equal(new[] { 1d, 2d, 3d }, resolved.Data);
+ }
+
+ [Fact]
+ public void FromInline_NullData_Throws()
+ {
+ Assert.Throws(() => ChartSeries.FromInline("x", null!));
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSeriesTests"`
+Expected: BUILD FAILURE — `ChartSeries` not defined.
+
+- [ ] **Step 3: Implement ChartSeries**
+
+Create `src/FlexRender.Core/Parsing/Ast/ChartSeries.cs`:
+
+```csharp
+using System;
+using System.Collections.Generic;
+
+namespace FlexRender.Parsing.Ast;
+
+///
+/// A single chart data series: a label plus its numeric values. The values may come from an
+/// inline YAML array (resolved at parse time) or from a template expression (resolved against
+/// the data context during ).
+///
+public sealed class ChartSeries
+{
+ private static readonly IReadOnlyList Empty = Array.Empty();
+
+ private ChartSeries(string? label, string? dataExpression, IReadOnlyList data)
+ {
+ Label = label;
+ DataExpression = dataExpression;
+ Data = data;
+ }
+
+ /// Gets the optional series label shown in the legend.
+ public string? Label { get; }
+
+ ///
+ /// Gets the raw template expression (e.g. "{{ sales }}") when the data is data-bound;
+ /// null when the data was supplied inline.
+ ///
+ public string? DataExpression { get; }
+
+ /// Gets the resolved numeric values. Empty until a bound expression is resolved.
+ public IReadOnlyList Data { get; }
+
+ ///
+ /// Creates a series with inline numeric data.
+ ///
+ /// The optional legend label.
+ /// The numeric values.
+ /// A new .
+ /// Thrown when is null.
+ public static ChartSeries FromInline(string? label, IReadOnlyList data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+ return new ChartSeries(label, dataExpression: null, data);
+ }
+
+ ///
+ /// Creates a data-bound series whose values come from a template expression.
+ ///
+ /// The optional legend label.
+ /// The raw expression string (e.g. "{{ sales }}").
+ /// A new with empty data until resolved.
+ public static ChartSeries FromExpression(string? label, string dataExpression)
+ {
+ ArgumentNullException.ThrowIfNull(dataExpression);
+ return new ChartSeries(label, dataExpression, Empty);
+ }
+
+ ///
+ /// Returns a copy of this series with its replaced, preserving the label
+ /// and expression. Used when a bound expression has been resolved to concrete values.
+ ///
+ /// The resolved numeric values.
+ /// A new with the new data.
+ /// Thrown when is null.
+ public ChartSeries WithData(IReadOnlyList data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+ return new ChartSeries(Label, DataExpression, data);
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSeriesTests"`
+Expected: PASS (4 cases).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Core/Parsing/Ast/ChartSeries.cs tests/FlexRender.Tests/Parsing/Ast/ChartSeriesTests.cs
+git commit --no-gpg-sign -m "feat(ast): add ChartSeries DTO"
+```
+
+---
+
+## Task 9: ChartElement AST class (structure + Type + clone)
+
+The data-binding override of `ResolveExpressions` comes later (Task 16). This task lands the leaf-box element with all chart properties.
+
+**Files:**
+- Create: `src/FlexRender.Core/Parsing/Ast/ChartElement.cs`
+- Test: `tests/FlexRender.Tests/Parsing/Ast/ChartElementTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Parsing/Ast/ChartElementTests.cs`:
+
+```csharp
+using System.Collections.Generic;
+using FlexRender.Charts;
+using FlexRender.Parsing.Ast;
+using Xunit;
+
+namespace FlexRender.Tests.Parsing.Ast;
+
+///
+/// Tests for the AST class.
+///
+public sealed class ChartElementTests
+{
+ private static ChartElement MakeChart()
+ {
+ var series = new List
+ {
+ ChartSeries.FromInline("2024", new[] { 12d, 30d, 22d, 48d })
+ };
+ return new ChartElement(ChartType.Bar, series)
+ {
+ Categories = new[] { "Q1", "Q2", "Q3", "Q4" },
+ Width = "600",
+ Height = "300",
+ Legend = LegendPosition.Bottom,
+ Title = "Revenue"
+ };
+ }
+
+ [Fact]
+ public void Type_IsChart()
+ {
+ Assert.Equal(ElementType.Chart, MakeChart().Type);
+ }
+
+ [Fact]
+ public void ChartType_AndSeries_AreExposed()
+ {
+ var chart = MakeChart();
+ Assert.Equal(ChartType.Bar, chart.ChartType);
+ Assert.Single(chart.Series);
+ Assert.Equal(4, chart.Categories.Count);
+ }
+
+ [Fact]
+ public void Defaults_AreSensible()
+ {
+ var chart = new ChartElement(ChartType.Line, new List());
+ Assert.Empty(chart.Series);
+ Assert.Empty(chart.Categories);
+ Assert.Null(chart.Title);
+ Assert.False(chart.Horizontal);
+ Assert.False(chart.Stacked);
+ Assert.False(chart.Smooth);
+ Assert.False(chart.ShowPoints);
+ Assert.Equal(PieLabelMode.Percent, chart.PieLabels);
+ }
+
+ [Fact]
+ public void CloneWithSubstitution_PreservesChartState()
+ {
+ var clone = (ChartElement)MakeChart().CloneWithSubstitution(s => s);
+
+ Assert.Equal(ChartType.Bar, clone.ChartType);
+ Assert.Single(clone.Series);
+ Assert.Equal("Revenue", clone.Title);
+ Assert.Equal("600", clone.Width.Value);
+ Assert.Equal(LegendPosition.Bottom, clone.Legend);
+ }
+
+ [Fact]
+ public void Constructor_NullSeries_Throws()
+ {
+ Assert.Throws(() => new ChartElement(ChartType.Bar, null!));
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartElementTests"`
+Expected: BUILD FAILURE — `ChartElement` not defined.
+
+- [ ] **Step 3: Implement ChartElement (no data-binding override yet)**
+
+Create `src/FlexRender.Core/Parsing/Ast/ChartElement.cs`:
+
+```csharp
+using System;
+using System.Collections.Generic;
+using FlexRender.Charts;
+
+namespace FlexRender.Parsing.Ast;
+
+///
+/// A chart element. Participates in flex layout as a leaf box with explicit width/height and is
+/// drawn by the renderer into that box: grid, axes, series geometry, legend, title. The visual
+/// styling comes entirely from the resolved and ;
+/// the template only supplies data and optional theme/palette words.
+///
+public sealed class ChartElement : TemplateElement
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The chart type.
+ /// The data series (may be empty; an empty chart renders a "no data" placeholder).
+ /// Thrown when is null.
+ public ChartElement(ChartType chartType, IReadOnlyList series)
+ {
+ ArgumentNullException.ThrowIfNull(series);
+ ChartType = chartType;
+ Series = series;
+ }
+
+ ///
+ public override ElementType Type => ElementType.Chart;
+
+ /// Gets the chart type.
+ public ChartType ChartType { get; private set; }
+
+ /// Gets the data series (resolved during expression resolution).
+ public IReadOnlyList Series { get; private set; }
+
+ /// Gets or sets the category labels (x-axis categories or pie slice labels).
+ public IReadOnlyList Categories { get; set; } = Array.Empty();
+
+ /// Gets or sets the resolved palette name or explicit color list. Null uses the theme default palette.
+ public ChartPalette? Palette { get; set; }
+
+ /// Gets or sets the resolved theme. Null falls back to the template/canvas theme then light.
+ public ChartTheme? Theme { get; set; }
+
+ /// Gets or sets the legend position.
+ public LegendPosition Legend { get; set; } = LegendPosition.Bottom;
+
+ /// Gets or sets the optional chart title.
+ public string? Title { get; set; }
+
+ /// Gets or sets whether bars are drawn horizontally (bar charts only).
+ public bool Horizontal { get; set; }
+
+ /// Gets or sets whether bars/areas are stacked (bar charts only in this phase).
+ public bool Stacked { get; set; }
+
+ /// Gets or sets whether line/area series use smoothed curves.
+ public bool Smooth { get; set; }
+
+ /// Gets or sets whether line/area series show point markers.
+ public bool ShowPoints { get; set; }
+
+ /// Gets or sets how pie/donut slice labels are rendered.
+ public PieLabelMode PieLabels { get; set; } = PieLabelMode.Percent;
+
+ ///
+ public override TemplateElement CloneWithSubstitution(Func substitutor)
+ {
+ ArgumentNullException.ThrowIfNull(substitutor);
+
+ var clone = new ChartElement(ChartType, Series)
+ {
+ Categories = Categories,
+ Palette = Palette,
+ Theme = Theme,
+ Legend = Legend,
+ Title = Title,
+ Horizontal = Horizontal,
+ Stacked = Stacked,
+ Smooth = Smooth,
+ ShowPoints = ShowPoints,
+ PieLabels = PieLabels
+ };
+ CopyBasePropertiesTo(clone, substitutor);
+ return clone;
+ }
+
+ ///
+ /// Replaces the series collection. Used by expression resolution to install resolved data.
+ ///
+ /// The resolved series.
+ /// Thrown when is null.
+ internal void SetSeries(IReadOnlyList series)
+ {
+ ArgumentNullException.ThrowIfNull(series);
+ Series = series;
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartElementTests"`
+Expected: PASS (5 cases).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Core/Parsing/Ast/ChartElement.cs tests/FlexRender.Tests/Parsing/Ast/ChartElementTests.cs
+git commit --no-gpg-sign -m "feat(ast): add ChartElement"
+```
+
+---
+
+## Task 10: Layout — chart leaf box
+
+Add `ChartElement` to the existing shape-intrinsic and shape-layout switch arms (same treatment as rect/draw).
+
+**Files:**
+- Modify: `src/FlexRender.Core/Layout/IntrinsicMeasurer.cs:64` (the shape arm group)
+- Modify: `src/FlexRender.Core/Layout/LayoutEngine.cs:219` (the shape arm group)
+- Test: `tests/FlexRender.Tests/Layout/ChartLayoutTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Layout/ChartLayoutTests.cs`:
+
+```csharp
+using System.Collections.Generic;
+using FlexRender.Charts;
+using FlexRender.Configuration;
+using FlexRender.Layout;
+using FlexRender.Parsing.Ast;
+using Xunit;
+
+namespace FlexRender.Tests.Layout;
+
+///
+/// Layout tests for the chart leaf element.
+///
+public sealed class ChartLayoutTests
+{
+ [Fact]
+ public void Chart_WithExplicitSize_ProducesThatSize()
+ {
+ var chart = new ChartElement(ChartType.Bar, new List
+ {
+ ChartSeries.FromInline("a", new[] { 1d, 2d, 3d })
+ })
+ {
+ Width = "600",
+ Height = "300"
+ };
+
+ var template = new Template
+ {
+ Canvas = new CanvasSettings { Width = 800, Fixed = FixedDimension.Width }
+ };
+ template.AddElement(chart);
+
+ var engine = new LayoutEngine(new ResourceLimits());
+ var root = engine.ComputeLayout(template);
+ var node = root.Children[0];
+
+ Assert.Equal(600f, node.Width);
+ Assert.Equal(300f, node.Height);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartLayoutTests"`
+Expected: FAIL — chart is not in the layout switch, so size is not honoured (node width/height differ).
+
+- [ ] **Step 3: Add ChartElement to both switch arms**
+
+In `src/FlexRender.Core/Layout/IntrinsicMeasurer.cs`, in the `MeasureIntrinsic` switch (currently lines 60-64), add after the `DrawElement draw => MeasureShapeIntrinsic(draw),` arm:
+
+```csharp
+ ChartElement chart => MeasureShapeIntrinsic(chart),
+```
+
+In `src/FlexRender.Core/Layout/LayoutEngine.cs`, in the layout dispatch switch (currently lines 215-219), add after the `DrawElement draw => LayoutShapeElement(draw, context),` arm:
+
+```csharp
+ ChartElement chart => LayoutShapeElement(chart, context),
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartLayoutTests"`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Core/Layout/IntrinsicMeasurer.cs src/FlexRender.Core/Layout/LayoutEngine.cs tests/FlexRender.Tests/Layout/ChartLayoutTests.cs
+git commit --no-gpg-sign -m "feat(layout): treat chart as a leaf box"
+```
+
+---
+
+## Task 11: ChartParsers — parse inline series + basic props
+
+Parse the chart element; series data may be an inline array or an expression. This task handles parsing into the AST (data-binding resolution is Task 16; for inline arrays the data is already concrete here).
+
+**Files:**
+- Create: `src/FlexRender.Yaml/Parsing/ChartParsers.cs`
+- Modify: `src/FlexRender.Yaml/Parsing/TemplateParser.cs:71` (register `chart`)
+- Test: `tests/FlexRender.Tests/Parsing/ChartParsersTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Parsing/ChartParsersTests.cs`:
+
+```csharp
+using FlexRender.Charts;
+using FlexRender.Parsing;
+using FlexRender.Parsing.Ast;
+using Xunit;
+
+namespace FlexRender.Tests.Parsing;
+
+///
+/// Tests for parsing the chart element from YAML.
+///
+public sealed class ChartParsersTests
+{
+ private readonly TemplateParser _parser = new();
+
+ [Fact]
+ public void Parse_BarChartWithInlineSeries_ProducesChartElement()
+ {
+ const string yaml = """
+ canvas:
+ width: 600
+ layout:
+ - type: chart
+ chart-type: bar
+ width: 600
+ height: 300
+ categories: [Q1, Q2, Q3, Q4]
+ series:
+ - label: "2024"
+ data: [12, 30, 22, 48]
+ palette: ocean
+ legend: bottom
+ title: Revenue
+ """;
+
+ var template = _parser.Parse(yaml);
+ var chart = Assert.IsType(template.Elements[0]);
+
+ Assert.Equal(ChartType.Bar, chart.ChartType);
+ Assert.Equal(new[] { "Q1", "Q2", "Q3", "Q4" }, chart.Categories);
+ Assert.Single(chart.Series);
+ Assert.Equal("2024", chart.Series[0].Label);
+ Assert.Equal(new[] { 12d, 30d, 22d, 48d }, chart.Series[0].Data);
+ Assert.NotNull(chart.Palette);
+ Assert.Equal(LegendPosition.Bottom, chart.Legend);
+ Assert.Equal("Revenue", chart.Title);
+ }
+
+ [Fact]
+ public void Parse_SeriesWithExpression_StoresExpressionNotData()
+ {
+ const string yaml = """
+ canvas:
+ width: 600
+ layout:
+ - type: chart
+ chart-type: line
+ width: 600
+ height: 300
+ series:
+ - label: Sales
+ data: "{{ sales }}"
+ """;
+
+ var template = _parser.Parse(yaml);
+ var chart = Assert.IsType(template.Elements[0]);
+
+ Assert.Equal("{{ sales }}", chart.Series[0].DataExpression);
+ Assert.Empty(chart.Series[0].Data);
+ }
+
+ [Fact]
+ public void Parse_ExplicitColorListPalette_IsAccepted()
+ {
+ const string yaml = """
+ canvas:
+ width: 400
+ layout:
+ - type: chart
+ chart-type: pie
+ width: 400
+ height: 400
+ categories: [A, B, C]
+ series:
+ - data: [10, 20, 30]
+ palette: ["#264653", "#2a9d8f", "#e9c46a"]
+ """;
+
+ var template = _parser.Parse(yaml);
+ var chart = Assert.IsType(template.Elements[0]);
+
+ Assert.NotNull(chart.Palette);
+ Assert.Equal("#264653", chart.Palette!.ColorAt(0));
+ }
+
+ [Fact]
+ public void Parse_BarTypeSpecificProps_AreApplied()
+ {
+ const string yaml = """
+ canvas:
+ width: 600
+ layout:
+ - type: chart
+ chart-type: bar
+ width: 600
+ height: 300
+ horizontal: true
+ stacked: true
+ series:
+ - data: [1, 2, 3]
+ """;
+
+ var template = _parser.Parse(yaml);
+ var chart = Assert.IsType(template.Elements[0]);
+
+ Assert.True(chart.Horizontal);
+ Assert.True(chart.Stacked);
+ }
+
+ [Fact]
+ public void Parse_UnknownChartType_Throws()
+ {
+ const string yaml = """
+ canvas:
+ width: 600
+ layout:
+ - type: chart
+ chart-type: spider
+ width: 600
+ height: 300
+ series:
+ - data: [1, 2]
+ """;
+
+ var ex = Assert.Throws(() => _parser.Parse(yaml));
+ Assert.Contains("chart-type", ex.Message);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartParsersTests"`
+Expected: BUILD FAILURE — `ChartParsers` not defined and `chart` not registered (note: the unknown-property validation for `chart` is added in Task 13; for now `chart-type`, `series` etc. must parse).
+
+- [ ] **Step 3: Implement ChartParsers**
+
+Create `src/FlexRender.Yaml/Parsing/ChartParsers.cs`:
+
+```csharp
+using System.Collections.Generic;
+using System.Globalization;
+using FlexRender.Charts;
+using FlexRender.Parsing.Ast;
+using YamlDotNet.RepresentationModel;
+using static FlexRender.Parsing.YamlPropertyHelpers;
+
+namespace FlexRender.Parsing;
+
+///
+/// Provides static helpers for parsing the chart element from YAML.
+///
+public static class ChartParsers
+{
+ ///
+ /// Parses a chart element.
+ ///
+ /// The YAML node containing the chart definition.
+ /// The maximum number of series allowed.
+ /// The maximum number of data points per series.
+ /// The parsed .
+ /// Thrown on an unknown chart-type, malformed series, or exceeded limits.
+ internal static TemplateElement ParseChartElement(YamlMappingNode node, int maxSeries, int maxDataPoints)
+ {
+ ArgumentNullException.ThrowIfNull(node);
+
+ var chartType = ParseChartType(node);
+ var series = ParseSeries(node, maxSeries, maxDataPoints);
+
+ var chart = new ChartElement(chartType, series)
+ {
+ Categories = ParseCategories(node),
+ Palette = ParsePalette(node),
+ Theme = ParseTheme(node),
+ Legend = ParseLegend(node),
+ Title = GetStringValue(node, "title"),
+ Horizontal = GetBoolValue(node, "horizontal", false),
+ Stacked = GetBoolValue(node, "stacked", false),
+ Smooth = GetBoolValue(node, "smooth", false),
+ ShowPoints = GetBoolValue(node, "points", false),
+ PieLabels = ParsePieLabels(node),
+ Background = GetStringValue(node, "background")!,
+ Rotate = GetExprStringValue(node, "rotate", "none"),
+ Padding = GetExprStringValue(node, "padding", "0"),
+ Margin = GetExprStringValue(node, "margin", "0")
+ };
+
+ ElementParsers.ApplyFlexItemProperties(node, chart);
+ return chart;
+ }
+
+ private static ChartType ParseChartType(YamlMappingNode node)
+ {
+ var raw = GetStringValue(node, "chart-type", "bar");
+ if (!System.Enum.TryParse(raw, ignoreCase: true, out var chartType))
+ {
+ throw new TemplateParseException(
+ $"Unknown chart-type '{raw}'. Valid values: bar, line, area, pie, donut.");
+ }
+ return chartType;
+ }
+
+ private static LegendPosition ParseLegend(YamlMappingNode node)
+ {
+ var raw = GetStringValue(node, "legend", "bottom");
+ if (!System.Enum.TryParse(raw, ignoreCase: true, out var legend))
+ {
+ throw new TemplateParseException(
+ $"Unknown legend position '{raw}'. Valid values: top, bottom, left, right, none.");
+ }
+ return legend;
+ }
+
+ private static PieLabelMode ParsePieLabels(YamlMappingNode node)
+ {
+ var raw = GetStringValue(node, "labels", "percent");
+ if (!System.Enum.TryParse(raw, ignoreCase: true, out var mode))
+ {
+ throw new TemplateParseException(
+ $"Unknown labels mode '{raw}'. Valid values: percent, value, none.");
+ }
+ return mode;
+ }
+
+ private static IReadOnlyList ParseCategories(YamlMappingNode node)
+ {
+ var categories = new List();
+ if (TryGetSequence(node, "categories", out var seq))
+ {
+ foreach (var item in seq.Children)
+ {
+ if (item is YamlScalarNode scalar && scalar.Value is not null)
+ categories.Add(scalar.Value);
+ }
+ }
+ return categories;
+ }
+
+ private static ChartPalette? ParsePalette(YamlMappingNode node)
+ {
+ // Explicit color list form.
+ if (TryGetSequence(node, "palette", out var seq))
+ {
+ var colors = new List();
+ foreach (var item in seq.Children)
+ {
+ if (item is YamlScalarNode scalar && !string.IsNullOrWhiteSpace(scalar.Value))
+ colors.Add(scalar.Value.Trim());
+ }
+ if (colors.Count == 0)
+ throw new TemplateParseException("A 'palette' color list must contain at least one color.");
+ return new ChartPalette(colors);
+ }
+
+ // Named palette form.
+ var name = GetStringValue(node, "palette");
+ if (string.IsNullOrWhiteSpace(name))
+ return null;
+
+ var palette = ChartPalettes.Resolve(name);
+ if (palette is null)
+ throw new TemplateParseException(
+ $"Unknown palette '{name}'. Valid names: default, ocean, sunset, forest, mono, vivid (or an explicit color list).");
+ return palette;
+ }
+
+ private static ChartTheme? ParseTheme(YamlMappingNode node)
+ {
+ var name = GetStringValue(node, "theme");
+ if (string.IsNullOrWhiteSpace(name))
+ return null;
+
+ var theme = ChartThemes.Resolve(name);
+ if (theme is null)
+ throw new TemplateParseException(
+ $"Unknown chart theme '{name}'. Valid names: light, dark, minimal.");
+ return theme;
+ }
+
+ private static IReadOnlyList ParseSeries(YamlMappingNode node, int maxSeries, int maxDataPoints)
+ {
+ var result = new List();
+
+ if (!TryGetSequence(node, "series", out var seriesSeq))
+ return result;
+
+ if (seriesSeq.Children.Count > maxSeries)
+ {
+ throw new TemplateParseException(
+ $"Chart has {seriesSeq.Children.Count} series, which exceeds the maximum of {maxSeries}.");
+ }
+
+ foreach (var item in seriesSeq.Children)
+ {
+ if (item is not YamlMappingNode seriesNode)
+ throw new TemplateParseException("Each entry in 'series' must be a mapping with a 'data' field.");
+
+ result.Add(ParseOneSeries(seriesNode, maxDataPoints));
+ }
+
+ return result;
+ }
+
+ private static ChartSeries ParseOneSeries(YamlMappingNode seriesNode, int maxDataPoints)
+ {
+ var label = GetStringValue(seriesNode, "label");
+
+ // Inline array form.
+ if (TryGetSequence(seriesNode, "data", out var dataSeq))
+ {
+ if (dataSeq.Children.Count > maxDataPoints)
+ {
+ throw new TemplateParseException(
+ $"Series '{label ?? "(unlabeled)"}' has {dataSeq.Children.Count} data points, which exceeds the maximum of {maxDataPoints}.");
+ }
+
+ var values = new List(dataSeq.Children.Count);
+ foreach (var v in dataSeq.Children)
+ {
+ if (v is not YamlScalarNode scalar
+ || !double.TryParse(scalar.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var d)
+ || !double.IsFinite(d))
+ {
+ throw new TemplateParseException(
+ $"Series '{label ?? "(unlabeled)"}' contains a non-numeric data value '{(v as YamlScalarNode)?.Value}'.");
+ }
+ values.Add(d);
+ }
+ return ChartSeries.FromInline(label, values);
+ }
+
+ // Expression form (scalar string containing {{ }}).
+ var expr = GetStringValue(seriesNode, "data");
+ if (!string.IsNullOrWhiteSpace(expr))
+ return ChartSeries.FromExpression(label, expr);
+
+ // No data at all → empty inline series (renders as "no data" if all series empty).
+ return ChartSeries.FromInline(label, System.Array.Empty());
+ }
+}
+```
+
+- [ ] **Step 4: Register `chart` in TemplateParser**
+
+In `src/FlexRender.Yaml/Parsing/TemplateParser.cs`, in the `_elementParsers` dictionary initializer (the block ending at line 71 with the `draw` entry), add after the `["draw"] = ...` entry:
+
+```csharp
+ ["chart"] = node => ChartParsers.ParseChartElement(node, _limits.MaxSeriesPerChart, _limits.MaxDataPointsPerSeries)
+```
+
+(Ensure the preceding `draw` line ends with a comma.)
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartParsersTests"`
+Expected: PASS (5 cases).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/FlexRender.Yaml/Parsing/ChartParsers.cs src/FlexRender.Yaml/Parsing/TemplateParser.cs tests/FlexRender.Tests/Parsing/ChartParsersTests.cs
+git commit --no-gpg-sign -m "feat(parser): parse chart element with inline series, palettes, themes"
+```
+
+---
+
+## Task 12: KnownProperties — chart property set + typo suggestions
+
+**Files:**
+- Modify: `src/FlexRender.Yaml/Parsing/KnownProperties.cs` (add `Chart` set + registry entry)
+- Test: `tests/FlexRender.Tests/Parsing/ChartKnownPropertiesTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Parsing/ChartKnownPropertiesTests.cs`:
+
+```csharp
+using FlexRender.Parsing;
+using Xunit;
+
+namespace FlexRender.Tests.Parsing;
+
+///
+/// Tests for chart property validation and typo suggestions.
+///
+public sealed class ChartKnownPropertiesTests
+{
+ private readonly TemplateParser _parser = new();
+
+ [Fact]
+ public void Parse_TypoInChartType_SuggestsCorrection()
+ {
+ const string yaml = """
+ canvas:
+ width: 600
+ layout:
+ - type: chart
+ chart-typ: bar
+ width: 600
+ height: 300
+ series:
+ - data: [1, 2]
+ """;
+
+ var ex = Assert.Throws(() => _parser.Parse(yaml));
+ Assert.Contains("chart-type", ex.Message);
+ }
+
+ [Fact]
+ public void Parse_UnknownChartProperty_Throws()
+ {
+ const string yaml = """
+ canvas:
+ width: 600
+ layout:
+ - type: chart
+ chart-type: bar
+ width: 600
+ height: 300
+ bogus: 1
+ series:
+ - data: [1, 2]
+ """;
+
+ var ex = Assert.Throws(() => _parser.Parse(yaml));
+ Assert.Contains("bogus", ex.Message);
+ }
+
+ [Fact]
+ public void Parse_AllKnownChartProps_DoesNotThrow()
+ {
+ const string yaml = """
+ canvas:
+ width: 600
+ layout:
+ - type: chart
+ chart-type: bar
+ width: 600
+ height: 300
+ categories: [A, B]
+ series:
+ - label: x
+ data: [1, 2]
+ palette: ocean
+ theme: dark
+ legend: top
+ title: T
+ horizontal: true
+ stacked: true
+ smooth: false
+ points: false
+ labels: percent
+ """;
+
+ var template = _parser.Parse(yaml);
+ Assert.NotNull(template);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartKnownPropertiesTests"`
+Expected: FAIL — `chart` is not in the `Registry`, so validation is skipped and the typo/unknown-property tests do not throw.
+
+- [ ] **Step 3: Add the Chart property set + registry entry**
+
+In `src/FlexRender.Yaml/Parsing/KnownProperties.cs`, after the `Draw` set (around line 194) add:
+
+```csharp
+ ///
+ /// Known properties for the 'chart' element type.
+ ///
+ internal static readonly HashSet Chart = BuildSet(FlexItemProperties,
+ [
+ "chart-type", "categories", "series", "palette", "theme", "legend", "title",
+ "horizontal", "stacked", "smooth", "points", "labels",
+ "background", "rotate", "padding", "margin"
+ ]);
+```
+
+In the `Registry` dictionary initializer (around lines 199-217), add after the `["draw"] = Draw` entry (add a comma to that line):
+
+```csharp
+ ["chart"] = Chart
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartKnownPropertiesTests"`
+Expected: PASS (3 cases).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Yaml/Parsing/KnownProperties.cs tests/FlexRender.Tests/Parsing/ChartKnownPropertiesTests.cs
+git commit --no-gpg-sign -m "feat(parser): register chart known properties with typo suggestions"
+```
+
+---
+
+## Task 13: ChartElement — series-data expression → array binding (HIGH RISK)
+
+Override `ResolveExpressions` so that data-bound series resolve their `{{ expr }}` to a numeric `double[]` against the data context. Inline series pass through unchanged. Non-numeric values raise a clear template error.
+
+**Files:**
+- Modify: `src/FlexRender.Core/Parsing/Ast/ChartElement.cs`
+- Test: `tests/FlexRender.Tests/Parsing/Ast/ChartElementDataBindingTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Parsing/Ast/ChartElementDataBindingTests.cs`:
+
+```csharp
+using System.Collections.Generic;
+using FlexRender;
+using FlexRender.Charts;
+using FlexRender.Parsing.Ast;
+using FlexRender.TemplateEngine;
+using Xunit;
+
+namespace FlexRender.Tests.Parsing.Ast;
+
+///
+/// Tests for resolving data-bound chart series against the data context.
+///
+public sealed class ChartElementDataBindingTests
+{
+ private static string PassthroughResolver(string raw, ObjectValue data) => raw;
+
+ [Fact]
+ public void ResolveExpressions_BoundSeries_ResolvesArrayFromContext()
+ {
+ var chart = new ChartElement(ChartType.Bar, new List
+ {
+ ChartSeries.FromExpression("Sales", "{{ sales }}")
+ });
+
+ var data = new ObjectValue
+ {
+ ["sales"] = new ArrayValue(new TemplateValue[]
+ {
+ new NumberValue(12m), new NumberValue(30m), new NumberValue(22m)
+ })
+ };
+
+ chart.ResolveExpressions(PassthroughResolver, data);
+
+ Assert.Equal(new[] { 12d, 30d, 22d }, chart.Series[0].Data);
+ }
+
+ [Fact]
+ public void ResolveExpressions_InlineSeries_Unchanged()
+ {
+ var chart = new ChartElement(ChartType.Bar, new List
+ {
+ ChartSeries.FromInline("a", new[] { 1d, 2d, 3d })
+ });
+
+ chart.ResolveExpressions(PassthroughResolver, new ObjectValue());
+
+ Assert.Equal(new[] { 1d, 2d, 3d }, chart.Series[0].Data);
+ }
+
+ [Fact]
+ public void ResolveExpressions_MissingPath_ResolvesToEmptyData()
+ {
+ var chart = new ChartElement(ChartType.Line, new List
+ {
+ ChartSeries.FromExpression("x", "{{ nothere }}")
+ });
+
+ chart.ResolveExpressions(PassthroughResolver, new ObjectValue());
+
+ Assert.Empty(chart.Series[0].Data);
+ }
+
+ [Fact]
+ public void ResolveExpressions_NonNumericArrayItem_Throws()
+ {
+ var chart = new ChartElement(ChartType.Bar, new List
+ {
+ ChartSeries.FromExpression("Sales", "{{ sales }}")
+ });
+
+ var data = new ObjectValue
+ {
+ ["sales"] = new ArrayValue(new TemplateValue[]
+ {
+ new NumberValue(12m), new StringValue("oops")
+ })
+ };
+
+ var ex = Assert.Throws(() => chart.ResolveExpressions(PassthroughResolver, data));
+ Assert.Contains("Sales", ex.Message);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartElementDataBindingTests"`
+Expected: FAIL — base `ResolveExpressions` does not resolve series data (bound series remain empty).
+
+- [ ] **Step 3: Add the data-binding override**
+
+In `src/FlexRender.Core/Parsing/Ast/ChartElement.cs`, add these usings at the top (after the existing ones):
+
+```csharp
+using FlexRender.TemplateEngine;
+```
+
+Then add this override inside the class (after `CloneWithSubstitution`):
+
+```csharp
+ ///
+ public override void ResolveExpressions(Func resolver, ObjectValue data)
+ {
+ base.ResolveExpressions(resolver, data);
+ ArgumentNullException.ThrowIfNull(data);
+
+ var anyBound = false;
+ foreach (var s in Series)
+ {
+ if (s.DataExpression is not null)
+ {
+ anyBound = true;
+ break;
+ }
+ }
+
+ if (!anyBound)
+ return;
+
+ var context = new TemplateContext(data);
+ var resolved = new List(Series.Count);
+
+ foreach (var s in Series)
+ {
+ if (s.DataExpression is null)
+ {
+ resolved.Add(s);
+ continue;
+ }
+
+ var path = StripBraces(s.DataExpression);
+ var value = ExpressionEvaluator.Resolve(path, context);
+ resolved.Add(s.WithData(ConvertToDoubles(value, s.Label)));
+ }
+
+ SetSeries(resolved);
+ }
+
+ ///
+ /// Removes surrounding {{ }} braces and whitespace from a data expression, yielding the
+ /// inner path for . Non-wrapped input is returned trimmed.
+ ///
+ /// The raw expression (e.g. "{{ sales }}").
+ /// The inner path (e.g. "sales").
+ private static string StripBraces(string expression)
+ {
+ var span = expression.AsSpan().Trim();
+ if (span.StartsWith("{{") && span.EndsWith("}}"))
+ {
+ span = span[2..^2].Trim();
+ }
+ return span.ToString();
+ }
+
+ ///
+ /// Converts a resolved of numbers to a double array.
+ /// A non-array (e.g. missing path resolving to null) yields an empty array; a non-numeric
+ /// element raises a clear template error naming the series.
+ ///
+ /// The resolved template value.
+ /// The series label, for error messages.
+ /// The numeric values (possibly empty).
+ /// Thrown when an array element is not numeric.
+ private static IReadOnlyList ConvertToDoubles(TemplateValue value, string? label)
+ {
+ if (value is not ArrayValue array)
+ return Array.Empty();
+
+ var result = new double[array.Count];
+ for (var i = 0; i < array.Count; i++)
+ {
+ if (array[i] is NumberValue number)
+ {
+ result[i] = (double)number.Value;
+ }
+ else
+ {
+ throw new TemplateEngineException(
+ $"Chart series '{label ?? "(unlabeled)"}' data element at index {i} is not numeric " +
+ $"(got {array[i].GetType().Name}). Series data must resolve to an array of numbers.");
+ }
+ }
+ return result;
+ }
+```
+
+Note: confirm `ArrayValue` exposes an indexer (`this[int]`) and `Count` — verified in `src/FlexRender.Core/Values/ArrayValue.cs`. `NumberValue.Value` is a `decimal`.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartElementDataBindingTests"`
+Expected: PASS (4 cases).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Core/Parsing/Ast/ChartElement.cs tests/FlexRender.Tests/Parsing/Ast/ChartElementDataBindingTests.cs
+git commit --no-gpg-sign -m "feat(charts): resolve data-bound series to numeric arrays"
+```
+
+---
+
+## Task 14: ChartLayout — pure plot-area computation (HIGH RISK helper)
+
+Before drawing, compute the inner plot rectangle by subtracting space for title, legend, and axis labels. Pure math, renderer-agnostic, unit-tested.
+
+**Files:**
+- Create: `src/FlexRender.Core/Charts/ChartLayout.cs`
+- Test: `tests/FlexRender.Tests/Charts/ChartLayoutMathTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Charts/ChartLayoutMathTests.cs`:
+
+```csharp
+using FlexRender.Charts;
+using Xunit;
+
+namespace FlexRender.Tests.Charts;
+
+///
+/// Tests for the renderer-agnostic plot-area subdivision math.
+///
+public sealed class ChartLayoutMathTests
+{
+ [Fact]
+ public void ComputePlotArea_NoTitleNoLegend_LeavesAxisGutter()
+ {
+ var plot = ChartLayout.ComputePlotArea(
+ width: 600f, height: 300f,
+ hasTitle: false, legend: LegendPosition.None,
+ axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f);
+
+ Assert.Equal(40f, plot.Left);
+ Assert.Equal(0f, plot.Top);
+ Assert.Equal(600f - 40f, plot.Right);
+ Assert.Equal(300f - 24f, plot.Bottom);
+ }
+
+ [Fact]
+ public void ComputePlotArea_WithTitle_ReservesTopBand()
+ {
+ var plot = ChartLayout.ComputePlotArea(
+ width: 600f, height: 300f,
+ hasTitle: true, legend: LegendPosition.None,
+ axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f);
+
+ Assert.Equal(24f, plot.Top);
+ }
+
+ [Fact]
+ public void ComputePlotArea_BottomLegend_ReservesBottomBand()
+ {
+ var plot = ChartLayout.ComputePlotArea(
+ width: 600f, height: 300f,
+ hasTitle: false, legend: LegendPosition.Bottom,
+ axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f);
+
+ Assert.Equal(300f - 24f - 60f, plot.Bottom);
+ }
+
+ [Fact]
+ public void ComputePlotArea_RightLegend_ReservesRightBand()
+ {
+ var plot = ChartLayout.ComputePlotArea(
+ width: 600f, height: 300f,
+ hasTitle: false, legend: LegendPosition.Right,
+ axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f);
+
+ Assert.Equal(600f - 40f - 60f, plot.Right);
+ }
+
+ [Fact]
+ public void ComputePlotArea_DegenerateSize_DoesNotInvert()
+ {
+ var plot = ChartLayout.ComputePlotArea(
+ width: 30f, height: 20f,
+ hasTitle: true, legend: LegendPosition.Bottom,
+ axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f);
+
+ Assert.True(plot.Right >= plot.Left);
+ Assert.True(plot.Bottom >= plot.Top);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartLayoutMathTests"`
+Expected: BUILD FAILURE — `ChartLayout` / `PlotArea` not defined.
+
+- [ ] **Step 3: Implement ChartLayout**
+
+Create `src/FlexRender.Core/Charts/ChartLayout.cs`:
+
+```csharp
+using System;
+
+namespace FlexRender.Charts;
+
+///
+/// An axis-aligned plot rectangle in chart-local coordinates (origin at the chart box top-left).
+///
+/// The left edge.
+/// The top edge.
+/// The right edge.
+/// The bottom edge.
+public readonly record struct PlotArea(float Left, float Top, float Right, float Bottom)
+{
+ /// Gets the plot width.
+ public float Width => Right - Left;
+
+ /// Gets the plot height.
+ public float Height => Bottom - Top;
+}
+
+///
+/// Pure, renderer-agnostic computation of the chart plot area by subtracting reserved bands
+/// for the title, legend, and axis label gutters.
+///
+public static class ChartLayout
+{
+ ///
+ /// Computes the inner plot rectangle.
+ ///
+ /// The chart box width.
+ /// The chart box height.
+ /// Whether a title band is reserved at the top.
+ /// The legend position (reserves a band on the corresponding side).
+ /// The left gutter reserved for y-axis labels.
+ /// The bottom gutter reserved for x-axis labels.
+ /// The title band height when is true.
+ /// The legend band size (height for top/bottom, width for left/right).
+ /// The computed , never inverted.
+ public static PlotArea ComputePlotArea(
+ float width,
+ float height,
+ bool hasTitle,
+ LegendPosition legend,
+ float axisGutterLeft,
+ float axisGutterBottom,
+ float titleHeight,
+ float legendExtent)
+ {
+ var left = axisGutterLeft;
+ var top = hasTitle ? titleHeight : 0f;
+ var right = width;
+ var bottom = height - axisGutterBottom;
+
+ switch (legend)
+ {
+ case LegendPosition.Top:
+ top += legendExtent;
+ break;
+ case LegendPosition.Bottom:
+ bottom -= legendExtent;
+ break;
+ case LegendPosition.Left:
+ left += legendExtent;
+ break;
+ case LegendPosition.Right:
+ right -= legendExtent;
+ break;
+ case LegendPosition.None:
+ default:
+ break;
+ }
+
+ // Guard against inversion on tiny boxes.
+ right = Math.Max(right, left);
+ bottom = Math.Max(bottom, top);
+
+ return new PlotArea(left, top, right, bottom);
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartLayoutMathTests"`
+Expected: PASS (5 cases).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Core/Charts/ChartLayout.cs tests/FlexRender.Tests/Charts/ChartLayoutMathTests.cs
+git commit --no-gpg-sign -m "feat(charts): add plot-area subdivision math"
+```
+
+---
+
+## Task 15: ChartRenderer skeleton — dispatch + background + "no data" placeholder
+
+Establish the renderer entry point, wire it into `RenderingEngine`, and make an empty chart draw the "no data" placeholder. Series geometry comes in later tasks. Text is drawn with an `SKFont` built from a `FontManager` typeface (passed in by the engine), degrading to no labels when null.
+
+**Files:**
+- Create: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`
+- Modify: `src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs` (add `case ChartElement` near line 349)
+- Test: `tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs`:
+
+```csharp
+using System;
+using FlexRender.Parsing;
+using FlexRender.Parsing.Ast;
+using FlexRender.Rendering;
+using FlexRender.TemplateEngine;
+using SkiaSharp;
+using Xunit;
+
+namespace FlexRender.Tests.Rendering;
+
+///
+/// Smoke tests verifying charts are drawn (not merely laid out) by the Skia pipeline.
+///
+public sealed class ChartRenderSmokeTests : IDisposable
+{
+ private readonly SkiaRenderer _renderer = new();
+ private readonly TemplateParser _parser = new();
+
+ public void Dispose()
+ {
+ _renderer.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ [Fact]
+ public async Task EmptyChart_DrawsNoDataPlaceholderNotBlank()
+ {
+ const string yaml = """
+ canvas:
+ width: 200
+ height: 120
+ background: "#ffffff"
+ layout:
+ - type: chart
+ chart-type: bar
+ width: 200
+ height: 120
+ series: []
+ """;
+
+ var template = _parser.Parse(yaml);
+ using var bitmap = await Render(template, new ObjectValue());
+
+ Assert.True(HasNonBackgroundPixel(bitmap), "Expected the 'no data' placeholder to draw something.");
+ }
+
+ [Fact]
+ public async Task BarChart_WithData_DrawsColoredPixels()
+ {
+ const string yaml = """
+ canvas:
+ width: 300
+ height: 200
+ background: "#ffffff"
+ layout:
+ - type: chart
+ chart-type: bar
+ width: 300
+ height: 200
+ categories: [A, B, C]
+ series:
+ - data: [10, 20, 15]
+ legend: none
+ """;
+
+ var template = _parser.Parse(yaml);
+ using var bitmap = await Render(template, new ObjectValue());
+
+ Assert.True(HasNonBackgroundPixel(bitmap), "Expected the bar chart to draw something.");
+ }
+
+ private static bool HasNonBackgroundPixel(SKBitmap bitmap)
+ {
+ for (var y = 0; y < bitmap.Height; y += 3)
+ for (var x = 0; x < bitmap.Width; x += 3)
+ {
+ var p = bitmap.GetPixel(x, y);
+ if (p.Red < 240 || p.Green < 240 || p.Blue < 240)
+ return true;
+ }
+ return false;
+ }
+
+ private async Task Render(Template template, ObjectValue data)
+ {
+ var size = await _renderer.MeasureAsync(template, data);
+ var width = Math.Max((int)Math.Ceiling(size.Width), 1);
+ var height = Math.Max((int)Math.Ceiling(size.Height), 1);
+ var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul);
+ await _renderer.Render(bitmap, template, data, default, default);
+ return bitmap;
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartRenderSmokeTests"`
+Expected: FAIL — chart is not dispatched in `RenderingEngine`, so nothing draws (both tests fail on the blank-bitmap assertion). `BarChart_WithData` may also fail until Task 17.
+
+- [ ] **Step 3: Implement ChartRenderer skeleton**
+
+Create `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`:
+
+```csharp
+using System;
+using FlexRender.Charts;
+using FlexRender.Parsing.Ast;
+using SkiaSharp;
+
+namespace FlexRender.Rendering;
+
+///
+/// Draws instances to a SkiaSharp canvas. Computes the plot area
+/// (minus title/legend/axis gutters), then draws grid, axes, series geometry, and legend using
+/// the resolved theme and palette. Label text uses an supplied by the
+/// caller; when null, labels are skipped but geometry still draws.
+///
+internal static class ChartRenderer
+{
+ ///
+ /// Draws a chart into the given box.
+ ///
+ /// The canvas to draw on.
+ /// The chart element.
+ /// The chart box left edge.
+ /// The chart box top edge.
+ /// The chart box width.
+ /// The chart box height.
+ /// The typeface for labels, or null to skip labels.
+ /// Whether to anti-alias drawing.
+ /// Thrown when or is null.
+ internal static void Draw(
+ SKCanvas canvas,
+ ChartElement chart,
+ float x,
+ float y,
+ float width,
+ float height,
+ SKTypeface? typeface,
+ bool antialias)
+ {
+ ArgumentNullException.ThrowIfNull(canvas);
+ ArgumentNullException.ThrowIfNull(chart);
+
+ if (width <= 0f || height <= 0f)
+ return;
+
+ var theme = chart.Theme ?? ChartThemes.Default;
+
+ canvas.Save();
+ try
+ {
+ canvas.ClipRect(new SKRect(x, y, x + width, y + height));
+ canvas.Translate(x, y);
+
+ DrawChartBackground(canvas, theme, width, height, antialias);
+
+ if (!HasAnyData(chart))
+ {
+ DrawNoData(canvas, theme, width, height, typeface, antialias);
+ return;
+ }
+
+ // Series geometry is added in subsequent tasks (bar/line/area/pie/donut).
+ DrawSeries(canvas, chart, theme, width, height, typeface, antialias);
+ }
+ finally
+ {
+ canvas.Restore();
+ }
+ }
+
+ /// Returns whether any series has at least one data point.
+ private static bool HasAnyData(ChartElement chart)
+ {
+ foreach (var s in chart.Series)
+ {
+ if (s.Data.Count > 0)
+ return true;
+ }
+ return false;
+ }
+
+ /// Fills the chart background using the theme color (skipped when transparent).
+ private static void DrawChartBackground(SKCanvas canvas, ChartTheme theme, float width, float height, bool antialias)
+ {
+ if (string.IsNullOrEmpty(theme.BackgroundColor))
+ return;
+
+ using var paint = new SKPaint
+ {
+ Color = ColorParser.Parse(theme.BackgroundColor),
+ Style = SKPaintStyle.Fill,
+ IsAntialias = antialias
+ };
+ canvas.DrawRect(0f, 0f, width, height, paint);
+ }
+
+ ///
+ /// Draws a centred "No data" placeholder: a light dashed border plus centered text
+ /// (or just the border when no typeface is available).
+ ///
+ private static void DrawNoData(SKCanvas canvas, ChartTheme theme, float width, float height, SKTypeface? typeface, bool antialias)
+ {
+ var inset = MathF.Min(width, height) * 0.08f;
+ var rect = new SKRect(inset, inset, width - inset, height - inset);
+
+ using var border = new SKPaint
+ {
+ Color = ColorParser.Parse(theme.AxisColor),
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = 1.5f,
+ IsAntialias = antialias,
+ PathEffect = SKPathEffect.CreateDash(new[] { 6f, 4f }, 0f)
+ };
+ canvas.DrawRect(rect, border);
+
+ if (typeface is null)
+ return;
+
+ const string message = "No data";
+ using var font = new SKFont(typeface, theme.LabelSize);
+ using var textPaint = new SKPaint { Color = ColorParser.Parse(theme.LabelColor), IsAntialias = antialias };
+ var textWidth = font.MeasureText(message);
+ var tx = (width - textWidth) / 2f;
+ var ty = (height + theme.LabelSize) / 2f;
+ canvas.DrawText(message, tx, ty, SKTextAlign.Left, font, textPaint);
+ }
+
+ ///
+ /// Draws series geometry by chart type. Phase-2 bar geometry is added in Task 17; this
+ /// placeholder keeps the dispatch surface in place so smoke tests can run.
+ ///
+ private static void DrawSeries(
+ SKCanvas canvas,
+ ChartElement chart,
+ ChartTheme theme,
+ float width,
+ float height,
+ SKTypeface? typeface,
+ bool antialias)
+ {
+ // Filled in by subsequent tasks. Until then, draw a faint plot border so a chart with
+ // data is visibly non-blank in smoke tests.
+ using var border = new SKPaint
+ {
+ Color = ColorParser.Parse(theme.AxisColor),
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = 1f,
+ IsAntialias = antialias
+ };
+ canvas.DrawRect(0.5f, 0.5f, width - 1f, height - 1f, border);
+ }
+}
+```
+
+- [ ] **Step 4: Dispatch from RenderingEngine**
+
+In `src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs`, in the `switch (element)` inside `DrawElement`, add this arm after the `case DrawElement drawEl:` block (around line 349):
+
+```csharp
+ case ChartElement chart:
+ ChartRenderer.Draw(
+ canvas, chart, x, y, width, height,
+ _fontManager?.GetTypeface("main"),
+ renderOptions.Antialiasing);
+ break;
+```
+
+Note: `_fontManager` is a nullable field on `RenderingEngine`; `GetTypeface("main")` returns the registered chart label font (the snapshot harness registers "main"). When `_fontManager` is null, labels are skipped.
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartRenderSmokeTests"`
+Expected: PASS (2 cases) — both the placeholder and the data chart draw non-background pixels.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs
+git commit --no-gpg-sign -m "feat(renderer): dispatch chart element and draw no-data placeholder"
+```
+
+---
+
+## Task 16: ChartAxis helper — map data values to pixel coordinates
+
+A small pure helper to map a data value to a pixel Y within the plot, used by bar/line/area. Unit-tested so the rendering tasks stay simple.
+
+**Files:**
+- Create: `src/FlexRender.Core/Charts/ValueMapper.cs`
+- Test: `tests/FlexRender.Tests/Charts/ValueMapperTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Charts/ValueMapperTests.cs`:
+
+```csharp
+using FlexRender.Charts;
+using Xunit;
+
+namespace FlexRender.Tests.Charts;
+
+///
+/// Tests for mapping data values to pixel positions within a plot band.
+///
+public sealed class ValueMapperTests
+{
+ [Fact]
+ public void MapY_MinMapsToBottom_MaxMapsToTop()
+ {
+ // plot spans pixel Y 0 (top) .. 100 (bottom); scale 0..50.
+ var mapper = new ValueMapper(0d, 50d, plotTop: 0f, plotBottom: 100f);
+
+ Assert.Equal(100f, mapper.MapY(0d), 3);
+ Assert.Equal(0f, mapper.MapY(50d), 3);
+ Assert.Equal(50f, mapper.MapY(25d), 3);
+ }
+
+ [Fact]
+ public void MapY_ZeroBaseline_WhenScaleCrossesZero()
+ {
+ var mapper = new ValueMapper(-50d, 50d, plotTop: 0f, plotBottom: 100f);
+ Assert.Equal(50f, mapper.MapY(0d), 3);
+ }
+
+ [Fact]
+ public void MapY_DegenerateScale_DoesNotDivideByZero()
+ {
+ var mapper = new ValueMapper(10d, 10d, plotTop: 0f, plotBottom: 100f);
+ var yy = mapper.MapY(10d);
+ Assert.True(float.IsFinite(yy));
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ValueMapperTests"`
+Expected: BUILD FAILURE — `ValueMapper` not defined.
+
+- [ ] **Step 3: Implement ValueMapper**
+
+Create `src/FlexRender.Core/Charts/ValueMapper.cs`:
+
+```csharp
+namespace FlexRender.Charts;
+
+///
+/// Maps a numeric data value to a pixel Y within a plot band, where the scale minimum maps to
+/// the plot bottom and the scale maximum maps to the plot top (screen Y grows downward).
+///
+public readonly struct ValueMapper
+{
+ private readonly double _min;
+ private readonly double _max;
+ private readonly float _plotTop;
+ private readonly float _plotBottom;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The scale minimum (maps to the plot bottom).
+ /// The scale maximum (maps to the plot top).
+ /// The plot top pixel Y.
+ /// The plot bottom pixel Y.
+ public ValueMapper(double min, double max, float plotTop, float plotBottom)
+ {
+ _min = min;
+ _max = max;
+ _plotTop = plotTop;
+ _plotBottom = plotBottom;
+ }
+
+ ///
+ /// Maps a data value to its pixel Y within the plot band.
+ ///
+ /// The data value.
+ /// The pixel Y. Returns the plot bottom when the scale is degenerate.
+ public float MapY(double value)
+ {
+ var span = _max - _min;
+ if (span <= 0d)
+ return _plotBottom;
+
+ var t = (value - _min) / span;
+ return _plotBottom - (float)(t * (_plotBottom - _plotTop));
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ValueMapperTests"`
+Expected: PASS (3 cases).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Core/Charts/ValueMapper.cs tests/FlexRender.Tests/Charts/ValueMapperTests.cs
+git commit --no-gpg-sign -m "feat(charts): add value-to-pixel mapper"
+```
+
+---
+
+## Task 17: ChartRenderer — bar geometry (vertical + horizontal), grid, axes
+
+Replace the placeholder `DrawSeries` with real grid/axis/bar drawing for `ChartType.Bar`. Other types still fall through to the faint plot border for now.
+
+**Files:**
+- Modify: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`
+- Test: `tests/FlexRender.Tests/Rendering/ChartBarRenderTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Rendering/ChartBarRenderTests.cs`:
+
+```csharp
+using System;
+using System.Collections.Generic;
+using FlexRender.Charts;
+using FlexRender.Parsing.Ast;
+using FlexRender.Rendering;
+using SkiaSharp;
+using Xunit;
+
+namespace FlexRender.Tests.Rendering;
+
+///
+/// Verifies bar geometry is drawn with the palette color.
+///
+public sealed class ChartBarRenderTests
+{
+ [Fact]
+ public void VerticalBars_DrawPaletteColoredColumns()
+ {
+ var chart = new ChartElement(ChartType.Bar, new List
+ {
+ ChartSeries.FromInline("a", new[] { 10d, 20d, 30d })
+ })
+ {
+ Categories = new[] { "A", "B", "C" },
+ Legend = LegendPosition.None,
+ Palette = new ChartPalette(new[] { "#ff0000" }),
+ Theme = ChartThemes.Default
+ };
+
+ using var bitmap = new SKBitmap(300, 200, SKColorType.Rgba8888, SKAlphaType.Premul);
+ using var canvas = new SKCanvas(bitmap);
+ canvas.Clear(SKColors.White);
+
+ ChartRenderer.Draw(canvas, chart, 0f, 0f, 300f, 200f, typeface: null, antialias: true);
+
+ Assert.True(HasRedPixel(bitmap), "Expected red bar pixels.");
+ }
+
+ private static bool HasRedPixel(SKBitmap bitmap)
+ {
+ for (var y = 0; y < bitmap.Height; y += 2)
+ for (var x = 0; x < bitmap.Width; x += 2)
+ {
+ var p = bitmap.GetPixel(x, y);
+ if (p.Red > 200 && p.Green < 80 && p.Blue < 80)
+ return true;
+ }
+ return false;
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartBarRenderTests"`
+Expected: FAIL — no red bars yet (placeholder only draws a grey border).
+
+- [ ] **Step 3: Replace DrawSeries with bar/grid/axis drawing**
+
+In `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`, add `using System.Collections.Generic;` and `using FlexRender.Charts;` (the latter is already present). Replace the placeholder `DrawSeries` method with:
+
+```csharp
+ private static void DrawSeries(
+ SKCanvas canvas,
+ ChartElement chart,
+ ChartTheme theme,
+ float width,
+ float height,
+ SKTypeface? typeface,
+ bool antialias)
+ {
+ switch (chart.ChartType)
+ {
+ case ChartType.Bar:
+ DrawBars(canvas, chart, theme, width, height, typeface, antialias);
+ break;
+ default:
+ // Other chart types are added in later tasks.
+ using (var border = new SKPaint
+ {
+ Color = ColorParser.Parse(theme.AxisColor),
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = 1f,
+ IsAntialias = antialias
+ })
+ {
+ canvas.DrawRect(0.5f, 0.5f, width - 1f, height - 1f, border);
+ }
+ break;
+ }
+ }
+
+ /// Computes the combined data min/max across all series.
+ private static (double Min, double Max) DataBounds(ChartElement chart)
+ {
+ var min = double.MaxValue;
+ var max = double.MinValue;
+ foreach (var s in chart.Series)
+ {
+ foreach (var v in s.Data)
+ {
+ if (v < min) min = v;
+ if (v > max) max = v;
+ }
+ }
+ if (min == double.MaxValue)
+ return (0d, 1d);
+ return (min, max);
+ }
+
+ /// Draws horizontal grid lines and y-axis tick labels for the given scale.
+ private static void DrawGridAndYAxis(
+ SKCanvas canvas, ChartTheme theme, in PlotArea plot, AxisScale scale,
+ SKTypeface? typeface, bool antialias)
+ {
+ var mapper = new ValueMapper(scale.Min, scale.Max, plot.Top, plot.Bottom);
+
+ using var grid = new SKPaint
+ {
+ Color = ColorParser.Parse(theme.GridColor),
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = 1f,
+ IsAntialias = antialias
+ };
+
+ SKFont? font = null;
+ SKPaint? labelPaint = null;
+ if (typeface is not null)
+ {
+ font = new SKFont(typeface, theme.LabelSize);
+ labelPaint = new SKPaint { Color = ColorParser.Parse(theme.LabelColor), IsAntialias = antialias };
+ }
+
+ try
+ {
+ foreach (var tick in scale.Ticks)
+ {
+ var ty = mapper.MapY(tick);
+ canvas.DrawLine(plot.Left, ty, plot.Right, ty, grid);
+
+ if (font is not null && labelPaint is not null)
+ {
+ var label = FormatTick(tick);
+ var tw = font.MeasureText(label);
+ canvas.DrawText(label, plot.Left - tw - 4f, ty + (theme.LabelSize / 3f), SKTextAlign.Left, font, labelPaint);
+ }
+ }
+ }
+ finally
+ {
+ font?.Dispose();
+ labelPaint?.Dispose();
+ }
+ }
+
+ /// Draws x-axis category labels centred under each category slot.
+ private static void DrawCategoryLabels(
+ SKCanvas canvas, ChartElement chart, ChartTheme theme, in PlotArea plot,
+ SKTypeface? typeface, bool antialias)
+ {
+ if (typeface is null || chart.Categories.Count == 0)
+ return;
+
+ using var font = new SKFont(typeface, theme.LabelSize);
+ using var paint = new SKPaint { Color = ColorParser.Parse(theme.LabelColor), IsAntialias = antialias };
+
+ var slot = plot.Width / chart.Categories.Count;
+ for (var i = 0; i < chart.Categories.Count; i++)
+ {
+ var label = chart.Categories[i];
+ var tw = font.MeasureText(label);
+ var cx = plot.Left + (slot * (i + 0.5f));
+ canvas.DrawText(label, cx - (tw / 2f), plot.Bottom + theme.LabelSize + 2f, SKTextAlign.Left, font, paint);
+ }
+ }
+
+ /// Draws a vertical or horizontal bar chart with grid and axis labels.
+ private static void DrawBars(
+ SKCanvas canvas, ChartElement chart, ChartTheme theme,
+ float width, float height, SKTypeface? typeface, bool antialias)
+ {
+ var (dataMin, dataMax) = DataBounds(chart);
+ var scale = AxisScale.Compute(dataMin, dataMax, targetTicks: 5);
+
+ var hasTitle = !string.IsNullOrEmpty(chart.Title);
+ var plot = ChartLayout.ComputePlotArea(
+ width, height, hasTitle, chart.Legend,
+ axisGutterLeft: 44f, axisGutterBottom: 22f, titleHeight: theme.TitleSize + 8f, legendExtent: 28f);
+
+ DrawGridAndYAxis(canvas, theme, plot, scale, typeface, antialias);
+
+ var palette = chart.Palette ?? ChartPalettes.Default;
+ var mapper = new ValueMapper(scale.Min, scale.Max, plot.Top, plot.Bottom);
+ var zeroY = mapper.MapY(0d);
+
+ var seriesCount = chart.Series.Count;
+ if (seriesCount == 0)
+ return;
+
+ // Determine category count from the longest series.
+ var categoryCount = 0;
+ foreach (var s in chart.Series)
+ categoryCount = Math.Max(categoryCount, s.Data.Count);
+ if (categoryCount == 0)
+ return;
+
+ var groupSlot = plot.Width / categoryCount;
+ var groupPadding = groupSlot * 0.15f;
+ var barAreaWidth = groupSlot - (2f * groupPadding);
+ var barWidth = barAreaWidth / seriesCount;
+
+ for (var si = 0; si < seriesCount; si++)
+ {
+ var data = chart.Series[si].Data;
+ using var paint = new SKPaint
+ {
+ Color = ColorParser.Parse(palette.ColorAt(si)),
+ Style = SKPaintStyle.Fill,
+ IsAntialias = antialias
+ };
+
+ for (var ci = 0; ci < data.Count; ci++)
+ {
+ var value = data[ci];
+ var valueY = mapper.MapY(value);
+ var barLeft = plot.Left + (groupSlot * ci) + groupPadding + (barWidth * si);
+ var top = Math.Min(valueY, zeroY);
+ var bottom = Math.Max(valueY, zeroY);
+ var rect = new SKRect(barLeft, top, barLeft + barWidth, bottom);
+
+ if (theme.BarCornerRadius > 0f)
+ canvas.DrawRoundRect(rect, theme.BarCornerRadius, theme.BarCornerRadius, paint);
+ else
+ canvas.DrawRect(rect, paint);
+ }
+ }
+
+ DrawCategoryLabels(canvas, chart, theme, plot, typeface, antialias);
+ }
+
+ /// Formats a tick value compactly, trimming trailing zeros.
+ private static string FormatTick(double value)
+ {
+ if (value == Math.Floor(value) && Math.Abs(value) < 1e15)
+ return ((long)value).ToString(System.Globalization.CultureInfo.InvariantCulture);
+ return value.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture);
+ }
+```
+
+(Note: the spec's `horizontal: true` is honoured visually in Task 18; this task lands the vertical default plus all shared grid/axis infrastructure.)
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartBarRenderTests"`
+Expected: PASS.
+
+- [ ] **Step 5: Run the chart smoke tests to confirm no regression**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartRenderSmokeTests"`
+Expected: PASS (2 cases).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs tests/FlexRender.Tests/Rendering/ChartBarRenderTests.cs
+git commit --no-gpg-sign -m "feat(renderer): draw vertical bar charts with grid and axes"
+```
+
+---
+
+## Task 18: ChartRenderer — horizontal bars
+
+**Files:**
+- Modify: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`
+- Test: `tests/FlexRender.Tests/Rendering/ChartHorizontalBarRenderTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Rendering/ChartHorizontalBarRenderTests.cs`:
+
+```csharp
+using System.Collections.Generic;
+using FlexRender.Charts;
+using FlexRender.Parsing.Ast;
+using FlexRender.Rendering;
+using SkiaSharp;
+using Xunit;
+
+namespace FlexRender.Tests.Rendering;
+
+///
+/// Verifies horizontal bars extend along the X axis from the left baseline.
+///
+public sealed class ChartHorizontalBarRenderTests
+{
+ [Fact]
+ public void HorizontalBars_DrawWiderForLargerValues()
+ {
+ var chart = new ChartElement(ChartType.Bar, new List
+ {
+ ChartSeries.FromInline("a", new[] { 5d, 40d })
+ })
+ {
+ Categories = new[] { "A", "B" },
+ Legend = LegendPosition.None,
+ Horizontal = true,
+ Palette = new ChartPalette(new[] { "#0000ff" }),
+ Theme = ChartThemes.Default
+ };
+
+ using var bitmap = new SKBitmap(300, 200, SKColorType.Rgba8888, SKAlphaType.Premul);
+ using var canvas = new SKCanvas(bitmap);
+ canvas.Clear(SKColors.White);
+
+ ChartRenderer.Draw(canvas, chart, 0f, 0f, 300f, 200f, typeface: null, antialias: true);
+
+ Assert.True(CountBlueInRow(bitmap, 150) >= CountBlueInRow(bitmap, 60),
+ "Expected the larger value's bar (lower row) to be at least as wide as the smaller value's bar.");
+ }
+
+ private static int CountBlueInRow(SKBitmap bitmap, int y)
+ {
+ var count = 0;
+ for (var x = 0; x < bitmap.Width; x++)
+ {
+ var p = bitmap.GetPixel(x, y);
+ if (p.Blue > 200 && p.Red < 80 && p.Green < 80)
+ count++;
+ }
+ return count;
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartHorizontalBarRenderTests"`
+Expected: FAIL — horizontal layout not yet implemented (bars still vertical).
+
+- [ ] **Step 3: Branch DrawBars on chart.Horizontal**
+
+In `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`, replace the body of `DrawBars` so that, after computing `scale`, `plot`, and `palette`, it dispatches to a vertical or horizontal layout. Replace the existing `DrawBars` method with:
+
+```csharp
+ private static void DrawBars(
+ SKCanvas canvas, ChartElement chart, ChartTheme theme,
+ float width, float height, SKTypeface? typeface, bool antialias)
+ {
+ var (dataMin, dataMax) = DataBounds(chart);
+ var scale = AxisScale.Compute(dataMin, dataMax, targetTicks: 5);
+
+ var hasTitle = !string.IsNullOrEmpty(chart.Title);
+ var plot = ChartLayout.ComputePlotArea(
+ width, height, hasTitle, chart.Legend,
+ axisGutterLeft: 44f, axisGutterBottom: 22f, titleHeight: theme.TitleSize + 8f, legendExtent: 28f);
+
+ var palette = chart.Palette ?? ChartPalettes.Default;
+
+ var seriesCount = chart.Series.Count;
+ if (seriesCount == 0)
+ return;
+
+ var categoryCount = 0;
+ foreach (var s in chart.Series)
+ categoryCount = Math.Max(categoryCount, s.Data.Count);
+ if (categoryCount == 0)
+ return;
+
+ if (chart.Horizontal)
+ {
+ DrawHorizontalBars(canvas, chart, theme, plot, scale, palette, seriesCount, categoryCount, antialias);
+ }
+ else
+ {
+ DrawGridAndYAxis(canvas, theme, plot, scale, typeface, antialias);
+ DrawVerticalBars(canvas, chart, theme, plot, scale, palette, seriesCount, categoryCount, antialias);
+ DrawCategoryLabels(canvas, chart, theme, plot, typeface, antialias);
+ }
+ }
+
+ private static void DrawVerticalBars(
+ SKCanvas canvas, ChartElement chart, ChartTheme theme, in PlotArea plot, AxisScale scale,
+ ChartPalette palette, int seriesCount, int categoryCount, bool antialias)
+ {
+ var mapper = new ValueMapper(scale.Min, scale.Max, plot.Top, plot.Bottom);
+ var zeroY = mapper.MapY(0d);
+
+ var groupSlot = plot.Width / categoryCount;
+ var groupPadding = groupSlot * 0.15f;
+ var barAreaWidth = groupSlot - (2f * groupPadding);
+ var barWidth = barAreaWidth / seriesCount;
+
+ for (var si = 0; si < seriesCount; si++)
+ {
+ var data = chart.Series[si].Data;
+ using var paint = new SKPaint
+ {
+ Color = ColorParser.Parse(palette.ColorAt(si)),
+ Style = SKPaintStyle.Fill,
+ IsAntialias = antialias
+ };
+
+ for (var ci = 0; ci < data.Count; ci++)
+ {
+ var valueY = mapper.MapY(data[ci]);
+ var barLeft = plot.Left + (groupSlot * ci) + groupPadding + (barWidth * si);
+ var rect = new SKRect(barLeft, Math.Min(valueY, zeroY), barLeft + barWidth, Math.Max(valueY, zeroY));
+
+ if (theme.BarCornerRadius > 0f)
+ canvas.DrawRoundRect(rect, theme.BarCornerRadius, theme.BarCornerRadius, paint);
+ else
+ canvas.DrawRect(rect, paint);
+ }
+ }
+ }
+
+ private static void DrawHorizontalBars(
+ SKCanvas canvas, ChartElement chart, ChartTheme theme, in PlotArea plot, AxisScale scale,
+ ChartPalette palette, int seriesCount, int categoryCount, bool antialias)
+ {
+ // For horizontal bars the value axis is X: min -> plot.Left, max -> plot.Right.
+ var xSpan = scale.Max - scale.Min;
+ float MapX(double value) => xSpan <= 0d
+ ? plot.Left
+ : plot.Left + (float)(((value - scale.Min) / xSpan) * plot.Width);
+ var zeroX = MapX(0d);
+
+ var groupSlot = plot.Height / categoryCount;
+ var groupPadding = groupSlot * 0.15f;
+ var barAreaHeight = groupSlot - (2f * groupPadding);
+ var barHeight = barAreaHeight / seriesCount;
+
+ for (var si = 0; si < seriesCount; si++)
+ {
+ var data = chart.Series[si].Data;
+ using var paint = new SKPaint
+ {
+ Color = ColorParser.Parse(palette.ColorAt(si)),
+ Style = SKPaintStyle.Fill,
+ IsAntialias = antialias
+ };
+
+ for (var ci = 0; ci < data.Count; ci++)
+ {
+ var valueX = MapX(data[ci]);
+ var barTop = plot.Top + (groupSlot * ci) + groupPadding + (barHeight * si);
+ var rect = new SKRect(Math.Min(valueX, zeroX), barTop, Math.Max(valueX, zeroX), barTop + barHeight);
+
+ if (theme.BarCornerRadius > 0f)
+ canvas.DrawRoundRect(rect, theme.BarCornerRadius, theme.BarCornerRadius, paint);
+ else
+ canvas.DrawRect(rect, paint);
+ }
+ }
+ }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartHorizontalBarRenderTests"`
+Expected: PASS.
+
+- [ ] **Step 5: Confirm vertical bars still pass**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartBarRenderTests"`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs tests/FlexRender.Tests/Rendering/ChartHorizontalBarRenderTests.cs
+git commit --no-gpg-sign -m "feat(renderer): support horizontal bar charts"
+```
+
+---
+
+## Task 19: ChartRenderer — line and area charts
+
+**Files:**
+- Modify: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`
+- Test: `tests/FlexRender.Tests/Rendering/ChartLineAreaRenderTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Rendering/ChartLineAreaRenderTests.cs`:
+
+```csharp
+using System.Collections.Generic;
+using FlexRender.Charts;
+using FlexRender.Parsing.Ast;
+using FlexRender.Rendering;
+using SkiaSharp;
+using Xunit;
+
+namespace FlexRender.Tests.Rendering;
+
+///
+/// Verifies line and area charts draw with the palette color.
+///
+public sealed class ChartLineAreaRenderTests
+{
+ private static ChartElement Make(ChartType type) => new(type, new List
+ {
+ ChartSeries.FromInline("a", new[] { 10d, 30d, 20d, 40d })
+ })
+ {
+ Categories = new[] { "A", "B", "C", "D" },
+ Legend = LegendPosition.None,
+ Palette = new ChartPalette(new[] { "#00aa00" }),
+ Theme = ChartThemes.Default
+ };
+
+ [Fact]
+ public void Line_DrawsGreenPixels()
+ {
+ var chart = Make(ChartType.Line);
+ Assert.True(Render(chart), "Expected green line pixels.");
+ }
+
+ [Fact]
+ public void Area_DrawsGreenPixels()
+ {
+ var chart = Make(ChartType.Area);
+ Assert.True(Render(chart), "Expected green area pixels.");
+ }
+
+ private static bool Render(ChartElement chart)
+ {
+ using var bitmap = new SKBitmap(300, 200, SKColorType.Rgba8888, SKAlphaType.Premul);
+ using var canvas = new SKCanvas(bitmap);
+ canvas.Clear(SKColors.White);
+
+ ChartRenderer.Draw(canvas, chart, 0f, 0f, 300f, 200f, typeface: null, antialias: true);
+
+ for (var y = 0; y < bitmap.Height; y += 2)
+ for (var x = 0; x < bitmap.Width; x += 2)
+ {
+ var p = bitmap.GetPixel(x, y);
+ if (p.Green > 120 && p.Red < 120 && p.Blue < 120)
+ return true;
+ }
+ return false;
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartLineAreaRenderTests"`
+Expected: FAIL — line/area fall through to the grey-border default.
+
+- [ ] **Step 3: Add line/area cases and drawing**
+
+In `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`, in `DrawSeries`, add cases before `default:`:
+
+```csharp
+ case ChartType.Line:
+ DrawLineOrArea(canvas, chart, theme, width, height, typeface, fillArea: false, antialias);
+ break;
+ case ChartType.Area:
+ DrawLineOrArea(canvas, chart, theme, width, height, typeface, fillArea: true, antialias);
+ break;
+```
+
+Then add these methods to the class:
+
+```csharp
+ /// Draws a line chart, optionally filling the area under each series.
+ private static void DrawLineOrArea(
+ SKCanvas canvas, ChartElement chart, ChartTheme theme,
+ float width, float height, SKTypeface? typeface, bool fillArea, bool antialias)
+ {
+ var (dataMin, dataMax) = DataBounds(chart);
+ var scale = AxisScale.Compute(dataMin, dataMax, targetTicks: 5);
+
+ var hasTitle = !string.IsNullOrEmpty(chart.Title);
+ var plot = ChartLayout.ComputePlotArea(
+ width, height, hasTitle, chart.Legend,
+ axisGutterLeft: 44f, axisGutterBottom: 22f, titleHeight: theme.TitleSize + 8f, legendExtent: 28f);
+
+ DrawGridAndYAxis(canvas, theme, plot, scale, typeface, antialias);
+
+ var palette = chart.Palette ?? ChartPalettes.Default;
+ var mapper = new ValueMapper(scale.Min, scale.Max, plot.Top, plot.Bottom);
+ var zeroY = mapper.MapY(0d);
+
+ for (var si = 0; si < chart.Series.Count; si++)
+ {
+ var data = chart.Series[si].Data;
+ if (data.Count == 0)
+ continue;
+
+ var color = ColorParser.Parse(palette.ColorAt(si));
+ var step = data.Count > 1 ? plot.Width / (data.Count - 1) : 0f;
+
+ float X(int i) => data.Count > 1 ? plot.Left + (step * i) : plot.Left + (plot.Width / 2f);
+
+ using var linePath = new SKPath();
+ linePath.MoveTo(X(0), mapper.MapY(data[0]));
+ for (var i = 1; i < data.Count; i++)
+ linePath.LineTo(X(i), mapper.MapY(data[i]));
+
+ if (fillArea)
+ {
+ using var areaPath = new SKPath();
+ areaPath.MoveTo(X(0), zeroY);
+ for (var i = 0; i < data.Count; i++)
+ areaPath.LineTo(X(i), mapper.MapY(data[i]));
+ areaPath.LineTo(X(data.Count - 1), zeroY);
+ areaPath.Close();
+
+ var fillColor = color.WithAlpha(70);
+ using var areaPaint = new SKPaint { Color = fillColor, Style = SKPaintStyle.Fill, IsAntialias = antialias };
+ canvas.DrawPath(areaPath, areaPaint);
+ }
+
+ using var linePaint = new SKPaint
+ {
+ Color = color,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = theme.LineWidth,
+ IsAntialias = antialias,
+ StrokeCap = SKStrokeCap.Round,
+ StrokeJoin = SKStrokeJoin.Round
+ };
+ canvas.DrawPath(linePath, linePaint);
+
+ if (chart.ShowPoints)
+ {
+ using var pointPaint = new SKPaint { Color = color, Style = SKPaintStyle.Fill, IsAntialias = antialias };
+ for (var i = 0; i < data.Count; i++)
+ canvas.DrawCircle(X(i), mapper.MapY(data[i]), theme.LineWidth + 1f, pointPaint);
+ }
+ }
+
+ DrawCategoryLabels(canvas, chart, theme, plot, typeface, antialias);
+ }
+```
+
+Note: `smooth` (curved lines) is intentionally simplified to straight segments in this phase; the property is parsed and accepted but uses polyline geometry. This keeps the geometry robust; smoothing can be a follow-up. The `smooth` property remains registered and parsed so templates are valid.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartLineAreaRenderTests"`
+Expected: PASS (2 cases).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs tests/FlexRender.Tests/Rendering/ChartLineAreaRenderTests.cs
+git commit --no-gpg-sign -m "feat(renderer): draw line and area charts"
+```
+
+---
+
+## Task 20: ChartRenderer — pie and donut charts
+
+**Files:**
+- Modify: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`
+- Test: `tests/FlexRender.Tests/Rendering/ChartPieRenderTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Rendering/ChartPieRenderTests.cs`:
+
+```csharp
+using System.Collections.Generic;
+using FlexRender.Charts;
+using FlexRender.Parsing.Ast;
+using FlexRender.Rendering;
+using SkiaSharp;
+using Xunit;
+
+namespace FlexRender.Tests.Rendering;
+
+///
+/// Verifies pie and donut charts draw slices, and the donut leaves a hollow center.
+///
+public sealed class ChartPieRenderTests
+{
+ private static ChartElement Make(ChartType type) => new(type, new List
+ {
+ ChartSeries.FromInline(null, new[] { 30d, 50d, 20d })
+ })
+ {
+ Categories = new[] { "A", "B", "C" },
+ Legend = LegendPosition.None,
+ Palette = new ChartPalette(new[] { "#ff0000", "#00ff00", "#0000ff" }),
+ Theme = ChartThemes.Default,
+ PieLabels = PieLabelMode.None
+ };
+
+ [Fact]
+ public void Pie_DrawsColoredSlices()
+ {
+ using var bitmap = Render(Make(ChartType.Pie));
+ Assert.True(HasColor(bitmap, redDominant: true), "Expected red slice pixels.");
+ }
+
+ [Fact]
+ public void Donut_LeavesHollowCenter()
+ {
+ using var bitmap = Render(Make(ChartType.Donut));
+ var center = bitmap.GetPixel(bitmap.Width / 2, bitmap.Height / 2);
+ Assert.True(center.Red > 230 && center.Green > 230 && center.Blue > 230,
+ $"Expected white donut center, got {center}.");
+ }
+
+ private static SKBitmap Render(ChartElement chart)
+ {
+ var bitmap = new SKBitmap(200, 200, SKColorType.Rgba8888, SKAlphaType.Premul);
+ using var canvas = new SKCanvas(bitmap);
+ canvas.Clear(SKColors.White);
+ ChartRenderer.Draw(canvas, chart, 0f, 0f, 200f, 200f, typeface: null, antialias: true);
+ return bitmap;
+ }
+
+ private static bool HasColor(SKBitmap bitmap, bool redDominant)
+ {
+ for (var y = 0; y < bitmap.Height; y += 2)
+ for (var x = 0; x < bitmap.Width; x += 2)
+ {
+ var p = bitmap.GetPixel(x, y);
+ if (redDominant && p.Red > 200 && p.Green < 120 && p.Blue < 120)
+ return true;
+ }
+ return false;
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartPieRenderTests"`
+Expected: FAIL — pie/donut fall through to the grey-border default.
+
+- [ ] **Step 3: Add pie/donut cases and drawing**
+
+In `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`, in `DrawSeries`, add cases before `default:`:
+
+```csharp
+ case ChartType.Pie:
+ DrawPie(canvas, chart, theme, width, height, typeface, isDonut: false, antialias);
+ break;
+ case ChartType.Donut:
+ DrawPie(canvas, chart, theme, width, height, typeface, isDonut: true, antialias);
+ break;
+```
+
+Then add this method to the class:
+
+```csharp
+ ///
+ /// Draws a pie or donut chart from the first series' values. Slices are proportional to each
+ /// value's share of the total; donut leaves a hollow center.
+ ///
+ private static void DrawPie(
+ SKCanvas canvas, ChartElement chart, ChartTheme theme,
+ float width, float height, SKTypeface? typeface, bool isDonut, bool antialias)
+ {
+ if (chart.Series.Count == 0)
+ return;
+
+ var data = chart.Series[0].Data;
+ var total = 0d;
+ foreach (var v in data)
+ {
+ if (v > 0d)
+ total += v;
+ }
+ if (total <= 0d)
+ {
+ DrawNoData(canvas, theme, width, height, typeface, antialias);
+ return;
+ }
+
+ var hasTitle = !string.IsNullOrEmpty(chart.Title);
+ var top = hasTitle ? theme.TitleSize + 8f : 0f;
+ var legendReserve = chart.Legend == LegendPosition.Bottom ? 28f : 0f;
+ var availH = height - top - legendReserve;
+ var availW = width;
+
+ var diameter = MathF.Min(availW, availH) * 0.85f;
+ var radius = diameter / 2f;
+ var cx = width / 2f;
+ var cy = top + (availH / 2f);
+
+ var palette = chart.Palette ?? ChartPalettes.Default;
+ var bounds = new SKRect(cx - radius, cy - radius, cx + radius, cy + radius);
+
+ var startAngle = -90f; // start at 12 o'clock
+ for (var i = 0; i < data.Count; i++)
+ {
+ if (data[i] <= 0d)
+ continue;
+
+ var sweep = (float)(data[i] / total * 360d);
+ using var slice = new SKPath();
+ slice.MoveTo(cx, cy);
+ slice.ArcTo(bounds, startAngle, sweep, forceMoveTo: false);
+ slice.Close();
+
+ using var paint = new SKPaint
+ {
+ Color = ColorParser.Parse(palette.ColorAt(i)),
+ Style = SKPaintStyle.Fill,
+ IsAntialias = antialias
+ };
+ canvas.DrawPath(slice, paint);
+
+ startAngle += sweep;
+ }
+
+ if (isDonut)
+ {
+ using var hole = new SKPaint
+ {
+ Color = string.IsNullOrEmpty(theme.BackgroundColor)
+ ? SKColors.White
+ : ColorParser.Parse(theme.BackgroundColor),
+ Style = SKPaintStyle.Fill,
+ IsAntialias = antialias
+ };
+ canvas.DrawCircle(cx, cy, radius * 0.55f, hole);
+ }
+ }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartPieRenderTests"`
+Expected: PASS (2 cases).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs tests/FlexRender.Tests/Rendering/ChartPieRenderTests.cs
+git commit --no-gpg-sign -m "feat(renderer): draw pie and donut charts"
+```
+
+---
+
+## Task 21: ChartRenderer — title and legend
+
+**Files:**
+- Modify: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`
+- Test: `tests/FlexRender.Tests/Rendering/ChartLegendTitleRenderTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Rendering/ChartLegendTitleRenderTests.cs`. This test renders WITH a typeface (loaded from the snapshot Inter font) so title/legend text actually draws; it asserts pixels appear in the title band and legend band.
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection;
+using FlexRender.Charts;
+using FlexRender.Parsing.Ast;
+using FlexRender.Rendering;
+using SkiaSharp;
+using Xunit;
+
+namespace FlexRender.Tests.Rendering;
+
+///
+/// Verifies the title band and bottom legend render text when a typeface is available.
+///
+public sealed class ChartLegendTitleRenderTests
+{
+ [Fact]
+ public void TitleAndLegend_DrawTextInReservedBands()
+ {
+ using var typeface = LoadInter();
+ Assert.NotNull(typeface);
+
+ var chart = new ChartElement(ChartType.Bar, new List
+ {
+ ChartSeries.FromInline("Series One", new[] { 10d, 20d, 30d })
+ })
+ {
+ Categories = new[] { "A", "B", "C" },
+ Legend = LegendPosition.Bottom,
+ Title = "Revenue",
+ Palette = new ChartPalette(new[] { "#3366cc" }),
+ Theme = ChartThemes.Default
+ };
+
+ using var bitmap = new SKBitmap(320, 240, SKColorType.Rgba8888, SKAlphaType.Premul);
+ using var canvas = new SKCanvas(bitmap);
+ canvas.Clear(SKColors.White);
+
+ ChartRenderer.Draw(canvas, chart, 0f, 0f, 320f, 240f, typeface, antialias: true);
+
+ Assert.True(HasDarkPixelInBand(bitmap, 0, 24), "Expected title text near the top band.");
+ Assert.True(HasDarkPixelInBand(bitmap, 240 - 24, 240), "Expected legend text near the bottom band.");
+ }
+
+ private static bool HasDarkPixelInBand(SKBitmap bitmap, int yStart, int yEnd)
+ {
+ for (var y = Math.Max(0, yStart); y < Math.Min(bitmap.Height, yEnd); y++)
+ for (var x = 0; x < bitmap.Width; x++)
+ {
+ var p = bitmap.GetPixel(x, y);
+ if (p.Red < 150 && p.Green < 150 && p.Blue < 150)
+ return true;
+ }
+ return false;
+ }
+
+ private static SKTypeface? LoadInter()
+ {
+ var asmDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
+ var current = asmDir;
+ while (!string.IsNullOrEmpty(current))
+ {
+ if (Directory.GetFiles(current, "*.csproj").Length > 0)
+ {
+ var fontPath = Path.Combine(current, "Snapshots", "Fonts", "Inter-Regular.ttf");
+ return File.Exists(fontPath) ? SKTypeface.FromFile(fontPath) : null;
+ }
+ current = Directory.GetParent(current)?.FullName;
+ }
+ return null;
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartLegendTitleRenderTests"`
+Expected: FAIL — title/legend text is not yet drawn.
+
+- [ ] **Step 3: Draw title and legend**
+
+In `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`, at the end of the `DrawSeries` switch dispatch (in the `Draw` method, after the `DrawSeries(...)` call inside the `else`/data branch), add calls to draw the title and legend. Update the data branch of `Draw` so it reads:
+
+```csharp
+ // Series geometry per chart-type.
+ DrawSeries(canvas, chart, theme, width, height, typeface, antialias);
+ DrawTitle(canvas, chart, theme, width, typeface, antialias);
+ DrawLegend(canvas, chart, theme, width, height, typeface, antialias);
+```
+
+Then add these two methods to the class:
+
+```csharp
+ /// Draws the centred chart title in the reserved top band, when present.
+ private static void DrawTitle(
+ SKCanvas canvas, ChartElement chart, ChartTheme theme, float width, SKTypeface? typeface, bool antialias)
+ {
+ if (typeface is null || string.IsNullOrEmpty(chart.Title))
+ return;
+
+ using var font = new SKFont(typeface, theme.TitleSize);
+ using var paint = new SKPaint { Color = ColorParser.Parse(theme.TitleColor), IsAntialias = antialias };
+ var tw = font.MeasureText(chart.Title);
+ canvas.DrawText(chart.Title, (width - tw) / 2f, theme.TitleSize + 2f, SKTextAlign.Left, font, paint);
+ }
+
+ ///
+ /// Draws a simple legend (colored swatch + series label) for each labeled series.
+ /// Supports the bottom legend band; other positions reserve space but use the same row layout.
+ ///
+ private static void DrawLegend(
+ SKCanvas canvas, ChartElement chart, ChartTheme theme,
+ float width, float height, SKTypeface? typeface, bool antialias)
+ {
+ if (typeface is null || chart.Legend == LegendPosition.None)
+ return;
+
+ using var font = new SKFont(typeface, theme.LabelSize);
+ using var textPaint = new SKPaint { Color = ColorParser.Parse(theme.LabelColor), IsAntialias = antialias };
+
+ var palette = chart.Palette ?? ChartPalettes.Default;
+ const float swatch = 10f;
+ const float gap = 6f;
+ const float itemGap = 16f;
+
+ // Build entries: for pie/donut use categories, otherwise use series labels.
+ var labels = new List();
+ if (chart.ChartType is ChartType.Pie or ChartType.Donut)
+ {
+ foreach (var c in chart.Categories)
+ labels.Add(c);
+ }
+ else
+ {
+ for (var i = 0; i < chart.Series.Count; i++)
+ labels.Add(chart.Series[i].Label ?? $"Series {i + 1}");
+ }
+
+ if (labels.Count == 0)
+ return;
+
+ // Measure total width for centring along the bottom.
+ var totalWidth = 0f;
+ foreach (var label in labels)
+ totalWidth += swatch + gap + font.MeasureText(label) + itemGap;
+ totalWidth -= itemGap;
+
+ var startX = (width - totalWidth) / 2f;
+ var rowY = chart.Legend == LegendPosition.Top
+ ? theme.LabelSize + 4f
+ : height - (theme.LabelSize / 2f) - 4f;
+
+ var x = startX;
+ for (var i = 0; i < labels.Count; i++)
+ {
+ using (var swatchPaint = new SKPaint
+ {
+ Color = ColorParser.Parse(palette.ColorAt(i)),
+ Style = SKPaintStyle.Fill,
+ IsAntialias = antialias
+ })
+ {
+ canvas.DrawRect(x, rowY - swatch, swatch, swatch, swatchPaint);
+ }
+
+ x += swatch + gap;
+ canvas.DrawText(labels[i], x, rowY, SKTextAlign.Left, font, textPaint);
+ x += font.MeasureText(labels[i]) + itemGap;
+ }
+ }
+```
+
+(Note: `DrawTitle`/`DrawLegend` are also safe to call for pie/donut because the plot-area reservation in `ChartLayout`/`DrawPie` already accounts for the title and bottom legend bands.)
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartLegendTitleRenderTests"`
+Expected: PASS.
+
+- [ ] **Step 5: Confirm prior chart render tests still pass**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartBarRenderTests|FullyQualifiedName~ChartLineAreaRenderTests|FullyQualifiedName~ChartPieRenderTests|FullyQualifiedName~ChartRenderSmokeTests"`
+Expected: PASS (all).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs tests/FlexRender.Tests/Rendering/ChartLegendTitleRenderTests.cs
+git commit --no-gpg-sign -m "feat(renderer): draw chart title and legend"
+```
+
+---
+
+## Task 22: Snapshot goldens — chart types × themes
+
+Add golden snapshot tests covering each chart type and theme. The Inter font is auto-registered by `SnapshotTestBase`.
+
+**Files:**
+- Create: `tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs`
+- Create golden PNGs under `tests/FlexRender.Tests/Snapshots/golden/` (generated via UPDATE_SNAPSHOTS)
+
+- [ ] **Step 1: Write the snapshot tests**
+
+Create `tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs`:
+
+```csharp
+using FlexRender;
+using Xunit;
+
+namespace FlexRender.Tests.Snapshots;
+
+///
+/// Golden-image snapshot tests for charts (types × themes).
+///
+public sealed class ChartSnapshotTests : SnapshotTestBase
+{
+ [Fact]
+ public async Task BarChart_Light()
+ {
+ var template = Parser.Parse("""
+ canvas:
+ width: 600
+ background: "#ffffff"
+ layout:
+ - type: chart
+ chart-type: bar
+ width: 600
+ height: 320
+ categories: [Q1, Q2, Q3, Q4]
+ series:
+ - label: "2024"
+ data: [12, 30, 22, 48]
+ title: Revenue
+ legend: bottom
+ palette: ocean
+ """);
+ await AssertSnapshot("chart_bar_light", template, new ObjectValue());
+ }
+
+ [Fact]
+ public async Task BarChart_HorizontalDark()
+ {
+ var template = Parser.Parse("""
+ canvas:
+ width: 600
+ background: "#1e1e1e"
+ layout:
+ - type: chart
+ chart-type: bar
+ width: 600
+ height: 320
+ horizontal: true
+ categories: [A, B, C, D]
+ series:
+ - data: [5, 40, 25, 60]
+ theme: dark
+ legend: none
+ palette: vivid
+ """);
+ await AssertSnapshot("chart_bar_horizontal_dark", template, new ObjectValue());
+ }
+
+ [Fact]
+ public async Task LineChart_Minimal()
+ {
+ var template = Parser.Parse("""
+ canvas:
+ width: 600
+ background: "#ffffff"
+ layout:
+ - type: chart
+ chart-type: line
+ width: 600
+ height: 300
+ categories: [Mon, Tue, Wed, Thu, Fri]
+ series:
+ - label: Visitors
+ data: [120, 200, 150, 280, 240]
+ - label: Signups
+ data: [20, 45, 30, 60, 50]
+ theme: minimal
+ points: true
+ legend: bottom
+ """);
+ await AssertSnapshot("chart_line_minimal", template, new ObjectValue());
+ }
+
+ [Fact]
+ public async Task AreaChart_Light()
+ {
+ var template = Parser.Parse("""
+ canvas:
+ width: 600
+ background: "#ffffff"
+ layout:
+ - type: chart
+ chart-type: area
+ width: 600
+ height: 300
+ categories: [Jan, Feb, Mar, Apr]
+ series:
+ - data: [30, 60, 45, 80]
+ legend: none
+ palette: forest
+ """);
+ await AssertSnapshot("chart_area_light", template, new ObjectValue());
+ }
+
+ [Fact]
+ public async Task PieChart_Light()
+ {
+ var template = Parser.Parse("""
+ canvas:
+ width: 400
+ background: "#ffffff"
+ layout:
+ - type: chart
+ chart-type: pie
+ width: 400
+ height: 360
+ categories: [Direct, Social, Search]
+ series:
+ - data: [30, 50, 20]
+ legend: bottom
+ palette: sunset
+ """);
+ await AssertSnapshot("chart_pie_light", template, new ObjectValue());
+ }
+
+ [Fact]
+ public async Task DonutChart_Dark()
+ {
+ var template = Parser.Parse("""
+ canvas:
+ width: 400
+ background: "#1e1e1e"
+ layout:
+ - type: chart
+ chart-type: donut
+ width: 400
+ height: 360
+ categories: [A, B, C, D]
+ series:
+ - data: [10, 20, 30, 40]
+ theme: dark
+ legend: bottom
+ palette: ocean
+ """);
+ await AssertSnapshot("chart_donut_dark", template, new ObjectValue());
+ }
+
+ [Fact]
+ public async Task EmptyChart_NoDataPlaceholder()
+ {
+ var template = Parser.Parse("""
+ canvas:
+ width: 300
+ background: "#ffffff"
+ layout:
+ - type: chart
+ chart-type: bar
+ width: 300
+ height: 180
+ series: []
+ """);
+ await AssertSnapshot("chart_no_data", template, new ObjectValue());
+ }
+}
+```
+
+- [ ] **Step 2: Generate the golden images**
+
+Run: `UPDATE_SNAPSHOTS=true dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSnapshotTests"`
+Expected: PASS (goldens written under `tests/FlexRender.Tests/Snapshots/golden/chart_*.png`). Visually inspect each generated PNG to confirm it looks like a correct chart before committing.
+
+- [ ] **Step 3: Re-run without update to verify deterministic match**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSnapshotTests"`
+Expected: PASS (7 cases) against the committed goldens.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs tests/FlexRender.Tests/Snapshots/golden/chart_*.png
+git commit --no-gpg-sign -m "test(charts): add golden snapshots for chart types and themes"
+```
+
+---
+
+## Task 23: Full build + test gate
+
+- [ ] **Step 1: Build the whole solution**
+
+Run: `dotnet build FlexRender.slnx`
+Expected: BUILD SUCCEEDED, zero warnings (`TreatWarningsAsErrors=true`).
+
+- [ ] **Step 2: Run the entire test suite**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0`
+Expected: PASS — all existing tests plus the new chart tests. If any pre-existing test regressed, fix it before proceeding.
+
+- [ ] **Step 3: Commit (only if any fix was required)**
+
+```bash
+git add -A
+git commit --no-gpg-sign -m "fix(charts): address full-suite regressions"
+```
+
+(If nothing needed fixing, skip this commit.)
+
+---
+
+## Task 24: Docs — llms.txt and llms-full.txt
+
+**Files:**
+- Modify: `llms.txt`
+- Modify: `llms-full.txt`
+
+- [ ] **Step 1: Add chart docs to llms.txt**
+
+Open `llms.txt`, find the element-types section (search for where `draw`/`rect` are documented from Phase 1). Add a concise `chart` entry immediately after the shape entries:
+
+```
+### chart
+Declarative chart element. Properties:
+- chart-type: bar | line | area | pie | donut (default bar)
+- width / height: required pixel size
+- categories: [..] x-axis / slice labels
+- series: list of { label?, data } where data is an inline number array OR "{{ expr }}" resolving to a number array
+- palette: named (default|ocean|sunset|forest|mono|vivid) OR explicit ["#hex", ...]
+- theme: light | dark | minimal (per-element override of template theme)
+- legend: top | bottom | left | right | none (default bottom)
+- title: optional string
+- bar only: horizontal (bool), stacked (bool)
+- line/area: smooth (bool), points (bool)
+- pie/donut: labels (percent|value|none)
+Empty/missing series renders a "no data" placeholder, never an error.
+```
+
+- [ ] **Step 2: Add full chart docs to llms-full.txt**
+
+Open `llms-full.txt`, find the shapes section from Phase 1, and add a full `chart` subsection after it documenting every property (mirroring the table above), the data-binding behavior (series data resolves like table rows), the palette/theme systems, and a complete YAML example:
+
+```yaml
+- type: chart
+ chart-type: bar
+ width: 600
+ height: 300
+ categories: [Q1, Q2, Q3, Q4]
+ series:
+ - label: "2024"
+ data: "{{ sales }}"
+ - label: "2025"
+ data: [12, 30, 22, 48]
+ palette: ocean
+ legend: bottom
+ title: "Revenue"
+ theme: dark
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add llms.txt llms-full.txt
+git commit --no-gpg-sign -m "docs: document chart element in llms.txt and llms-full.txt"
+```
+
+---
+
+## Task 25: Docs — wiki Element-Reference and Visual-Reference
+
+**Files:**
+- Modify: `docs/wiki/Element-Reference.md`
+- Modify: `docs/wiki/Visual-Reference.md`
+
+- [ ] **Step 1: Add the chart element to Element-Reference.md**
+
+Open `docs/wiki/Element-Reference.md`, find the shapes section added in Phase 1, and add a `## chart` section with a properties table:
+
+| Property | Type | Default | Description |
+|---|---|---|---|
+| `chart-type` | string | `bar` | `bar`, `line`, `area`, `pie`, `donut` |
+| `width` / `height` | number | — | Pixel dimensions (required) |
+| `categories` | list | `[]` | X-axis categories / slice labels |
+| `series` | list | `[]` | Each `{ label?, data }`; `data` is an inline array or `{{ expr }}` |
+| `palette` | string or list | theme default | Named palette or explicit color list |
+| `theme` | string | template theme | `light`, `dark`, `minimal` |
+| `legend` | string | `bottom` | `top`, `bottom`, `left`, `right`, `none` |
+| `title` | string | — | Optional title |
+| `horizontal` | bool | `false` | Bar only |
+| `stacked` | bool | `false` | Bar only |
+| `smooth` | bool | `false` | Line/area |
+| `points` | bool | `false` | Line/area markers |
+| `labels` | string | `percent` | Pie/donut: `percent`, `value`, `none` |
+
+- [ ] **Step 2: Add chart examples to Visual-Reference.md**
+
+Open `docs/wiki/Visual-Reference.md` and add a "Charts" section with the YAML snippets used by the snapshot tests (bar, line, area, pie, donut) so readers can copy working examples. Reference the golden image filenames from Task 22 as the expected output thumbnails.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add docs/wiki/Element-Reference.md docs/wiki/Visual-Reference.md
+git commit --no-gpg-sign -m "docs(wiki): document chart element reference and visual examples"
+```
+
+---
+
+## Task 26: Docs — Playground schema + autocomplete
+
+**Files:**
+- Modify: `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json`
+- Modify: `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs`
+
+- [ ] **Step 1: Add chart to the JSON schema**
+
+Open `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json`. Locate how Phase-1 elements (`rect`/`draw`) were added to the element `oneOf`/`type` enum and per-type property definitions. Add `"chart"` to the element `type` enum and a chart property schema block mirroring the others: `chart-type` enum (`bar`,`line`,`area`,`pie`,`donut`), `categories` (array of strings), `series` (array of objects with `label` string + `data` array-or-string), `palette` (string or array), `theme` enum (`light`,`dark`,`minimal`), `legend` enum (`top`,`bottom`,`left`,`right`,`none`), `title` string, `horizontal`/`stacked`/`smooth`/`points` booleans, `labels` enum (`percent`,`value`,`none`).
+
+- [ ] **Step 2: Add chart to autocomplete**
+
+Open `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs`. Find where Phase-1 element types and their properties were registered for completion suggestions. Add a `chart` entry listing the same property names as the schema so the playground autocompletes chart properties.
+
+- [ ] **Step 3: Verify the schema is valid JSON**
+
+Run: `node -e "JSON.parse(require('fs').readFileSync('src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json','utf8')); console.log('valid')"`
+Expected: prints `valid`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs
+git commit --no-gpg-sign -m "docs(playground): add chart to schema and autocomplete"
+```
+
+---
+
+## Task 27: Final verification
+
+- [ ] **Step 1: Clean build**
+
+Run: `dotnet build FlexRender.slnx`
+Expected: BUILD SUCCEEDED, zero warnings.
+
+- [ ] **Step 2: Full test suite**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0`
+Expected: PASS — all tests green, including the new chart unit, smoke, render, and snapshot tests.
+
+- [ ] **Step 3: Confirm git log is clean and conventional**
+
+Run: `git log --oneline -25`
+Expected: a sequence of `feat(...)`/`test(...)`/`docs(...)` commits with no attribution lines.
+
+- [ ] **Step 4: Domain checklist self-audit**
+
+Confirm by inspection:
+- [ ] AOT-safe: no reflection, no `dynamic`, no runtime regex in chart code (none added).
+- [ ] All new concrete classes are `sealed` (`ChartElement`, `ChartSeries`, `ChartPalette`); static helpers are `static`; records are `sealed record` / `readonly record struct`.
+- [ ] Guard clauses use `ArgumentNullException.ThrowIfNull` (ChartElement, ChartSeries, ChartPalette, ChartRenderer).
+- [ ] Element dispatch is switch-based (`RenderingEngine.DrawElement`, layout switch arms).
+- [ ] Every new YAML property is in `KnownProperties.Chart`.
+- [ ] XML docs on all public API (enums, AxisScale, ChartPalette(s), ChartTheme(s), ChartLayout, ValueMapper, ChartSeries, ChartElement).
+- [ ] Resource limits added, not weakened (`MaxSeriesPerChart`, `MaxDataPointsPerSeries`); enforced in `ChartParsers`.
+- [ ] Snapshot tests added for visual changes (Task 22).
+
+---
+
+## Self-Review
+
+**Spec coverage (Phase-2 requirements → task):**
+- `chart` element + `chart-type` bar/line/area/pie/donut → Tasks 9, 11, 17–20.
+- Bar `horizontal` → Task 18; `stacked` → parsed (Task 11) and accepted; grouped/stacked rendering: bars are grouped (Task 17/18); full stacking is parsed but rendered as grouped in this phase (acceptable simplification — flagged; `stacked` property remains valid).
+- Line/area `smooth`, `points` → Task 19 (`points` rendered; `smooth` parsed/accepted, rendered as straight segments — flagged simplification).
+- Pie/donut `labels` → parsed (Task 11), enum/property registered; slice labels are governed by `PieLabelMode` and default to not drawing text when no typeface — drawing slice-percentage text is a follow-up (the geometry and label mode plumbing are complete).
+- Themes (light/dark/minimal) + template-level/per-element override → Tasks 7, 11 (per-element `theme`); template-level theme inheritance falls back to `ChartThemes.Default` when unset (per-element override is the primary surface).
+- Palettes (named + explicit list) → Tasks 6, 11.
+- Axes + nice ticks → Tasks 3, 4, 16, 17.
+- Grid → Task 17. Legend → Task 21. Title → Task 21.
+- "No data" placeholder → Task 15.
+- Data binding (series expression → array, like table rows) → Task 13.
+- Error handling: unknown chart-type/property typo suggestions → Tasks 11, 12; non-numeric data error with context → Tasks 11 (inline), 13 (bound).
+- Resource limits → Task 1.
+- Docs (llms, wiki, playground) → Tasks 24–26.
+
+**Known phase-scoped simplifications (intentional, flagged above):** `stacked` renders as grouped; `smooth` renders as straight segments; pie/donut slice-value/percent text labels are plumbed (`PieLabelMode`) but text drawing of slice labels is a follow-up. None of these block a polished default chart; all corresponding properties parse and validate so templates remain valid. If the reviewing maintainer wants full stacking/smoothing/slice-labels inside Phase 2, add three follow-up tasks mirroring Tasks 17/19/20 with the same test-first rhythm.
+
+**Placeholder scan:** No "TBD"/"implement later" in code steps; every code step contains complete compilable C#. Doc tasks (24–26) describe exact insertions and reference real files; their content is prose/markdown/JSON, not code under test.
+
+**Type consistency:** `ChartElement(ChartType, IReadOnlyList)`, `ChartSeries.FromInline/FromExpression/WithData`, `ChartPalette(IReadOnlyList)`/`ColorAt(int)`, `ChartPalettes.Resolve`/`.Default`, `ChartThemes.Resolve`/`.Default`, `ChartTheme` record fields, `AxisScale.Compute`/`.Ticks`/`.Min`/`.Max`/`.Step`, `ChartLayout.ComputePlotArea`/`PlotArea`, `ValueMapper(min,max,plotTop,plotBottom).MapY`, and `ChartRenderer.Draw(canvas, chart, x, y, width, height, typeface, antialias)` are used consistently across Tasks 3–22. Layout switch arms reuse the existing `MeasureShapeIntrinsic`/`LayoutShapeElement` helpers (Task 10).
diff --git a/docs/superpowers/plans/2026-06-13-charts-phase3.md b/docs/superpowers/plans/2026-06-13-charts-phase3.md
new file mode 100644
index 0000000..703f674
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-13-charts-phase3.md
@@ -0,0 +1,2291 @@
+# Charts (Phase 3) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Extend the `chart` element with five new chart-types — `scatter`, `bubble`, `gauge`, `progress`, `sparkline` — so LLM agents can render XY point clouds, sized-bubble plots, single-value dial/ring indicators, and tiny inline sparklines from declarative YAML, with zero styling decisions.
+
+**Architecture:** Phase 2 series carry a flat `IReadOnlyList Data`. Scatter/bubble need XY(R) tuples, so `ChartSeries` gains a parallel `IReadOnlyList Points` representation (`ChartPoint` is a new `readonly record struct (double X, double Y, double R)` in `FlexRender.Core/Charts/`). Flat-data series remain byte-for-byte unchanged — `Points` is empty for them and `Data` is empty for tuple series. `ChartElement` gains `Value`/`Max`/`ValueLabel` for the single-value gauge/progress indicators. Parsing (`ChartParsers`) grows a tuple-aware data path (detecting array-of-arrays) plus `value`/`max`/`label` parsing; all new YAML props register in `KnownProperties.Chart`. Rendering adds five `case` arms to `ChartRenderer.DrawSeries`: scatter/bubble compute an X *and* Y `AxisScale` (a small local `MapX` mirrors the existing `ValueMapper.MapY`), gauge/progress draw arcs via `SKPath.AddArc`/`SKCanvas.DrawArc`, and sparkline reuses the existing `BuildSeriesPath` with no chrome. Expression-bound data stays numeric-array only this phase; tuple data is inline-only (a follow-up note covers array-of-arrays binding).
+
+**Tech Stack:** .NET 10, C# latest, xUnit, SkiaSharp, YamlDotNet. AOT-safe (no reflection, no `dynamic`, no runtime regex), `sealed` classes, `ArgumentNullException.ThrowIfNull`, switch-based dispatch, XML docs on all public API.
+
+---
+
+## Conventions used throughout this plan
+
+- All commands run from repo root `/Users/robonet/Projects/SkiaLayout`.
+- Branch is already `feature/charts-and-shapes`. Do NOT create worktrees. Do NOT merge to `main`.
+- Build: `dotnet build FlexRender.slnx`. Test (authoritative, net10.0): `dotnet test FlexRender.slnx --framework net10.0 --filter ...`. The net8.0 test host cannot launch in this environment — always pass `--framework net10.0`.
+- NEVER pipe `dotnet` output through `tail`/`head`/`grep`. Run commands directly.
+- Commit messages use Conventional Commits, NO attribution / Co-Authored-By lines. The signing key is missing — every commit uses `--no-gpg-sign`.
+- After every code edit the build must be warning-free (`TreatWarningsAsErrors=true`). Watch for CA1859 (return concrete types where the concrete type is used internally), unused-parameter, and unused-using analyzer errors.
+- All new public API gets XML docs. All new YAML props get registered in `KnownProperties.Chart`. Never weaken `ResourceLimits`.
+- Snapshot goldens are generated by running the snapshot test once with `UPDATE_SNAPSHOTS=true dotnet test FlexRender.slnx --framework net10.0 --filter ...`, then committed.
+
+## File structure (created / modified across all tasks)
+
+Created:
+- `src/FlexRender.Core/Charts/ChartPoint.cs` (XY(R) tuple struct)
+- Test files (one per task, see each task)
+
+Modified:
+- `src/FlexRender.Core/Charts/ChartEnums.cs` (add `Scatter`, `Bubble`, `Gauge`, `Progress`, `Sparkline` to `ChartType`)
+- `src/FlexRender.Core/Parsing/Ast/ChartSeries.cs` (add `Points`, `FromPoints`, keep `WithData`/`FromInline`/`FromExpression`)
+- `src/FlexRender.Core/Parsing/Ast/ChartElement.cs` (add `Value`/`Max`/`ValueLabel` props; clone them)
+- `src/FlexRender.Yaml/Parsing/ChartParsers.cs` (tuple data path; `value`/`max`/`label`; chart-type error message)
+- `src/FlexRender.Yaml/Parsing/KnownProperties.cs` (add `value`, `max`, `label` to `Chart` set)
+- `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs` (5 new `DrawSeries` arms + draw methods)
+- Docs: `llms.txt`, `llms-full.txt`, `docs/wiki/Element-Reference.md`, `docs/wiki/Visual-Reference.md`,
+ `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json`,
+ `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs`
+
+## Highest-risk tasks (flagged for extra review)
+
+1. **Task 2–3 — scatter/bubble tuple-data representation + parsing.** `ChartSeries.Points` must coexist cleanly with flat `Data` (Phase-2 series untouched), and `ChartParsers` must detect array-of-arrays vs flat-array and validate tuple arity (2 for scatter, 2-or-3 for bubble), non-numeric, and non-finite, naming the series in every error.
+2. **Task 8 — scatter/bubble X-axis scaling.** The codebase only scales Y (`ValueMapper.MapY`). Scatter/bubble need a *second* `AxisScale` over the X data range and a local `MapX` mirroring `MapY`. Degenerate spans (single point, identical X) must not divide by zero.
+3. **Task 9–10 — gauge/progress arc geometry.** Arc start/sweep angles, clamping `value` into `[0, max]`, donut-style ring thickness, and the centered value label must be correct and dispose all `SKPath`/`SKPaint`/`SKFont` with balanced `Save`/`Restore`.
+
+---
+
+## Task 1: Extend ChartType enum with the five Phase-3 types
+
+**Files:**
+- Modify: `src/FlexRender.Core/Charts/ChartEnums.cs`
+- Test: `tests/FlexRender.Tests/Charts/ChartTypePhase3Tests.cs` (create)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Charts/ChartTypePhase3Tests.cs`:
+
+```csharp
+using System;
+using FlexRender.Charts;
+using Xunit;
+
+namespace FlexRender.Tests.Charts;
+
+///
+/// Sanity tests for the Phase-3 chart type enum members.
+///
+public sealed class ChartTypePhase3Tests
+{
+ [Theory]
+ [InlineData("Scatter")]
+ [InlineData("Bubble")]
+ [InlineData("Gauge")]
+ [InlineData("Progress")]
+ [InlineData("Sparkline")]
+ public void ChartType_HasAllPhase3Members(string name)
+ {
+ Assert.True(Enum.TryParse(name, out _));
+ }
+
+ [Theory]
+ [InlineData("Bar")]
+ [InlineData("Line")]
+ [InlineData("Area")]
+ [InlineData("Pie")]
+ [InlineData("Donut")]
+ public void ChartType_StillHasPhase2Members(string name)
+ {
+ Assert.True(Enum.TryParse(name, out _));
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartTypePhase3Tests"`
+Expected: FAIL — `Scatter`/`Bubble`/`Gauge`/`Progress`/`Sparkline` not defined (the Phase-3 cases parse to false).
+
+- [ ] **Step 3: Add the enum members**
+
+In `src/FlexRender.Core/Charts/ChartEnums.cs`, replace the `ChartType` enum body so the closing `Donut` line ends with a comma and the new members follow (keep the existing five members and their docs unchanged):
+
+```csharp
+public enum ChartType
+{
+ /// Vertical (or horizontal) bar chart.
+ Bar,
+
+ /// Line chart.
+ Line,
+
+ /// Filled area chart.
+ Area,
+
+ /// Pie chart.
+ Pie,
+
+ /// Donut (ring) chart.
+ Donut,
+
+ /// XY scatter plot ([x, y] tuples).
+ Scatter,
+
+ /// Bubble plot ([x, y, r] tuples; the third value sizes the bubble).
+ Bubble,
+
+ /// Single-value arc/dial gauge.
+ Gauge,
+
+ /// Single-value progress ring.
+ Progress,
+
+ /// Tiny inline line chart with no axes, labels, or legend.
+ Sparkline
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartTypePhase3Tests"`
+Expected: PASS (10 cases).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Core/Charts/ChartEnums.cs tests/FlexRender.Tests/Charts/ChartTypePhase3Tests.cs
+git commit --no-gpg-sign -m "feat(charts): add scatter/bubble/gauge/progress/sparkline chart types"
+```
+
+---
+
+## Task 2: ChartPoint struct + ChartSeries.Points representation
+
+The flat `Data` path is untouched. Tuple series carry a parallel `Points` list; flat series leave `Points` empty and vice versa.
+
+**Files:**
+- Create: `src/FlexRender.Core/Charts/ChartPoint.cs`
+- Modify: `src/FlexRender.Core/Parsing/Ast/ChartSeries.cs`
+- Test: `tests/FlexRender.Tests/Charts/ChartPointTests.cs` (create)
+- Test: `tests/FlexRender.Tests/Parsing/Ast/ChartSeriesPointsTests.cs` (create)
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `tests/FlexRender.Tests/Charts/ChartPointTests.cs`:
+
+```csharp
+using FlexRender.Charts;
+using Xunit;
+
+namespace FlexRender.Tests.Charts;
+
+///
+/// Tests for the XY(R) tuple struct.
+///
+public sealed class ChartPointTests
+{
+ [Fact]
+ public void Constructor_StoresXYR()
+ {
+ var p = new ChartPoint(1.5d, 2.5d, 3.5d);
+ Assert.Equal(1.5d, p.X);
+ Assert.Equal(2.5d, p.Y);
+ Assert.Equal(3.5d, p.R);
+ }
+
+ [Fact]
+ public void Xy_DefaultsRadiusToZero()
+ {
+ var p = ChartPoint.Xy(4d, 5d);
+ Assert.Equal(4d, p.X);
+ Assert.Equal(5d, p.Y);
+ Assert.Equal(0d, p.R);
+ }
+}
+```
+
+Create `tests/FlexRender.Tests/Parsing/Ast/ChartSeriesPointsTests.cs`:
+
+```csharp
+using System;
+using System.Collections.Generic;
+using FlexRender.Charts;
+using FlexRender.Parsing.Ast;
+using Xunit;
+
+namespace FlexRender.Tests.Parsing.Ast;
+
+///
+/// Tests for the tuple-point representation on .
+///
+public sealed class ChartSeriesPointsTests
+{
+ [Fact]
+ public void FromPoints_StoresLabelAndPoints_DataEmpty()
+ {
+ var points = new List { ChartPoint.Xy(1d, 2d), ChartPoint.Xy(3d, 4d) };
+ var series = ChartSeries.FromPoints("xy", points);
+
+ Assert.Equal("xy", series.Label);
+ Assert.Equal(2, series.Points.Count);
+ Assert.Equal(1d, series.Points[0].X);
+ Assert.Empty(series.Data);
+ Assert.Null(series.DataExpression);
+ }
+
+ [Fact]
+ public void FromInline_LeavesPointsEmpty()
+ {
+ var series = ChartSeries.FromInline("flat", new[] { 1d, 2d, 3d });
+ Assert.Empty(series.Points);
+ Assert.Equal(3, series.Data.Count);
+ }
+
+ [Fact]
+ public void FromPoints_NullPoints_Throws()
+ {
+ Assert.Throws(() => ChartSeries.FromPoints("x", null!));
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartPointTests|FullyQualifiedName~ChartSeriesPointsTests"`
+Expected: BUILD FAILURE — `ChartPoint` and `ChartSeries.FromPoints`/`Points` not defined.
+
+- [ ] **Step 3: Create ChartPoint**
+
+Create `src/FlexRender.Core/Charts/ChartPoint.cs`:
+
+```csharp
+namespace FlexRender.Charts;
+
+///
+/// A single XY data point with an optional radius, used by scatter ([x, y]) and bubble
+/// ([x, y, r]) series. Renderer-agnostic; is zero for scatter points.
+///
+/// The X coordinate in data space.
+/// The Y coordinate in data space.
+/// The bubble radius in data space (zero for scatter points).
+public readonly record struct ChartPoint(double X, double Y, double R)
+{
+ ///
+ /// Creates a point with no radius (scatter).
+ ///
+ /// The X coordinate.
+ /// The Y coordinate.
+ /// A with set to zero.
+ public static ChartPoint Xy(double x, double y) => new(x, y, 0d);
+}
+```
+
+- [ ] **Step 4: Add Points to ChartSeries**
+
+In `src/FlexRender.Core/Parsing/Ast/ChartSeries.cs`:
+
+1. Add `using FlexRender.Charts;` to the using block.
+
+2. Add an empty-points sentinel next to the existing `Empty` field:
+
+```csharp
+ private static readonly IReadOnlyList EmptyPoints = Array.Empty();
+```
+
+3. Replace the private constructor to also accept points:
+
+```csharp
+ private ChartSeries(string? label, string? dataExpression, IReadOnlyList data, IReadOnlyList points)
+ {
+ Label = label;
+ DataExpression = dataExpression;
+ Data = data;
+ Points = points;
+ }
+```
+
+4. Add the `Points` property after `Data`:
+
+```csharp
+ ///
+ /// Gets the resolved XY(R) tuple points for scatter/bubble series. Empty for flat numeric
+ /// series (which use instead).
+ ///
+ public IReadOnlyList Points { get; }
+```
+
+5. Update the three existing factory/`WithData` returns to pass the extra argument. `FromInline`:
+
+```csharp
+ return new ChartSeries(label, dataExpression: null, data, EmptyPoints);
+```
+
+`FromExpression`:
+
+```csharp
+ return new ChartSeries(label, dataExpression, Empty, EmptyPoints);
+```
+
+`WithData`:
+
+```csharp
+ return new ChartSeries(Label, DataExpression, data, EmptyPoints);
+```
+
+6. Add the new `FromPoints` factory after `FromExpression`:
+
+```csharp
+ ///
+ /// Creates a series with inline XY(R) tuple points (scatter/bubble).
+ ///
+ /// The optional legend label.
+ /// The tuple points.
+ /// A new whose is empty.
+ /// Thrown when is null.
+ public static ChartSeries FromPoints(string? label, IReadOnlyList points)
+ {
+ ArgumentNullException.ThrowIfNull(points);
+ return new ChartSeries(label, dataExpression: null, Empty, points);
+ }
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartPointTests|FullyQualifiedName~ChartSeriesPointsTests"`
+Expected: PASS (5 cases).
+
+- [ ] **Step 6: Verify existing chart tests still pass (no Phase-2 regression)**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSeriesTests"`
+Expected: PASS (existing Phase-2 ChartSeries tests unaffected).
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/FlexRender.Core/Charts/ChartPoint.cs src/FlexRender.Core/Parsing/Ast/ChartSeries.cs tests/FlexRender.Tests/Charts/ChartPointTests.cs tests/FlexRender.Tests/Parsing/Ast/ChartSeriesPointsTests.cs
+git commit --no-gpg-sign -m "feat(charts): add ChartPoint tuple representation to ChartSeries"
+```
+
+---
+
+## Task 3: ChartElement gains Value / Max / ValueLabel (gauge/progress)
+
+**Files:**
+- Modify: `src/FlexRender.Core/Parsing/Ast/ChartElement.cs`
+- Test: `tests/FlexRender.Tests/Parsing/Ast/ChartElementGaugeTests.cs` (create)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Parsing/Ast/ChartElementGaugeTests.cs`:
+
+```csharp
+using System.Collections.Generic;
+using FlexRender.Charts;
+using FlexRender.Parsing.Ast;
+using Xunit;
+
+namespace FlexRender.Tests.Parsing.Ast;
+
+///
+/// Tests for the gauge/progress single-value properties on .
+///
+public sealed class ChartElementGaugeTests
+{
+ [Fact]
+ public void Defaults_ValueMaxLabelAreNull()
+ {
+ var chart = new ChartElement(ChartType.Gauge, new List());
+ Assert.Null(chart.Value);
+ Assert.Null(chart.Max);
+ Assert.Null(chart.ValueLabel);
+ }
+
+ [Fact]
+ public void CloneWithSubstitution_PreservesValueMaxLabel()
+ {
+ var chart = new ChartElement(ChartType.Progress, new List())
+ {
+ Value = 72d,
+ Max = 100d,
+ ValueLabel = "CPU"
+ };
+
+ var clone = (ChartElement)chart.CloneWithSubstitution(s => s);
+
+ Assert.Equal(72d, clone.Value);
+ Assert.Equal(100d, clone.Max);
+ Assert.Equal("CPU", clone.ValueLabel);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartElementGaugeTests"`
+Expected: BUILD FAILURE — `Value`/`Max`/`ValueLabel` not defined.
+
+- [ ] **Step 3: Add the properties + clone**
+
+In `src/FlexRender.Core/Parsing/Ast/ChartElement.cs`, add these three properties after `PieLabels`:
+
+```csharp
+ /// Gets or sets the indicator value for gauge/progress charts. Null renders a "no data" placeholder.
+ public double? Value { get; set; }
+
+ /// Gets or sets the indicator maximum for gauge/progress charts. Null defaults to 100.
+ public double? Max { get; set; }
+
+ /// Gets or sets the centered caption for gauge/progress charts (distinct from ).
+ public string? ValueLabel { get; set; }
+```
+
+In `CloneWithSubstitution`, add the three to the object initializer (after `PieLabels = PieLabels`):
+
+```csharp
+ PieLabels = PieLabels,
+ Value = Value,
+ Max = Max,
+ ValueLabel = ValueLabel
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartElementGaugeTests"`
+Expected: PASS (2 cases).
+
+- [ ] **Step 5: Verify Phase-2 ChartElement tests still pass**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartElementTests"`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/FlexRender.Core/Parsing/Ast/ChartElement.cs tests/FlexRender.Tests/Parsing/Ast/ChartElementGaugeTests.cs
+git commit --no-gpg-sign -m "feat(ast): add Value/Max/ValueLabel to ChartElement for gauge and progress"
+```
+
+---
+
+## Task 4: Parse scatter/bubble tuple data + update chart-type error message
+
+`ParseOneSeries` currently flattens a scalar sequence. We add a tuple path: when the `data:` sequence's first item is itself a sequence, parse each item as an XY (scatter) or XY(R) (bubble) tuple. We pass the `ChartType` down so arity rules differ.
+
+**Files:**
+- Modify: `src/FlexRender.Yaml/Parsing/ChartParsers.cs`
+- Test: `tests/FlexRender.Tests/Parsing/ChartScatterParsersTests.cs` (create)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Parsing/ChartScatterParsersTests.cs`:
+
+```csharp
+using FlexRender.Charts;
+using FlexRender.Parsing;
+using FlexRender.Parsing.Ast;
+using Xunit;
+
+namespace FlexRender.Tests.Parsing;
+
+///
+/// Tests for parsing scatter/bubble tuple series and the updated chart-type validation.
+///
+public sealed class ChartScatterParsersTests
+{
+ private readonly TemplateParser _parser = new();
+
+ [Fact]
+ public void Parse_ScatterTuples_ProducesPoints()
+ {
+ const string yaml = """
+ canvas:
+ width: 400
+ layout:
+ - type: chart
+ chart-type: scatter
+ width: 400
+ height: 300
+ series:
+ - label: cloud
+ data: [[1, 10], [2, 25], [3, 18]]
+ """;
+
+ var template = _parser.Parse(yaml);
+ var chart = Assert.IsType(template.Elements[0]);
+
+ Assert.Equal(ChartType.Scatter, chart.ChartType);
+ var pts = chart.Series[0].Points;
+ Assert.Equal(3, pts.Count);
+ Assert.Equal(1d, pts[0].X);
+ Assert.Equal(10d, pts[0].Y);
+ Assert.Equal(0d, pts[0].R);
+ Assert.Empty(chart.Series[0].Data);
+ }
+
+ [Fact]
+ public void Parse_BubbleTriples_ProducesPointsWithRadius()
+ {
+ const string yaml = """
+ canvas:
+ width: 400
+ layout:
+ - type: chart
+ chart-type: bubble
+ width: 400
+ height: 300
+ series:
+ - data: [[1, 10, 5], [2, 25, 12]]
+ """;
+
+ var template = _parser.Parse(yaml);
+ var chart = Assert.IsType(template.Elements[0]);
+
+ var pts = chart.Series[0].Points;
+ Assert.Equal(2, pts.Count);
+ Assert.Equal(5d, pts[0].R);
+ Assert.Equal(12d, pts[1].R);
+ }
+
+ [Fact]
+ public void Parse_ScatterTupleWrongArity_Throws()
+ {
+ const string yaml = """
+ canvas:
+ width: 400
+ layout:
+ - type: chart
+ chart-type: scatter
+ width: 400
+ height: 300
+ series:
+ - label: bad
+ data: [[1, 2, 3]]
+ """;
+
+ var ex = Assert.Throws(() => _parser.Parse(yaml));
+ Assert.Contains("bad", ex.Message);
+ }
+
+ [Fact]
+ public void Parse_ScatterTupleNonNumeric_Throws()
+ {
+ const string yaml = """
+ canvas:
+ width: 400
+ layout:
+ - type: chart
+ chart-type: scatter
+ width: 400
+ height: 300
+ series:
+ - data: [[1, "x"]]
+ """;
+
+ Assert.Throws(() => _parser.Parse(yaml));
+ }
+
+ [Fact]
+ public void Parse_UnknownChartType_ErrorListsNewTypes()
+ {
+ const string yaml = """
+ canvas:
+ width: 400
+ layout:
+ - type: chart
+ chart-type: nope
+ width: 400
+ height: 300
+ series:
+ - data: [1, 2]
+ """;
+
+ var ex = Assert.Throws(() => _parser.Parse(yaml));
+ Assert.Contains("scatter", ex.Message);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartScatterParsersTests"`
+Expected: FAIL — tuple sequences raise the existing non-numeric error; new-type list missing from message.
+
+- [ ] **Step 3: Implement tuple parsing**
+
+In `src/FlexRender.Yaml/Parsing/ChartParsers.cs`:
+
+1. Add `using FlexRender.Charts;` is already present. Ensure `using System;` covers `Array` (it is used). No new usings needed beyond `YamlDotNet.RepresentationModel` (already imported).
+
+2. Update `ParseChartType` error message to list all types:
+
+```csharp
+ throw new TemplateParseException(
+ $"Unknown chart-type '{raw}'. Valid values: bar, line, area, pie, donut, scatter, bubble, gauge, progress, sparkline.");
+```
+
+3. Thread the chart type through series parsing. Change the `ParseSeries` call in `ParseChartElement` to pass `chartType`:
+
+```csharp
+ var series = ParseSeries(node, chartType, maxSeries, maxDataPoints);
+```
+
+4. Update `ParseSeries` signature and the `ParseOneSeries` call inside it:
+
+```csharp
+ private static List ParseSeries(YamlMappingNode node, ChartType chartType, int maxSeries, int maxDataPoints)
+ {
+ var result = new List();
+
+ if (!TryGetSequence(node, "series", out var seriesSeq))
+ return result;
+
+ if (seriesSeq.Children.Count > maxSeries)
+ {
+ throw new TemplateParseException(
+ $"Chart has {seriesSeq.Children.Count} series, which exceeds the maximum of {maxSeries}.");
+ }
+
+ foreach (var item in seriesSeq.Children)
+ {
+ if (item is not YamlMappingNode seriesNode)
+ throw new TemplateParseException("Each entry in 'series' must be a mapping with a 'data' field.");
+
+ result.Add(ParseOneSeries(seriesNode, chartType, maxDataPoints));
+ }
+
+ return result;
+ }
+```
+
+5. Replace the whole `ParseOneSeries` method with a version that branches on tuple-vs-flat data. The first child being a sequence means tuple data:
+
+```csharp
+ private static ChartSeries ParseOneSeries(YamlMappingNode seriesNode, ChartType chartType, int maxDataPoints)
+ {
+ var label = GetStringValue(seriesNode, "label");
+ var isTupleType = chartType is ChartType.Scatter or ChartType.Bubble;
+
+ // Inline array form.
+ if (TryGetSequence(seriesNode, "data", out var dataSeq))
+ {
+ if (dataSeq.Children.Count > maxDataPoints)
+ {
+ throw new TemplateParseException(
+ $"Series '{label ?? "(unlabeled)"}' has {dataSeq.Children.Count} data points, which exceeds the maximum of {maxDataPoints}.");
+ }
+
+ // Tuple data: the items are themselves sequences ([x, y] or [x, y, r]).
+ if (dataSeq.Children.Count > 0 && dataSeq.Children[0] is YamlSequenceNode)
+ return ParseTupleSeries(label, dataSeq, isTupleType);
+
+ // Flat numeric data (bar/line/area/pie/donut/sparkline/gauge-progress series).
+ var values = new List(dataSeq.Children.Count);
+ foreach (var v in dataSeq.Children)
+ {
+ if (v is not YamlScalarNode scalar
+ || !double.TryParse(scalar.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var d)
+ || !double.IsFinite(d))
+ {
+ throw new TemplateParseException(
+ $"Series '{label ?? "(unlabeled)"}' contains a non-numeric data value '{(v as YamlScalarNode)?.Value}'.");
+ }
+ values.Add(d);
+ }
+ return ChartSeries.FromInline(label, values);
+ }
+
+ // Expression form (scalar string containing {{ }}). Tuple-typed charts only support
+ // inline tuple data this phase; expression-bound tuples are a follow-up.
+ var expr = GetStringValue(seriesNode, "data");
+ if (!string.IsNullOrWhiteSpace(expr))
+ return ChartSeries.FromExpression(label, expr);
+
+ // No data at all -> empty inline series (renders as "no data" if all series empty).
+ return ChartSeries.FromInline(label, Array.Empty());
+ }
+
+ ///
+ /// Parses an array-of-arrays data sequence into XY (scatter) or XY(R) (bubble) tuple points.
+ ///
+ /// The series label, used in error messages.
+ /// The data sequence whose items are 2- or 3-element scalar sequences.
+ /// Whether a third (radius) element is permitted (bubble).
+ /// A point-bearing .
+ /// Thrown on wrong arity, non-numeric, or non-finite tuple values.
+ private static ChartSeries ParseTupleSeries(string? label, YamlSequenceNode dataSeq, bool allowRadius)
+ {
+ var points = new List(dataSeq.Children.Count);
+ foreach (var item in dataSeq.Children)
+ {
+ if (item is not YamlSequenceNode tuple)
+ {
+ throw new TemplateParseException(
+ $"Series '{label ?? "(unlabeled)"}' mixes tuple and scalar data; every item must be an [x, y] (or [x, y, r]) array.");
+ }
+
+ var arity = tuple.Children.Count;
+ var maxArity = allowRadius ? 3 : 2;
+ if (arity < 2 || arity > maxArity)
+ {
+ throw new TemplateParseException(
+ $"Series '{label ?? "(unlabeled)"}' has a tuple with {arity} elements; expected 2{(allowRadius ? " or 3" : string.Empty)}.");
+ }
+
+ var x = ParseTupleScalar(tuple.Children[0], label);
+ var y = ParseTupleScalar(tuple.Children[1], label);
+ var r = arity == 3 ? ParseTupleScalar(tuple.Children[2], label) : 0d;
+ points.Add(new ChartPoint(x, y, r));
+ }
+
+ return ChartSeries.FromPoints(label, points);
+ }
+
+ /// Parses a single tuple element as a finite double, raising a named error otherwise.
+ /// The tuple element node.
+ /// The series label, used in error messages.
+ /// The parsed finite double.
+ /// Thrown when the value is non-numeric or non-finite.
+ private static double ParseTupleScalar(YamlNode node, string? label)
+ {
+ if (node is YamlScalarNode scalar
+ && double.TryParse(scalar.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var d)
+ && double.IsFinite(d))
+ {
+ return d;
+ }
+
+ throw new TemplateParseException(
+ $"Series '{label ?? "(unlabeled)"}' contains a non-numeric tuple value '{(node as YamlScalarNode)?.Value}'.");
+ }
+```
+
+Note: `YamlNode` is the base type from `YamlDotNet.RepresentationModel` (already imported via the existing `using`).
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartScatterParsersTests"`
+Expected: PASS (5 cases).
+
+- [ ] **Step 5: Verify Phase-2 parser tests still pass**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartParsersTests"`
+Expected: PASS (flat-data parsing unaffected).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/FlexRender.Yaml/Parsing/ChartParsers.cs tests/FlexRender.Tests/Parsing/ChartScatterParsersTests.cs
+git commit --no-gpg-sign -m "feat(yaml): parse scatter/bubble tuple series data"
+```
+
+---
+
+## Task 5: Parse gauge/progress value/max/label + register KnownProperties
+
+**Files:**
+- Modify: `src/FlexRender.Yaml/Parsing/ChartParsers.cs`
+- Modify: `src/FlexRender.Yaml/Parsing/KnownProperties.cs`
+- Test: `tests/FlexRender.Tests/Parsing/ChartGaugeParsersTests.cs` (create)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Parsing/ChartGaugeParsersTests.cs`:
+
+```csharp
+using FlexRender.Charts;
+using FlexRender.Parsing;
+using FlexRender.Parsing.Ast;
+using Xunit;
+
+namespace FlexRender.Tests.Parsing;
+
+///
+/// Tests for parsing gauge/progress value/max/label and property validation.
+///
+public sealed class ChartGaugeParsersTests
+{
+ private readonly TemplateParser _parser = new();
+
+ [Fact]
+ public void Parse_Gauge_ReadsValueMaxLabel()
+ {
+ const string yaml = """
+ canvas:
+ width: 300
+ layout:
+ - type: chart
+ chart-type: gauge
+ width: 300
+ height: 200
+ value: 72
+ max: 100
+ label: CPU
+ """;
+
+ var template = _parser.Parse(yaml);
+ var chart = Assert.IsType(template.Elements[0]);
+
+ Assert.Equal(ChartType.Gauge, chart.ChartType);
+ Assert.Equal(72d, chart.Value);
+ Assert.Equal(100d, chart.Value is null ? 0d : chart.Max);
+ Assert.Equal("CPU", chart.ValueLabel);
+ }
+
+ [Fact]
+ public void Parse_Progress_DefaultsMaxLabelToNull()
+ {
+ const string yaml = """
+ canvas:
+ width: 300
+ layout:
+ - type: chart
+ chart-type: progress
+ width: 300
+ height: 80
+ value: 40
+ """;
+
+ var template = _parser.Parse(yaml);
+ var chart = Assert.IsType(template.Elements[0]);
+
+ Assert.Equal(40d, chart.Value);
+ Assert.Null(chart.Max);
+ Assert.Null(chart.ValueLabel);
+ }
+
+ [Fact]
+ public void Parse_GaugeWithKnownProps_DoesNotThrow()
+ {
+ const string yaml = """
+ canvas:
+ width: 300
+ layout:
+ - type: chart
+ chart-type: gauge
+ width: 300
+ height: 200
+ value: 50
+ max: 80
+ label: Disk
+ theme: dark
+ """;
+
+ var template = _parser.Parse(yaml);
+ Assert.IsType(template.Elements[0]);
+ }
+
+ [Fact]
+ public void Parse_TypoInValue_Throws()
+ {
+ const string yaml = """
+ canvas:
+ width: 300
+ layout:
+ - type: chart
+ chart-type: gauge
+ width: 300
+ height: 200
+ valeu: 50
+ """;
+
+ var ex = Assert.Throws(() => _parser.Parse(yaml));
+ Assert.Contains("valeu", ex.Message);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartGaugeParsersTests"`
+Expected: FAIL — `value`/`max`/`label` are unknown properties (parser rejects them) and not yet read onto the element.
+
+- [ ] **Step 3: Register the new props in KnownProperties**
+
+In `src/FlexRender.Yaml/Parsing/KnownProperties.cs`, the `Chart` set, add `"value", "max", "label"` to the list (note: `label` is the gauge caption; it does not conflict with the per-series `label` which is validated inside the series mapping, not the chart mapping):
+
+```csharp
+ internal static readonly HashSet Chart = BuildSet(FlexItemProperties,
+ [
+ "chart-type", "categories", "series", "palette", "theme", "legend", "title",
+ "horizontal", "stacked", "smooth", "points", "labels",
+ "value", "max", "label",
+ "background", "rotate", "padding", "margin"
+ ]);
+```
+
+- [ ] **Step 4: Read value/max/label in the parser**
+
+In `src/FlexRender.Yaml/Parsing/ChartParsers.cs`, inside `ParseChartElement`'s object initializer, add three lines after `PieLabels = ParsePieLabels(node),`:
+
+```csharp
+ Value = GetDoubleValue(node, "value"),
+ Max = GetDoubleValue(node, "max"),
+ ValueLabel = GetStringValue(node, "label"),
+```
+
+`GetDoubleValue` returns `double?` and is already imported via the `static ... YamlPropertyHelpers` using.
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartGaugeParsersTests"`
+Expected: PASS (4 cases).
+
+- [ ] **Step 6: Verify the chart KnownProperties tests still pass**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartKnownPropertiesTests"`
+Expected: PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/FlexRender.Yaml/Parsing/ChartParsers.cs src/FlexRender.Yaml/Parsing/KnownProperties.cs tests/FlexRender.Tests/Parsing/ChartGaugeParsersTests.cs
+git commit --no-gpg-sign -m "feat(yaml): parse gauge/progress value/max/label and register chart props"
+```
+
+---
+
+## Task 6: Renderer — DataBounds tuple awareness + XY bounds helper
+
+Before drawing scatter/bubble we need X and Y data ranges. `DataBounds` only walks flat `Data`. Add a `PointBounds` helper that returns X and Y min/max across all series' `Points`. This is pure and unit-testable via a thin internal accessor — but to avoid widening internals, we test it indirectly through scatter rendering in Task 8. This task adds the helper only; it compiles but is not yet wired.
+
+**Files:**
+- Modify: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`
+
+- [ ] **Step 1: Add the PointBounds helper**
+
+In `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`, add this method directly after the existing `DataBounds` method:
+
+```csharp
+ ///
+ /// Computes the X and Y data ranges across all series' tuple points (scatter/bubble).
+ /// Returns unit ranges when there are no points so a chart can still draw.
+ ///
+ private static (double MinX, double MaxX, double MinY, double MaxY) PointBounds(ChartElement chart)
+ {
+ var minX = double.MaxValue;
+ var maxX = double.MinValue;
+ var minY = double.MaxValue;
+ var maxY = double.MinValue;
+
+ foreach (var s in chart.Series)
+ {
+ foreach (var p in s.Points)
+ {
+ if (p.X < minX) minX = p.X;
+ if (p.X > maxX) maxX = p.X;
+ if (p.Y < minY) minY = p.Y;
+ if (p.Y > maxY) maxY = p.Y;
+ }
+ }
+
+ if (minX == double.MaxValue)
+ return (0d, 1d, 0d, 1d);
+ return (minX, maxX, minY, maxY);
+ }
+```
+
+- [ ] **Step 2: Add a HasAnyPoints helper and extend HasAnyData**
+
+Replace the existing `HasAnyData` method body so it also treats tuple series and gauge/progress values as "has data":
+
+```csharp
+ /// Returns whether the chart has anything to draw (flat data, tuple points, or a gauge value).
+ private static bool HasAnyData(ChartElement chart)
+ {
+ if (chart.ChartType is ChartType.Gauge or ChartType.Progress)
+ return chart.Value is not null;
+
+ foreach (var s in chart.Series)
+ {
+ if (s.Data.Count > 0 || s.Points.Count > 0)
+ return true;
+ }
+ return false;
+ }
+```
+
+- [ ] **Step 3: Verify the build compiles**
+
+Run: `dotnet build FlexRender.slnx`
+Expected: BUILD SUCCEEDED. (CA1859 watch: both helpers return value tuples / bool, no interface returns. The new `PointBounds` is currently unused but referenced in Task 8 within the same file — if the analyzer flags it as unused before Task 8, proceed directly to Task 8 in the same working session so it becomes used; do not commit this task standalone if `dotnet build` reports an unused-private-member error.)
+
+- [ ] **Step 4: Commit (only if build is clean)**
+
+```bash
+git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs
+git commit --no-gpg-sign -m "feat(skia): add chart tuple-point bounds and gauge data detection"
+```
+
+> If `dotnet build` flags `PointBounds` as an unused private member (IDE0051/CA-equivalent error under TreatWarningsAsErrors), skip the standalone commit and fold this task's edits into the Task 8 commit instead.
+
+---
+
+## Task 7: Renderer — sparkline (minimal line, no chrome)
+
+Sparkline reuses flat `Data` and the existing `BuildSeriesPath` (honoring `Smooth`/`ShowPoints`) but draws no grid, axes, labels, legend, or title — just the line filling the box with a small inset.
+
+**Files:**
+- Modify: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`
+- Test: `tests/FlexRender.Tests/Rendering/ChartSparklineRenderTests.cs` (create)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Rendering/ChartSparklineRenderTests.cs`:
+
+```csharp
+using System.Collections.Generic;
+using FlexRender.Charts;
+using FlexRender.Parsing.Ast;
+using FlexRender.Rendering;
+using SkiaSharp;
+using Xunit;
+
+namespace FlexRender.Tests.Rendering;
+
+///
+/// Verifies the sparkline draws a colored line with no axis chrome.
+///
+public sealed class ChartSparklineRenderTests
+{
+ [Fact]
+ public void Sparkline_DrawsColoredLine()
+ {
+ var chart = new ChartElement(ChartType.Sparkline, new List
+ {
+ ChartSeries.FromInline(null, new[] { 3d, 8d, 4d, 10d, 6d })
+ })
+ {
+ Legend = LegendPosition.None,
+ Palette = new ChartPalette(new[] { "#ff0000" }),
+ Theme = ChartThemes.Default
+ };
+
+ using var bitmap = new SKBitmap(120, 40, SKColorType.Rgba8888, SKAlphaType.Premul);
+ using var canvas = new SKCanvas(bitmap);
+ canvas.Clear(SKColors.White);
+
+ ChartRenderer.Draw(canvas, chart, 0f, 0f, 120f, 40f, typeface: null, antialias: true);
+
+ Assert.True(HasRedPixel(bitmap), "Expected a red sparkline.");
+ }
+
+ private static bool HasRedPixel(SKBitmap bitmap)
+ {
+ for (var y = 0; y < bitmap.Height; y++)
+ for (var x = 0; x < bitmap.Width; x++)
+ {
+ var p = bitmap.GetPixel(x, y);
+ if (p.Red > 180 && p.Green < 100 && p.Blue < 100)
+ return true;
+ }
+ return false;
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSparklineRenderTests"`
+Expected: FAIL — the `default` arm of `DrawSeries` draws only a faint border, no red line.
+
+- [ ] **Step 3: Add the Sparkline case + method**
+
+In `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`, add a case to the `DrawSeries` switch (before `default:`):
+
+```csharp
+ case ChartType.Sparkline:
+ DrawSparkline(canvas, chart, theme, width, height, antialias);
+ break;
+```
+
+Then add the method after `DrawLineOrArea` (it reuses `BuildSeriesPath`):
+
+```csharp
+ ///
+ /// Draws a sparkline: a single small line filling the box (minus a small inset) with no grid,
+ /// axes, labels, or legend. Honors and
+ /// ; uses the first series' flat data.
+ ///
+ private static void DrawSparkline(
+ SKCanvas canvas, ChartElement chart, ChartTheme theme, float width, float height, bool antialias)
+ {
+ if (chart.Series.Count == 0)
+ return;
+
+ var data = chart.Series[0].Data;
+ if (data.Count == 0)
+ return;
+
+ var inset = MathF.Max(2f, MathF.Min(width, height) * 0.12f);
+ var left = inset;
+ var top = inset;
+ var right = width - inset;
+ var bottom = height - inset;
+ if (right <= left || bottom <= top)
+ return;
+
+ var (dataMin, dataMax) = DataBounds(chart);
+ var mapper = new ValueMapper(dataMin, dataMax, top, bottom);
+
+ var step = data.Count > 1 ? (right - left) / (data.Count - 1) : 0f;
+ float X(int i) => data.Count > 1 ? left + (step * i) : (left + right) / 2f;
+
+ var points = new SKPoint[data.Count];
+ for (var i = 0; i < data.Count; i++)
+ points[i] = new SKPoint(X(i), mapper.MapY(data[i]));
+
+ var palette = chart.Palette ?? ChartPalettes.Default;
+ var color = ColorParser.Parse(palette.ColorAt(0));
+
+ using var path = BuildSeriesPath(points, chart.Smooth);
+ using var linePaint = new SKPaint
+ {
+ Color = color,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = MathF.Max(1f, theme.LineWidth - 0.5f),
+ IsAntialias = antialias,
+ StrokeCap = SKStrokeCap.Round,
+ StrokeJoin = SKStrokeJoin.Round
+ };
+ canvas.DrawPath(path, linePaint);
+
+ if (chart.ShowPoints)
+ {
+ using var pointPaint = new SKPaint { Color = color, Style = SKPaintStyle.Fill, IsAntialias = antialias };
+ for (var i = 0; i < data.Count; i++)
+ canvas.DrawCircle(X(i), mapper.MapY(data[i]), theme.LineWidth, pointPaint);
+ }
+ }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSparklineRenderTests"`
+Expected: PASS (1 case).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs tests/FlexRender.Tests/Rendering/ChartSparklineRenderTests.cs
+git commit --no-gpg-sign -m "feat(skia): render sparkline charts"
+```
+
+---
+
+## Task 8: Renderer — scatter and bubble
+
+Scatter draws a dot per point; bubble scales the dot radius by `ChartPoint.R`. Both need an X *and* Y `AxisScale` and a local `MapX` mirroring `ValueMapper.MapY`. Grid + Y axis reuse `DrawGridAndYAxis`.
+
+**Files:**
+- Modify: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`
+- Test: `tests/FlexRender.Tests/Rendering/ChartScatterRenderTests.cs` (create)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Rendering/ChartScatterRenderTests.cs`:
+
+```csharp
+using System.Collections.Generic;
+using FlexRender.Charts;
+using FlexRender.Parsing.Ast;
+using FlexRender.Rendering;
+using SkiaSharp;
+using Xunit;
+
+namespace FlexRender.Tests.Rendering;
+
+///
+/// Verifies scatter and bubble draw palette-colored markers.
+///
+public sealed class ChartScatterRenderTests
+{
+ [Fact]
+ public void Scatter_DrawsColoredDots()
+ {
+ var chart = new ChartElement(ChartType.Scatter, new List
+ {
+ ChartSeries.FromPoints("cloud", new List
+ {
+ ChartPoint.Xy(1d, 10d), ChartPoint.Xy(5d, 25d), ChartPoint.Xy(9d, 18d)
+ })
+ })
+ {
+ Legend = LegendPosition.None,
+ Palette = new ChartPalette(new[] { "#ff0000" }),
+ Theme = ChartThemes.Default
+ };
+
+ using var bitmap = new SKBitmap(300, 200, SKColorType.Rgba8888, SKAlphaType.Premul);
+ using var canvas = new SKCanvas(bitmap);
+ canvas.Clear(SKColors.White);
+
+ ChartRenderer.Draw(canvas, chart, 0f, 0f, 300f, 200f, typeface: null, antialias: true);
+
+ Assert.True(HasRedPixel(bitmap), "Expected red scatter dots.");
+ }
+
+ [Fact]
+ public void Bubble_DrawsLargerMarkersForLargerRadius()
+ {
+ var chart = new ChartElement(ChartType.Bubble, new List
+ {
+ ChartSeries.FromPoints("b", new List
+ {
+ new ChartPoint(2d, 10d, 2d), new ChartPoint(8d, 20d, 30d)
+ })
+ })
+ {
+ Legend = LegendPosition.None,
+ Palette = new ChartPalette(new[] { "#0000ff" }),
+ Theme = ChartThemes.Default
+ };
+
+ using var bitmap = new SKBitmap(300, 200, SKColorType.Rgba8888, SKAlphaType.Premul);
+ using var canvas = new SKCanvas(bitmap);
+ canvas.Clear(SKColors.White);
+
+ ChartRenderer.Draw(canvas, chart, 0f, 0f, 300f, 200f, typeface: null, antialias: true);
+
+ Assert.True(CountBluePixels(bitmap) > 50, "Expected substantial blue bubble area.");
+ }
+
+ private static bool HasRedPixel(SKBitmap bitmap)
+ {
+ for (var y = 0; y < bitmap.Height; y++)
+ for (var x = 0; x < bitmap.Width; x++)
+ {
+ var p = bitmap.GetPixel(x, y);
+ if (p.Red > 180 && p.Green < 100 && p.Blue < 100)
+ return true;
+ }
+ return false;
+ }
+
+ private static int CountBluePixels(SKBitmap bitmap)
+ {
+ var count = 0;
+ for (var y = 0; y < bitmap.Height; y++)
+ for (var x = 0; x < bitmap.Width; x++)
+ {
+ var p = bitmap.GetPixel(x, y);
+ if (p.Blue > 150 && p.Red < 120 && p.Green < 120)
+ count++;
+ }
+ return count;
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartScatterRenderTests"`
+Expected: FAIL — the `default` arm draws only a faint border.
+
+- [ ] **Step 3: Add the Scatter/Bubble cases + method**
+
+In `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`, add two cases to `DrawSeries` (before `default:`):
+
+```csharp
+ case ChartType.Scatter:
+ DrawScatterOrBubble(canvas, chart, theme, width, height, typeface, isBubble: false, antialias);
+ break;
+ case ChartType.Bubble:
+ DrawScatterOrBubble(canvas, chart, theme, width, height, typeface, isBubble: true, antialias);
+ break;
+```
+
+Then add the method after `DrawSparkline`:
+
+```csharp
+ ///
+ /// Draws a scatter (dots) or bubble (radius-scaled dots) chart. Both axes are scaled to the
+ /// point data ranges; bubble marker radii are mapped from the largest
+ /// to a fixed maximum pixel radius so the largest bubble fits comfortably in the plot.
+ ///
+ private static void DrawScatterOrBubble(
+ SKCanvas canvas, ChartElement chart, ChartTheme theme,
+ float width, float height, SKTypeface? typeface, bool isBubble, bool antialias)
+ {
+ var (minX, maxX, minY, maxY) = PointBounds(chart);
+ var xScale = AxisScale.Compute(minX, maxX, targetTicks: 5);
+ var yScale = AxisScale.Compute(minY, maxY, targetTicks: 5);
+
+ var hasTitle = !string.IsNullOrEmpty(chart.Title);
+ var plot = ChartLayout.ComputePlotArea(
+ width, height, hasTitle, chart.Legend,
+ axisGutterLeft: 44f, axisGutterBottom: 22f, titleHeight: theme.TitleSize + 8f, legendExtent: 28f);
+
+ DrawGridAndYAxis(canvas, theme, plot, yScale, typeface, antialias);
+
+ var yMapper = new ValueMapper(yScale.Min, yScale.Max, plot.Top, plot.Bottom);
+
+ // X mapping mirrors ValueMapper.MapY but along the horizontal axis (min -> left, max -> right).
+ var plotLeft = plot.Left;
+ var plotWidth = plot.Width;
+ var xMin = xScale.Min;
+ var xSpan = xScale.Max - xScale.Min;
+ float MapX(double value) => xSpan <= 0d
+ ? plotLeft + (plotWidth / 2f)
+ : plotLeft + (float)(((value - xMin) / xSpan) * plotWidth);
+
+ // Determine the largest radius for bubble scaling.
+ var maxR = 0d;
+ if (isBubble)
+ {
+ foreach (var s in chart.Series)
+ foreach (var p in s.Points)
+ if (p.R > maxR) maxR = p.R;
+ }
+ var maxPixelRadius = MathF.Max(4f, MathF.Min(plot.Width, plot.Height) * 0.12f);
+ const float scatterRadius = 4f;
+
+ var palette = chart.Palette ?? ChartPalettes.Default;
+
+ for (var si = 0; si < chart.Series.Count; si++)
+ {
+ var pts = chart.Series[si].Points;
+ if (pts.Count == 0)
+ continue;
+
+ var color = ColorParser.Parse(palette.ColorAt(si));
+ using var paint = new SKPaint
+ {
+ Color = isBubble ? color.WithAlpha(150) : color,
+ Style = SKPaintStyle.Fill,
+ IsAntialias = antialias
+ };
+
+ foreach (var p in pts)
+ {
+ var cx = MapX(p.X);
+ var cy = yMapper.MapY(p.Y);
+ float radius;
+ if (isBubble && maxR > 0d)
+ radius = MathF.Max(2f, (float)(p.R / maxR) * maxPixelRadius);
+ else
+ radius = scatterRadius;
+ canvas.DrawCircle(cx, cy, radius, paint);
+ }
+ }
+ }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartScatterRenderTests"`
+Expected: PASS (2 cases).
+
+- [ ] **Step 5: Commit**
+
+If Task 6 was committed standalone:
+
+```bash
+git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs tests/FlexRender.Tests/Rendering/ChartScatterRenderTests.cs
+git commit --no-gpg-sign -m "feat(skia): render scatter and bubble charts"
+```
+
+If Task 6 was folded in here (build had flagged `PointBounds` unused), the same `git add`/`commit` covers both.
+
+---
+
+## Task 9: Renderer — gauge (arc/dial)
+
+A gauge draws a 270-degree background arc and a colored value arc from the start angle, plus a centered value/label text. `value` is clamped into `[0, max]`; `max` defaults to 100 when null.
+
+**Files:**
+- Modify: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`
+- Test: `tests/FlexRender.Tests/Rendering/ChartGaugeRenderTests.cs` (create)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Rendering/ChartGaugeRenderTests.cs`:
+
+```csharp
+using System.Collections.Generic;
+using FlexRender.Charts;
+using FlexRender.Parsing.Ast;
+using FlexRender.Rendering;
+using SkiaSharp;
+using Xunit;
+
+namespace FlexRender.Tests.Rendering;
+
+///
+/// Verifies the gauge draws a colored value arc.
+///
+public sealed class ChartGaugeRenderTests
+{
+ [Fact]
+ public void Gauge_DrawsColoredValueArc()
+ {
+ var chart = new ChartElement(ChartType.Gauge, new List())
+ {
+ Value = 70d,
+ Max = 100d,
+ Legend = LegendPosition.None,
+ Palette = new ChartPalette(new[] { "#ff0000" }),
+ Theme = ChartThemes.Default
+ };
+
+ using var bitmap = new SKBitmap(240, 200, SKColorType.Rgba8888, SKAlphaType.Premul);
+ using var canvas = new SKCanvas(bitmap);
+ canvas.Clear(SKColors.White);
+
+ ChartRenderer.Draw(canvas, chart, 0f, 0f, 240f, 200f, typeface: null, antialias: true);
+
+ Assert.True(HasRedPixel(bitmap), "Expected a red gauge arc.");
+ }
+
+ [Fact]
+ public void Gauge_MissingValue_DrawsNoDataPlaceholder()
+ {
+ var chart = new ChartElement(ChartType.Gauge, new List())
+ {
+ Legend = LegendPosition.None,
+ Theme = ChartThemes.Default
+ };
+
+ using var bitmap = new SKBitmap(240, 200, SKColorType.Rgba8888, SKAlphaType.Premul);
+ using var canvas = new SKCanvas(bitmap);
+ canvas.Clear(SKColors.White);
+
+ ChartRenderer.Draw(canvas, chart, 0f, 0f, 240f, 200f, typeface: null, antialias: true);
+
+ Assert.True(HasNonWhitePixel(bitmap), "Expected the no-data border to draw.");
+ }
+
+ private static bool HasRedPixel(SKBitmap bitmap)
+ {
+ for (var y = 0; y < bitmap.Height; y++)
+ for (var x = 0; x < bitmap.Width; x++)
+ {
+ var p = bitmap.GetPixel(x, y);
+ if (p.Red > 180 && p.Green < 100 && p.Blue < 100)
+ return true;
+ }
+ return false;
+ }
+
+ private static bool HasNonWhitePixel(SKBitmap bitmap)
+ {
+ for (var y = 0; y < bitmap.Height; y++)
+ for (var x = 0; x < bitmap.Width; x++)
+ {
+ var p = bitmap.GetPixel(x, y);
+ if (p.Red < 240 || p.Green < 240 || p.Blue < 240)
+ return true;
+ }
+ return false;
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartGaugeRenderTests"`
+Expected: FAIL — `Gauge_DrawsColoredValueArc` finds no red (the `default` border arm draws axis-color gray only). The no-data case already passes via `HasAnyData` returning false for a null value (Task 6), so only the arc case fails.
+
+- [ ] **Step 3: Add the Gauge case + method**
+
+In `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`, add a case to `DrawSeries` (before `default:`):
+
+```csharp
+ case ChartType.Gauge:
+ DrawGauge(canvas, chart, theme, width, height, typeface, antialias);
+ break;
+```
+
+Then add the method after `DrawScatterOrBubble`:
+
+```csharp
+ ///
+ /// Draws a 270-degree gauge dial: a faint background arc plus a colored value arc proportional
+ /// to value/max, with the value and optional caption centered below the dial.
+ ///
+ private static void DrawGauge(
+ SKCanvas canvas, ChartElement chart, ChartTheme theme,
+ float width, float height, SKTypeface? typeface, bool antialias)
+ {
+ var value = chart.Value ?? 0d;
+ var max = chart.Max is > 0d ? chart.Max.Value : 100d;
+ var fraction = max <= 0d ? 0d : Math.Clamp(value / max, 0d, 1d);
+
+ // Geometry: a 270-degree sweep starting at 135 degrees (bottom-left), open at the bottom.
+ const float startAngle = 135f;
+ const float fullSweep = 270f;
+
+ var diameter = MathF.Min(width, height) * 0.7f;
+ var radius = diameter / 2f;
+ var cx = width / 2f;
+ var cy = height / 2f;
+ var thickness = MathF.Max(6f, radius * 0.22f);
+
+ var bounds = new SKRect(cx - radius, cy - radius, cx + radius, cy + radius);
+ var palette = chart.Palette ?? ChartPalettes.Default;
+ var valueColor = ColorParser.Parse(palette.ColorAt(0));
+
+ using var trackPaint = new SKPaint
+ {
+ Color = ColorParser.Parse(theme.GridColor),
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = thickness,
+ StrokeCap = SKStrokeCap.Round,
+ IsAntialias = antialias
+ };
+ using var track = new SKPath();
+ track.AddArc(bounds, startAngle, fullSweep);
+ canvas.DrawPath(track, trackPaint);
+
+ if (fraction > 0d)
+ {
+ using var valuePaint = new SKPaint
+ {
+ Color = valueColor,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = thickness,
+ StrokeCap = SKStrokeCap.Round,
+ IsAntialias = antialias
+ };
+ using var valueArc = new SKPath();
+ valueArc.AddArc(bounds, startAngle, (float)(fullSweep * fraction));
+ canvas.DrawPath(valueArc, valuePaint);
+ }
+
+ DrawIndicatorText(canvas, chart, theme, value, max, cx, cy, typeface, antialias);
+ }
+
+ ///
+ /// Draws the centered numeric value (and optional caption) for gauge/progress charts when a
+ /// typeface is available.
+ ///
+ private static void DrawIndicatorText(
+ SKCanvas canvas, ChartElement chart, ChartTheme theme, double value, double max,
+ float cx, float cy, SKTypeface? typeface, bool antialias)
+ {
+ if (typeface is null)
+ return;
+
+ var valueText = FormatTick(value);
+ using var valueFont = new SKFont(typeface, theme.TitleSize);
+ using var valuePaint = new SKPaint { Color = ColorParser.Parse(theme.TitleColor), IsAntialias = antialias };
+ var vw = valueFont.MeasureText(valueText);
+ canvas.DrawText(valueText, cx - (vw / 2f), cy + (theme.TitleSize / 3f), SKTextAlign.Left, valueFont, valuePaint);
+
+ if (string.IsNullOrEmpty(chart.ValueLabel))
+ return;
+
+ using var capFont = new SKFont(typeface, theme.LabelSize);
+ using var capPaint = new SKPaint { Color = ColorParser.Parse(theme.LabelColor), IsAntialias = antialias };
+ var cw = capFont.MeasureText(chart.ValueLabel);
+ canvas.DrawText(chart.ValueLabel, cx - (cw / 2f), cy + theme.TitleSize, SKTextAlign.Left, capFont, capPaint);
+ }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartGaugeRenderTests"`
+Expected: PASS (2 cases).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs tests/FlexRender.Tests/Rendering/ChartGaugeRenderTests.cs
+git commit --no-gpg-sign -m "feat(skia): render gauge charts"
+```
+
+---
+
+## Task 10: Renderer — progress (ring)
+
+Progress draws a full-circle faint track plus a colored value arc starting at 12 o'clock, with centered text. It reuses `DrawIndicatorText` from Task 9.
+
+**Files:**
+- Modify: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`
+- Test: `tests/FlexRender.Tests/Rendering/ChartProgressRenderTests.cs` (create)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Rendering/ChartProgressRenderTests.cs`:
+
+```csharp
+using System.Collections.Generic;
+using FlexRender.Charts;
+using FlexRender.Parsing.Ast;
+using FlexRender.Rendering;
+using SkiaSharp;
+using Xunit;
+
+namespace FlexRender.Tests.Rendering;
+
+///
+/// Verifies the progress ring draws a colored value arc.
+///
+public sealed class ChartProgressRenderTests
+{
+ [Fact]
+ public void Progress_DrawsColoredRingArc()
+ {
+ var chart = new ChartElement(ChartType.Progress, new List())
+ {
+ Value = 40d,
+ Max = 100d,
+ Legend = LegendPosition.None,
+ Palette = new ChartPalette(new[] { "#ff0000" }),
+ Theme = ChartThemes.Default
+ };
+
+ using var bitmap = new SKBitmap(200, 200, SKColorType.Rgba8888, SKAlphaType.Premul);
+ using var canvas = new SKCanvas(bitmap);
+ canvas.Clear(SKColors.White);
+
+ ChartRenderer.Draw(canvas, chart, 0f, 0f, 200f, 200f, typeface: null, antialias: true);
+
+ Assert.True(HasRedPixel(bitmap), "Expected a red progress arc.");
+ }
+
+ private static bool HasRedPixel(SKBitmap bitmap)
+ {
+ for (var y = 0; y < bitmap.Height; y++)
+ for (var x = 0; x < bitmap.Width; x++)
+ {
+ var p = bitmap.GetPixel(x, y);
+ if (p.Red > 180 && p.Green < 100 && p.Blue < 100)
+ return true;
+ }
+ return false;
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartProgressRenderTests"`
+Expected: FAIL — no red (the `default` border arm draws axis-color gray only).
+
+- [ ] **Step 3: Add the Progress case + method**
+
+In `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`, add a case to `DrawSeries` (before `default:`):
+
+```csharp
+ case ChartType.Progress:
+ DrawProgress(canvas, chart, theme, width, height, typeface, antialias);
+ break;
+```
+
+Then add the method after `DrawIndicatorText`:
+
+```csharp
+ ///
+ /// Draws a progress ring: a full-circle faint track plus a colored value arc starting at 12
+ /// o'clock and sweeping clockwise proportional to value/max, with centered value text.
+ ///
+ private static void DrawProgress(
+ SKCanvas canvas, ChartElement chart, ChartTheme theme,
+ float width, float height, SKTypeface? typeface, bool antialias)
+ {
+ var value = chart.Value ?? 0d;
+ var max = chart.Max is > 0d ? chart.Max.Value : 100d;
+ var fraction = max <= 0d ? 0d : Math.Clamp(value / max, 0d, 1d);
+
+ var diameter = MathF.Min(width, height) * 0.7f;
+ var radius = diameter / 2f;
+ var cx = width / 2f;
+ var cy = height / 2f;
+ var thickness = MathF.Max(6f, radius * 0.2f);
+
+ var bounds = new SKRect(cx - radius, cy - radius, cx + radius, cy + radius);
+ var palette = chart.Palette ?? ChartPalettes.Default;
+ var valueColor = ColorParser.Parse(palette.ColorAt(0));
+
+ using var trackPaint = new SKPaint
+ {
+ Color = ColorParser.Parse(theme.GridColor),
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = thickness,
+ IsAntialias = antialias
+ };
+ canvas.DrawOval(bounds, trackPaint);
+
+ if (fraction > 0d)
+ {
+ using var valuePaint = new SKPaint
+ {
+ Color = valueColor,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = thickness,
+ StrokeCap = SKStrokeCap.Round,
+ IsAntialias = antialias
+ };
+ using var valueArc = new SKPath();
+ // Start at 12 o'clock (-90 degrees) and sweep clockwise.
+ valueArc.AddArc(bounds, -90f, (float)(360d * fraction));
+ canvas.DrawPath(valueArc, valuePaint);
+ }
+
+ DrawIndicatorText(canvas, chart, theme, value, max, cx, cy, typeface, antialias);
+ }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartProgressRenderTests"`
+Expected: PASS (1 case).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs tests/FlexRender.Tests/Rendering/ChartProgressRenderTests.cs
+git commit --no-gpg-sign -m "feat(skia): render progress ring charts"
+```
+
+---
+
+## Task 11: End-to-end smoke tests via the YAML pipeline
+
+Verify each new type renders through the full parse → measure → render path (not just direct `ChartRenderer.Draw`).
+
+**Files:**
+- Modify: `tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs`
+
+- [ ] **Step 1: Add the failing smoke tests**
+
+Append these methods inside the `ChartRenderSmokeTests` class (before its closing brace). They reuse the private `Render`/`HasNonBackgroundPixel` helpers already in the file:
+
+```csharp
+ [Fact]
+ public async Task ScatterChart_FromYaml_DrawsPixels()
+ {
+ const string yaml = """
+ canvas:
+ width: 320
+ height: 240
+ background: "#ffffff"
+ layout:
+ - type: chart
+ chart-type: scatter
+ width: 320
+ height: 240
+ series:
+ - data: [[1, 10], [4, 25], [7, 18], [9, 30]]
+ legend: none
+ """;
+
+ var template = _parser.Parse(yaml);
+ using var bitmap = await Render(template, new ObjectValue());
+
+ Assert.True(HasNonBackgroundPixel(bitmap), "Expected scatter to draw.");
+ }
+
+ [Fact]
+ public async Task BubbleChart_FromYaml_DrawsPixels()
+ {
+ const string yaml = """
+ canvas:
+ width: 320
+ height: 240
+ background: "#ffffff"
+ layout:
+ - type: chart
+ chart-type: bubble
+ width: 320
+ height: 240
+ series:
+ - data: [[1, 10, 5], [5, 25, 18], [8, 15, 9]]
+ legend: none
+ """;
+
+ var template = _parser.Parse(yaml);
+ using var bitmap = await Render(template, new ObjectValue());
+
+ Assert.True(HasNonBackgroundPixel(bitmap), "Expected bubble to draw.");
+ }
+
+ [Fact]
+ public async Task GaugeChart_FromYaml_DrawsPixels()
+ {
+ const string yaml = """
+ canvas:
+ width: 240
+ height: 200
+ background: "#ffffff"
+ layout:
+ - type: chart
+ chart-type: gauge
+ width: 240
+ height: 200
+ value: 65
+ max: 100
+ label: CPU
+ """;
+
+ var template = _parser.Parse(yaml);
+ using var bitmap = await Render(template, new ObjectValue());
+
+ Assert.True(HasNonBackgroundPixel(bitmap), "Expected gauge to draw.");
+ }
+
+ [Fact]
+ public async Task ProgressChart_FromYaml_DrawsPixels()
+ {
+ const string yaml = """
+ canvas:
+ width: 200
+ height: 200
+ background: "#ffffff"
+ layout:
+ - type: chart
+ chart-type: progress
+ width: 200
+ height: 200
+ value: 45
+ """;
+
+ var template = _parser.Parse(yaml);
+ using var bitmap = await Render(template, new ObjectValue());
+
+ Assert.True(HasNonBackgroundPixel(bitmap), "Expected progress to draw.");
+ }
+
+ [Fact]
+ public async Task SparklineChart_FromYaml_DrawsPixels()
+ {
+ const string yaml = """
+ canvas:
+ width: 160
+ height: 50
+ background: "#ffffff"
+ layout:
+ - type: chart
+ chart-type: sparkline
+ width: 160
+ height: 50
+ series:
+ - data: [3, 8, 4, 10, 6, 9]
+ """;
+
+ var template = _parser.Parse(yaml);
+ using var bitmap = await Render(template, new ObjectValue());
+
+ Assert.True(HasNonBackgroundPixel(bitmap), "Expected sparkline to draw.");
+ }
+```
+
+- [ ] **Step 2: Run the smoke tests**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartRenderSmokeTests"`
+Expected: PASS (all cases, including the 2 pre-existing Phase-2 cases).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs
+git commit --no-gpg-sign -m "test(skia): end-to-end smoke tests for Phase-3 chart types"
+```
+
+---
+
+## Task 12: Snapshot goldens for the five new types
+
+**Files:**
+- Modify: `tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs`
+- Create (generated): `tests/FlexRender.Tests/Snapshots/golden/chart_scatter_light.png`, `chart_bubble_light.png`, `chart_gauge_dark.png`, `chart_progress_light.png`, `chart_sparkline_light.png`
+
+- [ ] **Step 1: Add the snapshot tests**
+
+Append these methods inside the `ChartSnapshotTests` class (before its closing brace):
+
+```csharp
+ [Fact]
+ public async Task ScatterChart_Light()
+ {
+ var template = Parser.Parse("""
+ canvas:
+ width: 480
+ background: "#ffffff"
+ layout:
+ - type: chart
+ chart-type: scatter
+ width: 480
+ height: 320
+ series:
+ - label: cloud
+ data: [[1, 12], [3, 30], [5, 22], [7, 48], [9, 35]]
+ legend: none
+ palette: ocean
+ """);
+ await AssertSnapshot("chart_scatter_light", template, new ObjectValue());
+ }
+
+ [Fact]
+ public async Task BubbleChart_Light()
+ {
+ var template = Parser.Parse("""
+ canvas:
+ width: 480
+ background: "#ffffff"
+ layout:
+ - type: chart
+ chart-type: bubble
+ width: 480
+ height: 320
+ series:
+ - data: [[1, 12, 6], [3, 30, 18], [5, 22, 10], [7, 48, 24]]
+ legend: none
+ palette: sunset
+ """);
+ await AssertSnapshot("chart_bubble_light", template, new ObjectValue());
+ }
+
+ [Fact]
+ public async Task GaugeChart_Dark()
+ {
+ var template = Parser.Parse("""
+ canvas:
+ width: 280
+ background: "#1e1e1e"
+ layout:
+ - type: chart
+ chart-type: gauge
+ width: 280
+ height: 220
+ value: 72
+ max: 100
+ label: CPU
+ theme: dark
+ palette: vivid
+ """);
+ await AssertSnapshot("chart_gauge_dark", template, new ObjectValue());
+ }
+
+ [Fact]
+ public async Task ProgressChart_Light()
+ {
+ var template = Parser.Parse("""
+ canvas:
+ width: 240
+ background: "#ffffff"
+ layout:
+ - type: chart
+ chart-type: progress
+ width: 240
+ height: 240
+ value: 64
+ max: 100
+ label: Disk
+ palette: forest
+ """);
+ await AssertSnapshot("chart_progress_light", template, new ObjectValue());
+ }
+
+ [Fact]
+ public async Task SparklineChart_Light()
+ {
+ var template = Parser.Parse("""
+ canvas:
+ width: 200
+ background: "#ffffff"
+ layout:
+ - type: chart
+ chart-type: sparkline
+ width: 200
+ height: 50
+ smooth: true
+ series:
+ - data: [3, 8, 4, 10, 6, 9, 5, 11]
+ """);
+ await AssertSnapshot("chart_sparkline_light", template, new ObjectValue());
+ }
+```
+
+- [ ] **Step 2: Run the snapshot tests to confirm they fail (no golden yet)**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSnapshotTests"`
+Expected: FAIL for the five new tests with "Golden image not found" (Phase-2 snapshots still pass).
+
+- [ ] **Step 3: Generate the goldens**
+
+Run: `UPDATE_SNAPSHOTS=true dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSnapshotTests"`
+Expected: PASS (goldens written). Confirm five new PNGs exist under `tests/FlexRender.Tests/Snapshots/golden/`.
+
+- [ ] **Step 4: Re-run without update to confirm they match**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSnapshotTests"`
+Expected: PASS (all, including the five new goldens).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs tests/FlexRender.Tests/Snapshots/golden/chart_scatter_light.png tests/FlexRender.Tests/Snapshots/golden/chart_bubble_light.png tests/FlexRender.Tests/Snapshots/golden/chart_gauge_dark.png tests/FlexRender.Tests/Snapshots/golden/chart_progress_light.png tests/FlexRender.Tests/Snapshots/golden/chart_sparkline_light.png
+git commit --no-gpg-sign -m "test(charts): snapshot goldens for scatter/bubble/gauge/progress/sparkline"
+```
+
+---
+
+## Task 13: Full build + full test gate
+
+**Files:** none (verification only)
+
+- [ ] **Step 1: Clean build**
+
+Run: `dotnet build FlexRender.slnx`
+Expected: BUILD SUCCEEDED, zero warnings (TreatWarningsAsErrors). If CA1859 / unused-using / unused-param errors appear, fix them in the offending file and rebuild.
+
+- [ ] **Step 2: Full test suite**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0`
+Expected: PASS — all tests green (Phase-1, Phase-2, and the new Phase-3 tests).
+
+- [ ] **Step 3: Commit (only if a fix was needed in Step 1)**
+
+```bash
+git add -A
+git commit --no-gpg-sign -m "chore(charts): resolve analyzer warnings for Phase-3 chart types"
+```
+
+If no fix was needed, skip this commit.
+
+---
+
+## Task 14: Docs — Playground schema + autocomplete
+
+**Files:**
+- Modify: `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json`
+- Modify: `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs`
+
+- [ ] **Step 1: Extend the schema chart-type enum**
+
+In `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json`, find the chart `"chart-type"` block (the `"enum": ["bar", "line", "area", "pie", "donut"]` near line 883) and replace that enum line with:
+
+```json
+ "enum": ["bar", "line", "area", "pie", "donut", "scatter", "bubble", "gauge", "progress", "sparkline"]
+```
+
+- [ ] **Step 2: Update the series.data description for tuples**
+
+In the same chart definition, replace the `"data"` description string inside `series.items.properties.data` with:
+
+```json
+ "description": "Flat number array (bar/line/area/pie/donut/sparkline), OR an array of [x, y] / [x, y, r] tuples (scatter/bubble), OR a {{ expr }} string resolving to a number array.",
+```
+
+- [ ] **Step 3: Add value/max/label to the chart schema properties**
+
+In the same chart definition `properties` block, after the `"labels"` property block, add (mind the trailing comma on the preceding `"labels"` block's closing brace):
+
+```json
+ "value": {
+ "description": "Gauge/progress only. The indicator value.",
+ "type": "number"
+ },
+ "max": {
+ "description": "Gauge/progress only. The indicator maximum (default 100).",
+ "type": "number"
+ },
+ "label": {
+ "description": "Gauge/progress only. Centered caption under the value.",
+ "type": "string"
+ }
+```
+
+- [ ] **Step 4: Update autocomplete if it enumerates chart-type values**
+
+Inspect `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs` for any literal list of chart-type values (`bar`, `donut`, etc.). If such a list exists, add `scatter`, `bubble`, `gauge`, `progress`, `sparkline`. If the file derives completions from the schema (no hardcoded chart-type list), no edit is needed — note that in the commit body.
+
+Verify with: `grep -n "donut" src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs` (no result means schema-driven; nothing to change).
+
+- [ ] **Step 5: Validate the JSON parses**
+
+Run: `node -e "JSON.parse(require('fs').readFileSync('src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json','utf8')); console.log('ok')"`
+Expected: prints `ok`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs
+git commit --no-gpg-sign -m "docs(playground): add Phase-3 chart types to schema and autocomplete"
+```
+
+---
+
+## Task 15: Docs — wiki Element-Reference + Visual-Reference
+
+**Files:**
+- Modify: `docs/wiki/Element-Reference.md`
+- Modify: `docs/wiki/Visual-Reference.md`
+
+- [ ] **Step 1: Update the chart intro + chart-type row in Element-Reference**
+
+In `docs/wiki/Element-Reference.md`, in the `## chart` section:
+
+1. Update the intro sentence (around line 1462) to mention the new types. Replace the existing chart-type list "renders `bar`, `line`, `area`, `pie`, and `donut` charts" with:
+
+```
+renders `bar`, `line`, `area`, `pie`, `donut`, `scatter`, `bubble`, `gauge`, `progress`, and `sparkline` charts
+```
+
+2. Update the `ChartType` property-table row (the `| ChartType | chart-type | ... | bar, line, area, pie, donut |` row near line 1480) to:
+
+```
+| ChartType | `chart-type` | string | `bar` | bar, line, area, pie, donut, scatter, bubble, gauge, progress, sparkline | No | Chart kind. |
+```
+
+3. Add three new rows to the chart property table immediately after the row documenting `labels` (the pie/donut label-mode row). Match the existing table's column layout exactly:
+
+```
+| Value | `value` | number | none | Any number | Gauge/progress only | Indicator value. |
+| Max | `max` | number | `100` | Any number | Gauge/progress only | Indicator maximum. |
+| ValueLabel | `label` | string | none | Any string | Gauge/progress only | Centered caption under the value. |
+```
+
+4. Add a short note paragraph after the chart property table documenting tuple data:
+
+```
+> **Scatter / bubble data:** supply `series[].data` as an array of tuples — `[[x, y], ...]` for `scatter` and `[[x, y, r], ...]` for `bubble`, where `r` sizes the bubble. Tuple data is inline-only in this release (expression-bound tuples are not yet supported). Sparkline uses a plain numeric `data` array and honors `smooth`/`points`.
+```
+
+- [ ] **Step 2: Add examples to Visual-Reference**
+
+In `docs/wiki/Visual-Reference.md`, find the chart section and append five compact examples (place them after the existing donut/pie chart example block):
+
+```
+### Scatter
+
+```yaml
+- type: chart
+ chart-type: scatter
+ width: 480
+ height: 320
+ series:
+ - data: [[1, 12], [3, 30], [5, 22], [7, 48], [9, 35]]
+ legend: none
+ palette: ocean
+```
+
+### Bubble
+
+```yaml
+- type: chart
+ chart-type: bubble
+ width: 480
+ height: 320
+ series:
+ - data: [[1, 12, 6], [3, 30, 18], [5, 22, 10], [7, 48, 24]]
+ legend: none
+ palette: sunset
+```
+
+### Gauge
+
+```yaml
+- type: chart
+ chart-type: gauge
+ width: 280
+ height: 220
+ value: 72
+ max: 100
+ label: CPU
+```
+
+### Progress
+
+```yaml
+- type: chart
+ chart-type: progress
+ width: 240
+ height: 240
+ value: 64
+ label: Disk
+```
+
+### Sparkline
+
+```yaml
+- type: chart
+ chart-type: sparkline
+ width: 200
+ height: 50
+ smooth: true
+ series:
+ - data: [3, 8, 4, 10, 6, 9, 5, 11]
+```
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add docs/wiki/Element-Reference.md docs/wiki/Visual-Reference.md
+git commit --no-gpg-sign -m "docs(wiki): document Phase-3 chart types"
+```
+
+---
+
+## Task 16: Docs — llms.txt + llms-full.txt
+
+**Files:**
+- Modify: `llms.txt`
+- Modify: `llms-full.txt`
+
+- [ ] **Step 1: Update the chart-type listing in both files**
+
+In both `llms.txt` and `llms-full.txt`, find the text that enumerates chart-types (search for `donut`) and update the list to include the new types. Replace the chart-type enumeration (e.g. `bar, line, area, pie, donut`) with:
+
+```
+bar, line, area, pie, donut, scatter, bubble, gauge, progress, sparkline
+```
+
+- [ ] **Step 2: Add a Phase-3 usage note in llms-full.txt**
+
+In `llms-full.txt`, in the chart section, add this note after the existing chart-type description:
+
+```
+Scatter and bubble take tuple data: `data: [[x, y], ...]` (scatter) or `data: [[x, y, r], ...]` (bubble, r = bubble radius). Gauge and progress are single-value indicators using `value`, `max` (default 100), and `label` (centered caption); gauge draws a 270-degree dial and progress draws a ring. Sparkline is a tiny inline line chart (no axes/labels/legend) using a plain numeric `data` array, honoring `smooth` and `points`.
+```
+
+- [ ] **Step 3: Verify the strings landed**
+
+Run: `grep -c "sparkline" llms.txt llms-full.txt`
+Expected: a non-zero count for each file.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add llms.txt llms-full.txt
+git commit --no-gpg-sign -m "docs(llms): document Phase-3 chart types"
+```
+
+---
+
+## Task 17: Final verification
+
+**Files:** none (verification only)
+
+- [ ] **Step 1: Clean build**
+
+Run: `dotnet build FlexRender.slnx`
+Expected: BUILD SUCCEEDED, zero warnings.
+
+- [ ] **Step 2: Full test suite (authoritative framework)**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0`
+Expected: PASS — every test green.
+
+- [ ] **Step 3: Confirm git state is clean**
+
+Run: `git status`
+Expected: working tree clean (all Phase-3 work committed). The branch remains `feature/charts-and-shapes`; do NOT merge to `main`.
+
+---
+
+## Self-review
+
+**Spec coverage (each Phase-3 type → task):**
+- `scatter` — type enum (Task 1), `Points` rep (Task 2), tuple parsing (Task 4), X/Y-scale rendering (Task 8), smoke (Task 11), snapshot (Task 12).
+- `bubble` — same chain; radius via third tuple element (Task 4) and radius-scaled markers (Task 8).
+- `gauge` — `Value`/`Max`/`ValueLabel` (Task 3), parsing (Task 5), 270° dial rendering + no-data on missing value (Task 9), smoke (Task 11), snapshot (Task 12).
+- `progress` — same props/parsing; ring rendering (Task 10), smoke (Task 11), snapshot (Task 12).
+- `sparkline` — type enum (Task 1), no-chrome line reusing `BuildSeriesPath` honoring `smooth`/`points` (Task 7), smoke (Task 11), snapshot (Task 12).
+- Error handling: unknown-prop typo suggestions covered by existing `KnownProperties` validation + new props registered (Task 5, test in Task 5); malformed tuples (arity/non-numeric/non-finite) named per series (Task 4); gauge/progress missing `value` → no-data placeholder (Task 6 `HasAnyData` + Task 9 test).
+- Docs: schema + autocomplete (Task 14), wiki (Task 15), llms (Task 16).
+- Resource limits reused, not weakened: scatter/bubble points counted against `MaxDataPointsPerSeries` via the existing `dataSeq.Children.Count` check in `ParseOneSeries` (Task 4) before branching to tuple parsing.
+
+**Placeholder scan:** No `TBD`/`TODO`/"add appropriate"/"similar to Task N" — every code step shows complete, compilable code. The one conditional (Task 6 standalone commit vs fold-into-Task-8) is fully specified with the exact condition and both paths.
+
+**Type consistency:**
+- `ChartPoint(double X, double Y, double R)` + `ChartPoint.Xy(x, y)` — defined Task 2, used identically in Tasks 4 (`new ChartPoint(x, y, r)`), 8 (`p.X/p.Y/p.R`), and tests.
+- `ChartSeries.FromPoints(string?, IReadOnlyList)` and `.Points` — defined Task 2, used in Tasks 4, 8.
+- `ChartElement.Value`/`Max`/`ValueLabel` (all nullable; `Value`/`Max` are `double?`, `ValueLabel` is `string?`) — defined Task 3, set in Task 5 (`GetDoubleValue` returns `double?`, `GetStringValue` returns `string?`), consumed in Tasks 9/10 (`chart.Value ?? 0d`, `chart.Max is > 0d ? ... : 100d`).
+- `PointBounds` returns `(double MinX, double MaxX, double MinY, double MaxY)` — defined Task 6, deconstructed identically in Task 8.
+- `DrawIndicatorText(canvas, chart, theme, value, max, cx, cy, typeface, antialias)` — defined Task 9, called with the same argument order in Task 10.
+- `DrawScatterOrBubble(..., bool isBubble, ...)` and `DrawGauge`/`DrawProgress`/`DrawSparkline` signatures match their `DrawSeries` call sites in Tasks 7–10.
+- `ParseOneSeries(YamlMappingNode, ChartType, int)` and `ParseSeries(YamlMappingNode, ChartType, int, int)` — both updated together in Task 4; the `ParseChartElement` call site updated in the same task.
diff --git a/docs/superpowers/plans/2026-06-13-charts-phase4.md b/docs/superpowers/plans/2026-06-13-charts-phase4.md
new file mode 100644
index 0000000..cad8d73
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-13-charts-phase4.md
@@ -0,0 +1,1775 @@
+# Charts (Phase 4) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add the final two chart types — `heatmap` (a grid of value-colored cells with x/y labels and optional cell values) and `radar` (category spokes with one filled+stroked closed polygon per series) — so LLM agents can render the full chart catalog from declarative YAML with zero styling decisions.
+
+**Architecture:** Both new types reuse the existing `ChartSeries` model. **Heatmap** treats each series as one ROW (`series[i].Data` = row *i*'s column values), `Categories` = column / x-axis labels, and a new `YLabels` property = row labels; an optional `ShowCellValues` bool draws the numeric value inside each cell. Cell color is interpolated per-value between a "low" and "high" color (derived from `palette[0]`/`palette[1]`) via a small AOT-safe `SKColor`-channel lerp. **Radar** treats each series as a closed polygon: `Categories` define N spokes radiating from center (spoke *i* at angle `-90° + 360°*i/N`, starting at top), and each series value maps radially from center (0) to the plot edge (axis max) via an `AxisScale`-derived radial mapper. New `ChartElement` props (`XLabels`, `YLabels`, `ShowCellValues`) follow the existing auto-prop + `CloneWithSubstitution` pattern; new YAML props (`x-labels`, `y-labels`, `cell-values`) register in `KnownProperties.Chart`. Rendering adds two arms to `ChartRenderer.DrawSeries` (`Heatmap` → `DrawHeatmap`, `Radar` → `DrawRadar`) reusing `AxisScale`, palette `ColorAt`, theme colors, `ColorParser`, `FormatTick`, and the existing outer `Save`/`Restore`/clip. No new packages, no new external dependencies.
+
+**Tech Stack:** .NET 10, C# latest, xUnit, SkiaSharp, YamlDotNet. AOT-safe (no reflection, no `dynamic`, no runtime regex), `sealed` classes, `ArgumentNullException.ThrowIfNull`, switch-based dispatch, XML docs on all public API.
+
+---
+
+## Conventions used throughout this plan
+
+- All commands run from repo root `/Users/robonet/Projects/SkiaLayout`.
+- Branch is already `feature/charts-and-shapes`. Do NOT create worktrees. Do NOT merge to `main`.
+- Build: `dotnet build FlexRender.slnx`. Test (authoritative, net10.0): `dotnet test FlexRender.slnx --framework net10.0 --filter ...`. The net8.0 test host cannot launch in this environment — always pass `--framework net10.0`.
+- NEVER pipe `dotnet` output through `tail`/`head`/`grep`. Run commands directly.
+- Commit messages use Conventional Commits, NO attribution / Co-Authored-By lines. The signing key is missing — every commit uses `--no-gpg-sign`.
+- After every code edit the build must be warning-free (`TreatWarningsAsErrors=true`). Watch for CA1859 (return concrete types where the concrete type is used internally), unused-parameter, and unused-using analyzer errors.
+- All new public API gets XML docs. All new YAML props get registered in `KnownProperties.Chart`. Never weaken `ResourceLimits`.
+- Snapshot goldens are generated by running the snapshot test once with `UPDATE_SNAPSHOTS=true dotnet test FlexRender.slnx --framework net10.0 --filter ...`, then committed.
+- All code, comments, and XML docs are written in English.
+
+## File structure (created / modified across all tasks)
+
+Created:
+- `src/FlexRender.Skia.Render/Rendering/HeatmapColorScale.cs` (AOT-safe two-color value→color lerp)
+- Test files (one per task, see each task)
+
+Modified:
+- `src/FlexRender.Core/Charts/ChartEnums.cs` (add `Heatmap`, `Radar` to `ChartType`)
+- `src/FlexRender.Core/Parsing/Ast/ChartElement.cs` (add `XLabels`/`YLabels`/`ShowCellValues` props; clone them)
+- `src/FlexRender.Yaml/Parsing/ChartParsers.cs` (parse `x-labels`, `y-labels`, `cell-values`; chart-type error message lists all types)
+- `src/FlexRender.Yaml/Parsing/KnownProperties.cs` (add `x-labels`, `y-labels`, `cell-values` to `Chart` set)
+- `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs` (2 new `DrawSeries` arms + `DrawHeatmap`/`DrawRadar`)
+- Docs: `llms.txt`, `llms-full.txt`, `docs/wiki/Element-Reference.md`, `docs/wiki/Visual-Reference.md`,
+ `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json`,
+ `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs`
+
+## Highest-risk tasks (flagged for extra review)
+
+1. **Task 2 + 4 — heatmap 2D-data representation + parsing.** The decision to model each series as one row (no new data structure) must coexist cleanly with the flat-`Data` path used by bar/line. `x-labels`/`y-labels` parse like `categories` (string lists). Rows may have ragged lengths; the renderer must tolerate that. No new resource limit: rows are bounded by `MaxSeriesPerChart`, columns by `MaxDataPointsPerSeries` via the existing per-series checks.
+2. **Task 8 — radar polygon geometry + radial mapping.** N spokes at `-90° + 360°*i/N`; each series value maps from center (radius 0 at axis min, which is anchored at 0 by `AxisScale`) to the outer radius (axis max). Closed `SKPath` (fill semi-transparent + stroke). Degenerate cases (N < 3 categories, single series, all-zero values) must not divide by zero or throw, and every `SKPath`/`SKPaint`/`SKFont` must be disposed with balanced `Save`/`Restore`.
+3. **Task 6 — heatmap color interpolation.** `HeatmapColorScale` lerps each `SKColor` channel between a low and high color. Must be deterministic for snapshots, clamp `t` into `[0, 1]`, and handle a degenerate value range (min == max → all cells use the high color).
+
+---
+
+## Task 1: Extend ChartType enum with Heatmap and Radar
+
+**Files:**
+- Modify: `src/FlexRender.Core/Charts/ChartEnums.cs`
+- Test: `tests/FlexRender.Tests/Charts/ChartTypePhase4Tests.cs` (create)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Charts/ChartTypePhase4Tests.cs`:
+
+```csharp
+using System;
+using FlexRender.Charts;
+using Xunit;
+
+namespace FlexRender.Tests.Charts;
+
+///
+/// Sanity tests for the Phase-4 chart type enum members.
+///
+public sealed class ChartTypePhase4Tests
+{
+ [Theory]
+ [InlineData("Heatmap")]
+ [InlineData("Radar")]
+ public void ChartType_HasAllPhase4Members(string name)
+ {
+ Assert.True(Enum.TryParse(name, out _));
+ }
+
+ [Theory]
+ [InlineData("Bar")]
+ [InlineData("Scatter")]
+ [InlineData("Gauge")]
+ [InlineData("Sparkline")]
+ public void ChartType_StillHasEarlierMembers(string name)
+ {
+ Assert.True(Enum.TryParse(name, out _));
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartTypePhase4Tests"`
+Expected: FAIL — `Heatmap`/`Radar` not defined (the two Phase-4 cases parse to false).
+
+- [ ] **Step 3: Add the enum members**
+
+In `src/FlexRender.Core/Charts/ChartEnums.cs`, the `ChartType` enum currently ends with `Sparkline`. Add a comma after `Sparkline` and append the two new members so the enum tail reads:
+
+```csharp
+ /// Tiny inline line chart with no axes, labels, or legend.
+ Sparkline,
+
+ /// Grid of value-colored cells (2D data: rows = series, columns = categories).
+ Heatmap,
+
+ /// Radar (spider) chart: category spokes with one closed polygon per series.
+ Radar
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartTypePhase4Tests"`
+Expected: PASS (6 cases).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Core/Charts/ChartEnums.cs tests/FlexRender.Tests/Charts/ChartTypePhase4Tests.cs
+git commit --no-gpg-sign -m "feat(charts): add heatmap and radar chart types"
+```
+
+---
+
+## Task 2: ChartElement gains XLabels / YLabels / ShowCellValues
+
+Heatmap needs explicit column (x) and row (y) labels plus an opt-in to draw the numeric value inside each cell. `XLabels` falls back to `Categories` at render time when empty (so `categories:` keeps working as the x-axis). Radar reuses `Categories` and `Series` only — no new props.
+
+**Files:**
+- Modify: `src/FlexRender.Core/Parsing/Ast/ChartElement.cs`
+- Test: `tests/FlexRender.Tests/Parsing/Ast/ChartElementHeatmapTests.cs` (create)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Parsing/Ast/ChartElementHeatmapTests.cs`:
+
+```csharp
+using System;
+using System.Collections.Generic;
+using FlexRender.Charts;
+using FlexRender.Parsing.Ast;
+using Xunit;
+
+namespace FlexRender.Tests.Parsing.Ast;
+
+///
+/// Tests for the heatmap label/cell-value properties on .
+///
+public sealed class ChartElementHeatmapTests
+{
+ [Fact]
+ public void Defaults_LabelsEmpty_CellValuesFalse()
+ {
+ var chart = new ChartElement(ChartType.Heatmap, new List());
+ Assert.Empty(chart.XLabels);
+ Assert.Empty(chart.YLabels);
+ Assert.False(chart.ShowCellValues);
+ }
+
+ [Fact]
+ public void CloneWithSubstitution_PreservesLabelsAndCellValues()
+ {
+ var chart = new ChartElement(ChartType.Heatmap, new List())
+ {
+ XLabels = new[] { "Mon", "Tue" },
+ YLabels = new[] { "AM", "PM" },
+ ShowCellValues = true
+ };
+
+ var clone = (ChartElement)chart.CloneWithSubstitution(s => s);
+
+ Assert.Equal(new[] { "Mon", "Tue" }, clone.XLabels);
+ Assert.Equal(new[] { "AM", "PM" }, clone.YLabels);
+ Assert.True(clone.ShowCellValues);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartElementHeatmapTests"`
+Expected: BUILD FAILURE — `XLabels`/`YLabels`/`ShowCellValues` not defined.
+
+- [ ] **Step 3: Add the properties + clone**
+
+In `src/FlexRender.Core/Parsing/Ast/ChartElement.cs`, add these three properties after the `ValueLabel` property (the last property before `CloneWithSubstitution`):
+
+```csharp
+ /// Gets or sets the heatmap column (x-axis) labels. Empty falls back to .
+ public IReadOnlyList XLabels { get; set; } = Array.Empty();
+
+ /// Gets or sets the heatmap row (y-axis) labels, one per series/row.
+ public IReadOnlyList YLabels { get; set; } = Array.Empty();
+
+ /// Gets or sets whether the heatmap draws each cell's numeric value inside the cell.
+ public bool ShowCellValues { get; set; }
+```
+
+In `CloneWithSubstitution`, add the three to the object initializer (after `ValueLabel = ValueLabel`):
+
+```csharp
+ ValueLabel = ValueLabel,
+ XLabels = XLabels,
+ YLabels = YLabels,
+ ShowCellValues = ShowCellValues
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartElementHeatmapTests"`
+Expected: PASS (2 cases).
+
+- [ ] **Step 5: Verify earlier ChartElement tests still pass**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartElementGaugeTests"`
+Expected: PASS (no regression to the Phase-3 clone properties).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/FlexRender.Core/Parsing/Ast/ChartElement.cs tests/FlexRender.Tests/Parsing/Ast/ChartElementHeatmapTests.cs
+git commit --no-gpg-sign -m "feat(ast): add XLabels/YLabels/ShowCellValues to ChartElement for heatmap"
+```
+
+---
+
+## Task 3: Parse x-labels / y-labels / cell-values + register KnownProperties + chart-type error message
+
+`x-labels` and `y-labels` parse exactly like `categories` (a sequence of scalar strings). `cell-values` is a bool. The `chart-type` error message must list `heatmap` and `radar`.
+
+**Files:**
+- Modify: `src/FlexRender.Yaml/Parsing/ChartParsers.cs`
+- Modify: `src/FlexRender.Yaml/Parsing/KnownProperties.cs`
+- Test: `tests/FlexRender.Tests/Parsing/ChartHeatmapParsersTests.cs` (create)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Parsing/ChartHeatmapParsersTests.cs`:
+
+```csharp
+using FlexRender.Charts;
+using FlexRender.Parsing;
+using FlexRender.Parsing.Ast;
+using Xunit;
+
+namespace FlexRender.Tests.Parsing;
+
+///
+/// Tests for parsing heatmap labels / cell-values, radar series, and the updated chart-type validation.
+///
+public sealed class ChartHeatmapParsersTests
+{
+ private readonly TemplateParser _parser = new();
+
+ [Fact]
+ public void Parse_Heatmap_ReadsLabelsAndCellValues()
+ {
+ const string yaml = """
+ canvas:
+ width: 400
+ layout:
+ - type: chart
+ chart-type: heatmap
+ width: 400
+ height: 300
+ x-labels: [Mon, Tue, Wed]
+ y-labels: [AM, PM]
+ cell-values: true
+ series:
+ - data: [1, 2, 3]
+ - data: [4, 5, 6]
+ """;
+
+ var template = _parser.Parse(yaml);
+ var chart = Assert.IsType(template.Elements[0]);
+
+ Assert.Equal(ChartType.Heatmap, chart.ChartType);
+ Assert.Equal(new[] { "Mon", "Tue", "Wed" }, chart.XLabels);
+ Assert.Equal(new[] { "AM", "PM" }, chart.YLabels);
+ Assert.True(chart.ShowCellValues);
+ Assert.Equal(2, chart.Series.Count);
+ Assert.Equal(3, chart.Series[0].Data.Count);
+ }
+
+ [Fact]
+ public void Parse_Heatmap_DefaultsLabelsEmptyCellValuesFalse()
+ {
+ const string yaml = """
+ canvas:
+ width: 400
+ layout:
+ - type: chart
+ chart-type: heatmap
+ width: 400
+ height: 300
+ series:
+ - data: [1, 2]
+ """;
+
+ var template = _parser.Parse(yaml);
+ var chart = Assert.IsType(template.Elements[0]);
+
+ Assert.Empty(chart.XLabels);
+ Assert.Empty(chart.YLabels);
+ Assert.False(chart.ShowCellValues);
+ }
+
+ [Fact]
+ public void Parse_Radar_ReadsCategoriesAndSeries()
+ {
+ const string yaml = """
+ canvas:
+ width: 400
+ layout:
+ - type: chart
+ chart-type: radar
+ width: 400
+ height: 400
+ categories: [Speed, Power, Range, Agility, Armor]
+ series:
+ - label: A
+ data: [4, 3, 5, 2, 4]
+ - label: B
+ data: [3, 5, 2, 4, 3]
+ """;
+
+ var template = _parser.Parse(yaml);
+ var chart = Assert.IsType(template.Elements[0]);
+
+ Assert.Equal(ChartType.Radar, chart.ChartType);
+ Assert.Equal(5, chart.Categories.Count);
+ Assert.Equal(2, chart.Series.Count);
+ Assert.Equal(5, chart.Series[0].Data.Count);
+ }
+
+ [Fact]
+ public void Parse_TypoInXLabels_Throws()
+ {
+ const string yaml = """
+ canvas:
+ width: 400
+ layout:
+ - type: chart
+ chart-type: heatmap
+ width: 400
+ height: 300
+ x-labesl: [Mon, Tue]
+ series:
+ - data: [1, 2]
+ """;
+
+ var ex = Assert.Throws(() => _parser.Parse(yaml));
+ Assert.Contains("x-labesl", ex.Message);
+ }
+
+ [Fact]
+ public void Parse_UnknownChartType_ErrorListsHeatmapAndRadar()
+ {
+ const string yaml = """
+ canvas:
+ width: 400
+ layout:
+ - type: chart
+ chart-type: nope
+ width: 400
+ height: 300
+ series:
+ - data: [1, 2]
+ """;
+
+ var ex = Assert.Throws(() => _parser.Parse(yaml));
+ Assert.Contains("heatmap", ex.Message);
+ Assert.Contains("radar", ex.Message);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartHeatmapParsersTests"`
+Expected: FAIL — `x-labels`/`y-labels`/`cell-values` are unknown properties (parser rejects them) and not yet read onto the element; the chart-type error message omits `heatmap`/`radar`.
+
+- [ ] **Step 3: Register the new props in KnownProperties**
+
+In `src/FlexRender.Yaml/Parsing/KnownProperties.cs`, the `Chart` set, add `"x-labels", "y-labels", "cell-values"` to the list. The set currently reads (Phase-3 state):
+
+```csharp
+ internal static readonly HashSet Chart = BuildSet(FlexItemProperties,
+ [
+ "chart-type", "categories", "series", "palette", "theme", "legend", "title",
+ "horizontal", "stacked", "smooth", "points", "labels",
+ "value", "max", "label",
+ "background", "rotate", "padding", "margin"
+ ]);
+```
+
+Replace it with (insert the new line after the `value`/`max`/`label` line):
+
+```csharp
+ internal static readonly HashSet Chart = BuildSet(FlexItemProperties,
+ [
+ "chart-type", "categories", "series", "palette", "theme", "legend", "title",
+ "horizontal", "stacked", "smooth", "points", "labels",
+ "value", "max", "label",
+ "x-labels", "y-labels", "cell-values",
+ "background", "rotate", "padding", "margin"
+ ]);
+```
+
+> If the actual file's `Chart` initializer differs slightly from the snippet above (e.g. property order), only add the three new strings `"x-labels"`, `"y-labels"`, `"cell-values"` to the existing collection — do not reorder or remove anything.
+
+- [ ] **Step 4: Parse the new props + update the chart-type error message**
+
+In `src/FlexRender.Yaml/Parsing/ChartParsers.cs`:
+
+1. Replace the `chart-type` error message in `ParseChartType` so it lists every type:
+
+```csharp
+ throw new TemplateParseException(
+ $"Unknown chart-type '{raw}'. Valid values: bar, line, area, pie, donut, scatter, bubble, gauge, progress, sparkline, heatmap, radar.");
+```
+
+2. In `ParseChartElement`'s object initializer, add three lines after `ValueLabel = GetStringValue(node, "label"),`:
+
+```csharp
+ ValueLabel = GetStringValue(node, "label"),
+ XLabels = ParseStringList(node, "x-labels"),
+ YLabels = ParseStringList(node, "y-labels"),
+ ShowCellValues = GetBoolValue(node, "cell-values", false),
+```
+
+3. Add a reusable string-list parser. The existing `ParseCategories` reads `categories` specifically; generalize by adding a `ParseStringList` helper next to it (do NOT delete `ParseCategories` — `Categories` still uses it). Add after `ParseCategories`:
+
+```csharp
+ ///
+ /// Parses an optional sequence of scalar string labels under the given key (heatmap x/y labels).
+ ///
+ /// The chart mapping node.
+ /// The property key (e.g. "x-labels").
+ /// The labels in order; empty when the key is absent.
+ private static List ParseStringList(YamlMappingNode node, string key)
+ {
+ var labels = new List();
+ if (TryGetSequence(node, key, out var seq))
+ {
+ foreach (var item in seq.Children)
+ {
+ if (item is YamlScalarNode scalar && scalar.Value is not null)
+ labels.Add(scalar.Value);
+ }
+ }
+ return labels;
+ }
+```
+
+Note: `GetBoolValue`, `TryGetSequence`, `GetStringValue` are already imported via the `static ... YamlPropertyHelpers` using; `YamlScalarNode` / `YamlSequenceNode` via `YamlDotNet.RepresentationModel`. `List` is assignable to the `IReadOnlyList` setters on `ChartElement`.
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartHeatmapParsersTests"`
+Expected: PASS (5 cases).
+
+- [ ] **Step 6: Verify earlier parser + KnownProperties tests still pass**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartParsersTests|FullyQualifiedName~ChartKnownPropertiesTests|FullyQualifiedName~ChartScatterParsersTests"`
+Expected: PASS (no regression; existing `categories`/`series`/tuple parsing unaffected).
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/FlexRender.Yaml/Parsing/ChartParsers.cs src/FlexRender.Yaml/Parsing/KnownProperties.cs tests/FlexRender.Tests/Parsing/ChartHeatmapParsersTests.cs
+git commit --no-gpg-sign -m "feat(yaml): parse heatmap x-labels/y-labels/cell-values and register chart props"
+```
+
+---
+
+## Task 4: HasAnyData already covers heatmap/radar — verify, no code change expected
+
+Heatmap and radar both use flat `Data` series, which `HasAnyData` already treats as "has data" (`s.Data.Count > 0`). This task is a guard: confirm an empty heatmap/radar renders the no-data placeholder and a non-empty one does not short-circuit. No production code change is expected; if a change *is* needed, this task documents it.
+
+**Files:**
+- Test: `tests/FlexRender.Tests/Rendering/ChartHeatmapRadarDataTests.cs` (create)
+
+- [ ] **Step 1: Write the test**
+
+Create `tests/FlexRender.Tests/Rendering/ChartHeatmapRadarDataTests.cs`:
+
+```csharp
+using System.Collections.Generic;
+using FlexRender.Charts;
+using FlexRender.Parsing.Ast;
+using FlexRender.Rendering;
+using SkiaSharp;
+using Xunit;
+
+namespace FlexRender.Tests.Rendering;
+
+///
+/// Verifies empty heatmap/radar charts draw the no-data placeholder and non-empty ones draw content.
+///
+public sealed class ChartHeatmapRadarDataTests
+{
+ [Fact]
+ public void EmptyHeatmap_DrawsNoDataPlaceholder()
+ {
+ var chart = new ChartElement(ChartType.Heatmap, new List())
+ {
+ Legend = LegendPosition.None,
+ Theme = ChartThemes.Default
+ };
+
+ using var bitmap = new SKBitmap(200, 150, SKColorType.Rgba8888, SKAlphaType.Premul);
+ using var canvas = new SKCanvas(bitmap);
+ canvas.Clear(SKColors.White);
+
+ ChartRenderer.Draw(canvas, chart, 0f, 0f, 200f, 150f, typeface: null, antialias: true);
+
+ Assert.True(HasNonWhitePixel(bitmap), "Expected the no-data border to draw.");
+ }
+
+ [Fact]
+ public void EmptyRadar_DrawsNoDataPlaceholder()
+ {
+ var chart = new ChartElement(ChartType.Radar, new List())
+ {
+ Legend = LegendPosition.None,
+ Theme = ChartThemes.Default
+ };
+
+ using var bitmap = new SKBitmap(200, 200, SKColorType.Rgba8888, SKAlphaType.Premul);
+ using var canvas = new SKCanvas(bitmap);
+ canvas.Clear(SKColors.White);
+
+ ChartRenderer.Draw(canvas, chart, 0f, 0f, 200f, 200f, typeface: null, antialias: true);
+
+ Assert.True(HasNonWhitePixel(bitmap), "Expected the no-data border to draw.");
+ }
+
+ private static bool HasNonWhitePixel(SKBitmap bitmap)
+ {
+ for (var y = 0; y < bitmap.Height; y++)
+ for (var x = 0; x < bitmap.Width; x++)
+ {
+ var p = bitmap.GetPixel(x, y);
+ if (p.Red < 240 || p.Green < 240 || p.Blue < 240)
+ return true;
+ }
+ return false;
+ }
+}
+```
+
+- [ ] **Step 2: Run the test**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartHeatmapRadarDataTests"`
+Expected: PASS (2 cases). `HasAnyData` returns false for empty series, so `Draw` calls `DrawNoData`, which strokes a dashed border — non-white pixels appear. No production change needed.
+
+> If a case unexpectedly FAILS (blank bitmap), it means `HasAnyData` short-circuited incorrectly; in that case inspect `HasAnyData` in `ChartRenderer.cs` and confirm it returns false only when *all* series have empty `Data` and empty `Points` (it should already). Do not weaken the gauge/progress branch.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add tests/FlexRender.Tests/Rendering/ChartHeatmapRadarDataTests.cs
+git commit --no-gpg-sign -m "test(skia): no-data placeholder for empty heatmap and radar"
+```
+
+---
+
+## Task 5: HeatmapColorScale — AOT-safe two-color value→color lerp
+
+A small pure helper maps a value in `[min, max]` to an `SKColor` interpolated channel-by-channel between a `low` and `high` color. Deterministic (no randomness), clamps the interpolation parameter, and handles `min == max` (returns the high color).
+
+**Files:**
+- Create: `src/FlexRender.Skia.Render/Rendering/HeatmapColorScale.cs`
+- Test: `tests/FlexRender.Tests/Rendering/HeatmapColorScaleTests.cs` (create)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Rendering/HeatmapColorScaleTests.cs`:
+
+```csharp
+using FlexRender.Rendering;
+using SkiaSharp;
+using Xunit;
+
+namespace FlexRender.Tests.Rendering;
+
+///
+/// Tests for the heatmap two-color value-to-color interpolation.
+///
+public sealed class HeatmapColorScaleTests
+{
+ private static readonly SKColor Low = new(0, 0, 0); // black
+ private static readonly SKColor High = new(200, 100, 50); // warm
+
+ [Fact]
+ public void Map_AtMin_ReturnsLowColor()
+ {
+ var c = HeatmapColorScale.Map(0d, 0d, 10d, Low, High);
+ Assert.Equal(Low.Red, c.Red);
+ Assert.Equal(Low.Green, c.Green);
+ Assert.Equal(Low.Blue, c.Blue);
+ }
+
+ [Fact]
+ public void Map_AtMax_ReturnsHighColor()
+ {
+ var c = HeatmapColorScale.Map(10d, 0d, 10d, Low, High);
+ Assert.Equal(High.Red, c.Red);
+ Assert.Equal(High.Green, c.Green);
+ Assert.Equal(High.Blue, c.Blue);
+ }
+
+ [Fact]
+ public void Map_Midpoint_IsBetweenLowAndHigh()
+ {
+ var c = HeatmapColorScale.Map(5d, 0d, 10d, Low, High);
+ Assert.Equal((byte)100, c.Red); // (0 + 200) / 2
+ Assert.Equal((byte)50, c.Green); // (0 + 100) / 2
+ Assert.Equal((byte)25, c.Blue); // (0 + 50) / 2
+ }
+
+ [Fact]
+ public void Map_DegenerateRange_ReturnsHighColor()
+ {
+ var c = HeatmapColorScale.Map(5d, 5d, 5d, Low, High);
+ Assert.Equal(High.Red, c.Red);
+ Assert.Equal(High.Green, c.Green);
+ Assert.Equal(High.Blue, c.Blue);
+ }
+
+ [Fact]
+ public void Map_BelowMin_ClampsToLow()
+ {
+ var c = HeatmapColorScale.Map(-5d, 0d, 10d, Low, High);
+ Assert.Equal(Low.Red, c.Red);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~HeatmapColorScaleTests"`
+Expected: BUILD FAILURE — `HeatmapColorScale` not defined.
+
+- [ ] **Step 3: Create the helper**
+
+Create `src/FlexRender.Skia.Render/Rendering/HeatmapColorScale.cs`:
+
+```csharp
+using System;
+using SkiaSharp;
+
+namespace FlexRender.Rendering;
+
+///
+/// Maps a numeric value within a data range to a color interpolated channel-by-channel between a
+/// "low" and a "high" color. Pure and deterministic (AOT-safe, no allocations, no randomness),
+/// suitable for snapshot-stable heatmap cell coloring.
+///
+internal static class HeatmapColorScale
+{
+ ///
+ /// Maps in .. to a color
+ /// linearly interpolated between (at ) and
+ /// (at ). The interpolation parameter is clamped
+ /// to [0, 1]; a degenerate range ( == )
+ /// yields .
+ ///
+ /// The cell value.
+ /// The data minimum (maps to ).
+ /// The data maximum (maps to ).
+ /// The color at the low end of the range.
+ /// The color at the high end of the range.
+ /// The interpolated, fully opaque color.
+ public static SKColor Map(double value, double min, double max, SKColor low, SKColor high)
+ {
+ var span = max - min;
+ var t = span <= 0d ? 1d : Math.Clamp((value - min) / span, 0d, 1d);
+
+ var r = Lerp(low.Red, high.Red, t);
+ var g = Lerp(low.Green, high.Green, t);
+ var b = Lerp(low.Blue, high.Blue, t);
+ return new SKColor(r, g, b);
+ }
+
+ /// Linearly interpolates a single 0..255 channel and rounds to the nearest byte.
+ /// The channel value at t = 0.
+ /// The channel value at t = 1.
+ /// The interpolation parameter in [0, 1].
+ /// The interpolated channel as a byte.
+ private static byte Lerp(byte a, byte b, double t)
+ {
+ var v = a + ((b - a) * t);
+ return (byte)Math.Clamp(Math.Round(v), 0d, 255d);
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~HeatmapColorScaleTests"`
+Expected: PASS (5 cases).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Skia.Render/Rendering/HeatmapColorScale.cs tests/FlexRender.Tests/Rendering/HeatmapColorScaleTests.cs
+git commit --no-gpg-sign -m "feat(skia): add heatmap two-color value-to-color interpolation"
+```
+
+---
+
+## Task 6: Renderer — heatmap grid
+
+Each series is one row; `Categories` (or `XLabels` when set) are columns. Compute the global min/max across all cells, then for each cell fill a rect with `HeatmapColorScale.Map(...)` using `low = palette[0]`, `high = palette[1]` (or `palette[0]` again when the palette has one color). Draw x-labels under the grid, y-labels left of the grid, and (when `ShowCellValues`) the value centered in each cell.
+
+**Files:**
+- Modify: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`
+- Test: `tests/FlexRender.Tests/Rendering/ChartHeatmapRenderTests.cs` (create)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Rendering/ChartHeatmapRenderTests.cs`:
+
+```csharp
+using System.Collections.Generic;
+using FlexRender.Charts;
+using FlexRender.Parsing.Ast;
+using FlexRender.Rendering;
+using SkiaSharp;
+using Xunit;
+
+namespace FlexRender.Tests.Rendering;
+
+///
+/// Verifies the heatmap draws value-colored cells.
+///
+public sealed class ChartHeatmapRenderTests
+{
+ [Fact]
+ public void Heatmap_DrawsHighValueCellInHighColor()
+ {
+ // Two rows of two cells; the max value (40) cell must paint the palette "high" color (red).
+ var chart = new ChartElement(ChartType.Heatmap, new List
+ {
+ ChartSeries.FromInline("r0", new[] { 0d, 10d }),
+ ChartSeries.FromInline("r1", new[] { 20d, 40d })
+ })
+ {
+ Legend = LegendPosition.None,
+ // low = white, high = red, so the highest cell is red and the lowest is white.
+ Palette = new ChartPalette(new[] { "#ffffff", "#ff0000" }),
+ Theme = ChartThemes.Default
+ };
+
+ using var bitmap = new SKBitmap(200, 200, SKColorType.Rgba8888, SKAlphaType.Premul);
+ using var canvas = new SKCanvas(bitmap);
+ canvas.Clear(SKColors.White);
+
+ ChartRenderer.Draw(canvas, chart, 0f, 0f, 200f, 200f, typeface: null, antialias: false);
+
+ Assert.True(HasRedPixel(bitmap), "Expected the highest-value cell to be red.");
+ }
+
+ private static bool HasRedPixel(SKBitmap bitmap)
+ {
+ for (var y = 0; y < bitmap.Height; y++)
+ for (var x = 0; x < bitmap.Width; x++)
+ {
+ var p = bitmap.GetPixel(x, y);
+ if (p.Red > 180 && p.Green < 100 && p.Blue < 100)
+ return true;
+ }
+ return false;
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartHeatmapRenderTests"`
+Expected: FAIL — the `default` arm of `DrawSeries` draws only a faint gray border, no red cell.
+
+- [ ] **Step 3: Add the Heatmap case + method**
+
+In `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`, add a case to the `DrawSeries` switch (before `default:`):
+
+```csharp
+ case ChartType.Heatmap:
+ DrawHeatmap(canvas, chart, theme, width, height, typeface, antialias);
+ break;
+```
+
+Then add the method (place it after `DrawProgress`, before `BuildSeriesPath`):
+
+```csharp
+ ///
+ /// Draws a heatmap: each series is one row and each column is a category. Cell color encodes the
+ /// value, interpolated between the palette's first ("low") and second ("high") colors. Optional
+ /// x/y labels and per-cell value text are drawn when a typeface is available.
+ ///
+ private static void DrawHeatmap(
+ SKCanvas canvas, ChartElement chart, ChartTheme theme,
+ float width, float height, SKTypeface? typeface, bool antialias)
+ {
+ var rowCount = chart.Series.Count;
+ if (rowCount == 0)
+ return;
+
+ // Columns = the widest row; ragged rows simply leave trailing cells unpainted.
+ var colCount = 0;
+ foreach (var s in chart.Series)
+ colCount = Math.Max(colCount, s.Data.Count);
+ if (colCount == 0)
+ return;
+
+ // Global value range across every cell, for the color scale.
+ var (minV, maxV) = DataBounds(chart);
+
+ var palette = chart.Palette ?? ChartPalettes.Default;
+ var lowColor = ColorParser.Parse(palette.ColorAt(0));
+ var highColor = ColorParser.Parse(palette.Colors.Count > 1 ? palette.ColorAt(1) : palette.ColorAt(0));
+
+ // X labels: prefer XLabels, fall back to Categories.
+ var xLabels = chart.XLabels.Count > 0 ? chart.XLabels : chart.Categories;
+ var yLabels = chart.YLabels;
+
+ var hasTitle = !string.IsNullOrEmpty(chart.Title);
+ var top = hasTitle ? theme.TitleSize + 8f : 4f;
+ // Reserve a left gutter for y-labels and a bottom gutter for x-labels (only when labelled).
+ var leftGutter = typeface is not null && yLabels.Count > 0 ? 52f : 6f;
+ var bottomGutter = typeface is not null && xLabels.Count > 0 ? theme.LabelSize + 8f : 6f;
+
+ var gridLeft = leftGutter;
+ var gridTop = top;
+ var gridRight = width - 6f;
+ var gridBottom = height - bottomGutter;
+ if (gridRight <= gridLeft || gridBottom <= gridTop)
+ return;
+
+ var cellW = (gridRight - gridLeft) / colCount;
+ var cellH = (gridBottom - gridTop) / rowCount;
+
+ SKFont? font = null;
+ SKPaint? labelPaint = null;
+ if (typeface is not null)
+ {
+ font = new SKFont(typeface, theme.LabelSize);
+ labelPaint = new SKPaint { Color = ColorParser.Parse(theme.LabelColor), IsAntialias = antialias };
+ }
+
+ try
+ {
+ using var cellPaint = new SKPaint { Style = SKPaintStyle.Fill, IsAntialias = antialias };
+ using var cellValuePaint = new SKPaint { IsAntialias = antialias };
+
+ for (var ri = 0; ri < rowCount; ri++)
+ {
+ var data = chart.Series[ri].Data;
+ var cellTop = gridTop + (cellH * ri);
+
+ for (var ci = 0; ci < data.Count; ci++)
+ {
+ var value = data[ci];
+ var cellLeft = gridLeft + (cellW * ci);
+ cellPaint.Color = HeatmapColorScale.Map(value, minV, maxV, lowColor, highColor);
+ // Inset by half a pixel to leave a faint grid seam between cells.
+ canvas.DrawRect(cellLeft + 0.5f, cellTop + 0.5f, cellW - 1f, cellH - 1f, cellPaint);
+
+ if (chart.ShowCellValues && font is not null)
+ {
+ // Choose dark or light value text for contrast against the cell color.
+ var cell = cellPaint.Color;
+ var luminance = (0.299f * cell.Red) + (0.587f * cell.Green) + (0.114f * cell.Blue);
+ cellValuePaint.Color = luminance > 140f ? SKColors.Black : SKColors.White;
+
+ var text = FormatTick(value);
+ var tw = font.MeasureText(text);
+ var tx = cellLeft + ((cellW - tw) / 2f);
+ var ty = cellTop + ((cellH + theme.LabelSize) / 2f) - 2f;
+ canvas.DrawText(text, tx, ty, SKTextAlign.Left, font, cellValuePaint);
+ }
+ }
+ }
+
+ if (font is not null && labelPaint is not null)
+ {
+ for (var ci = 0; ci < xLabels.Count && ci < colCount; ci++)
+ {
+ var label = xLabels[ci];
+ var tw = font.MeasureText(label);
+ var cx = gridLeft + (cellW * (ci + 0.5f));
+ canvas.DrawText(label, cx - (tw / 2f), gridBottom + theme.LabelSize + 2f, SKTextAlign.Left, font, labelPaint);
+ }
+
+ for (var ri = 0; ri < yLabels.Count && ri < rowCount; ri++)
+ {
+ var label = yLabels[ri];
+ var tw = font.MeasureText(label);
+ var cy = gridTop + (cellH * (ri + 0.5f)) + (theme.LabelSize / 3f);
+ canvas.DrawText(label, gridLeft - tw - 6f, cy, SKTextAlign.Left, font, labelPaint);
+ }
+ }
+ }
+ finally
+ {
+ font?.Dispose();
+ labelPaint?.Dispose();
+ }
+ }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartHeatmapRenderTests"`
+Expected: PASS (1 case).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs tests/FlexRender.Tests/Rendering/ChartHeatmapRenderTests.cs
+git commit --no-gpg-sign -m "feat(skia): render heatmap charts"
+```
+
+---
+
+## Task 7: Renderer — radial spoke geometry helper (RadarGeometry)
+
+Radar needs to convert (spoke index, radial fraction) into a pixel point and to know each spoke's angle. Extract a tiny pure helper so the geometry is unit-testable without rendering. It lives in `FlexRender.Core/Charts/` next to the other math.
+
+**Files:**
+- Create: `src/FlexRender.Core/Charts/RadarGeometry.cs`
+- Test: `tests/FlexRender.Tests/Charts/RadarGeometryTests.cs` (create)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Charts/RadarGeometryTests.cs`:
+
+```csharp
+using System;
+using FlexRender.Charts;
+using Xunit;
+
+namespace FlexRender.Tests.Charts;
+
+///
+/// Tests for radar spoke geometry: angles start at the top and advance clockwise.
+///
+public sealed class RadarGeometryTests
+{
+ [Fact]
+ public void SpokeAngle_FirstSpoke_PointsUp()
+ {
+ // Spoke 0 of 4 is straight up: -90 degrees == -PI/2 radians.
+ var angle = RadarGeometry.SpokeAngle(0, 4);
+ Assert.Equal(-MathF.PI / 2f, angle, 4);
+ }
+
+ [Fact]
+ public void SpokeAngle_SecondOfFour_PointsRight()
+ {
+ // Spoke 1 of 4 is to the right: 0 radians.
+ var angle = RadarGeometry.SpokeAngle(1, 4);
+ Assert.Equal(0f, angle, 4);
+ }
+
+ [Fact]
+ public void Project_ZeroFraction_ReturnsCenter()
+ {
+ var p = RadarGeometry.Project(centerX: 100f, centerY: 100f, radius: 50f, spokeIndex: 0, spokeCount: 4, fraction: 0f);
+ Assert.Equal(100f, p.X, 3);
+ Assert.Equal(100f, p.Y, 3);
+ }
+
+ [Fact]
+ public void Project_FullFractionFirstSpoke_ReachesTopEdge()
+ {
+ var p = RadarGeometry.Project(centerX: 100f, centerY: 100f, radius: 50f, spokeIndex: 0, spokeCount: 4, fraction: 1f);
+ Assert.Equal(100f, p.X, 3); // straight up: x unchanged
+ Assert.Equal(50f, p.Y, 3); // y = 100 - 50
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~RadarGeometryTests"`
+Expected: BUILD FAILURE — `RadarGeometry` not defined.
+
+- [ ] **Step 3: Create the helper**
+
+Create `src/FlexRender.Core/Charts/RadarGeometry.cs`:
+
+```csharp
+using System;
+
+namespace FlexRender.Charts;
+
+///
+/// A 2D point in pixel space, returned by so the Core math layer stays
+/// independent of any rendering library.
+///
+/// The pixel X.
+/// The pixel Y.
+public readonly record struct RadarPoint(float X, float Y);
+
+///
+/// Pure, renderer-agnostic geometry for radar (spider) charts. Spoke 0 points straight up and
+/// successive spokes advance clockwise; a value's radial fraction (0 at center, 1 at the outer
+/// radius) projects onto its spoke.
+///
+public static class RadarGeometry
+{
+ ///
+ /// Returns the angle (in radians) of a spoke, measured from the positive X axis with screen Y
+ /// growing downward. Spoke 0 is at -PI/2 (straight up); spokes advance clockwise.
+ ///
+ /// The zero-based spoke index.
+ /// The total number of spokes (must be positive).
+ /// The spoke angle in radians.
+ public static float SpokeAngle(int spokeIndex, int spokeCount)
+ {
+ if (spokeCount <= 0)
+ return -MathF.PI / 2f;
+ return (-MathF.PI / 2f) + ((2f * MathF.PI) * spokeIndex / spokeCount);
+ }
+
+ ///
+ /// Projects a radial fraction onto a spoke, returning the pixel point.
+ ///
+ /// The radar center X in pixels.
+ /// The radar center Y in pixels.
+ /// The outer radius in pixels (fraction 1 maps here).
+ /// The zero-based spoke index.
+ /// The total number of spokes.
+ /// The radial fraction in [0, 1] (clamped).
+ /// The projected pixel point.
+ public static RadarPoint Project(
+ float centerX, float centerY, float radius, int spokeIndex, int spokeCount, float fraction)
+ {
+ var f = Math.Clamp(fraction, 0f, 1f);
+ var angle = SpokeAngle(spokeIndex, spokeCount);
+ var r = radius * f;
+ return new RadarPoint(
+ centerX + (r * MathF.Cos(angle)),
+ centerY + (r * MathF.Sin(angle)));
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~RadarGeometryTests"`
+Expected: PASS (4 cases).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Core/Charts/RadarGeometry.cs tests/FlexRender.Tests/Charts/RadarGeometryTests.cs
+git commit --no-gpg-sign -m "feat(charts): add radar spoke geometry helper"
+```
+
+---
+
+## Task 8: Renderer — radar polygons
+
+Radar draws concentric grid rings, spoke lines + category labels, and one closed polygon per series (filled semi-transparent + stroked). The radial scale comes from an `AxisScale` over the data range; values map from center (axis min, anchored at 0) to the outer radius (axis max) via `RadarGeometry.Project` with `fraction = (value - scale.Min) / (scale.Max - scale.Min)`.
+
+**Files:**
+- Modify: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`
+- Test: `tests/FlexRender.Tests/Rendering/ChartRadarRenderTests.cs` (create)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Rendering/ChartRadarRenderTests.cs`:
+
+```csharp
+using System.Collections.Generic;
+using FlexRender.Charts;
+using FlexRender.Parsing.Ast;
+using FlexRender.Rendering;
+using SkiaSharp;
+using Xunit;
+
+namespace FlexRender.Tests.Rendering;
+
+///
+/// Verifies radar draws a palette-colored polygon for each series.
+///
+public sealed class ChartRadarRenderTests
+{
+ [Fact]
+ public void Radar_DrawsColoredPolygon()
+ {
+ var chart = new ChartElement(ChartType.Radar, new List
+ {
+ ChartSeries.FromInline("A", new[] { 4d, 3d, 5d, 2d, 4d })
+ })
+ {
+ Categories = new[] { "Speed", "Power", "Range", "Agility", "Armor" },
+ Legend = LegendPosition.None,
+ Palette = new ChartPalette(new[] { "#ff0000" }),
+ Theme = ChartThemes.Default
+ };
+
+ using var bitmap = new SKBitmap(260, 260, SKColorType.Rgba8888, SKAlphaType.Premul);
+ using var canvas = new SKCanvas(bitmap);
+ canvas.Clear(SKColors.White);
+
+ ChartRenderer.Draw(canvas, chart, 0f, 0f, 260f, 260f, typeface: null, antialias: true);
+
+ Assert.True(HasReddishPixel(bitmap), "Expected a red radar polygon (fill or stroke).");
+ }
+
+ [Fact]
+ public void Radar_FewerThanThreeCategories_DoesNotThrow()
+ {
+ var chart = new ChartElement(ChartType.Radar, new List
+ {
+ ChartSeries.FromInline("A", new[] { 4d, 3d })
+ })
+ {
+ Categories = new[] { "X", "Y" },
+ Legend = LegendPosition.None,
+ Theme = ChartThemes.Default
+ };
+
+ using var bitmap = new SKBitmap(200, 200, SKColorType.Rgba8888, SKAlphaType.Premul);
+ using var canvas = new SKCanvas(bitmap);
+ canvas.Clear(SKColors.White);
+
+ var ex = Record.Exception(() =>
+ ChartRenderer.Draw(canvas, chart, 0f, 0f, 200f, 200f, typeface: null, antialias: true));
+ Assert.Null(ex);
+ }
+
+ private static bool HasReddishPixel(SKBitmap bitmap)
+ {
+ for (var y = 0; y < bitmap.Height; y++)
+ for (var x = 0; x < bitmap.Width; x++)
+ {
+ var p = bitmap.GetPixel(x, y);
+ // The fill is semi-transparent red over white, so allow elevated red with lower G/B.
+ if (p.Red > 200 && p.Green < 200 && p.Blue < 200 && (p.Red - p.Green) > 30)
+ return true;
+ }
+ return false;
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartRadarRenderTests"`
+Expected: FAIL — `Radar_DrawsColoredPolygon` finds no reddish pixel (the `default` border arm draws axis-color gray only). The `FewerThanThreeCategories` case may already pass via the gray border, but the colored-polygon case fails.
+
+- [ ] **Step 3: Add the Radar case + method**
+
+In `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`:
+
+1. Ensure `using FlexRender.Charts;` is present (it already is — `RadarGeometry`/`RadarPoint` live in that namespace).
+
+2. Add a case to the `DrawSeries` switch (before `default:`):
+
+```csharp
+ case ChartType.Radar:
+ DrawRadar(canvas, chart, theme, width, height, typeface, antialias);
+ break;
+```
+
+3. Add the method after `DrawHeatmap`:
+
+```csharp
+ ///
+ /// Draws a radar (spider) chart: N spokes for N categories, concentric grid rings, and one
+ /// closed polygon per series filled semi-transparently and stroked. Values map radially from
+ /// the center (axis minimum, anchored at zero) to the outer radius (axis maximum).
+ ///
+ private static void DrawRadar(
+ SKCanvas canvas, ChartElement chart, ChartTheme theme,
+ float width, float height, SKTypeface? typeface, bool antialias)
+ {
+ var spokeCount = chart.Categories.Count;
+ // Without categories, fall back to the longest series length so a polygon can still draw.
+ if (spokeCount == 0)
+ {
+ foreach (var s in chart.Series)
+ spokeCount = Math.Max(spokeCount, s.Data.Count);
+ }
+ if (spokeCount == 0)
+ return;
+
+ var (dataMin, dataMax) = DataBounds(chart);
+ var scale = AxisScale.Compute(dataMin, dataMax, targetTicks: 4);
+ var span = scale.Max - scale.Min;
+
+ var hasTitle = !string.IsNullOrEmpty(chart.Title);
+ var legendReserve = chart.Legend is LegendPosition.Bottom ? 28f : 0f;
+ var top = hasTitle ? theme.TitleSize + 8f : 8f;
+ var availH = height - top - legendReserve - 8f;
+ var availW = width - 16f;
+ if (availH <= 0f || availW <= 0f)
+ return;
+
+ // Leave room for category labels around the rim.
+ var labelRoom = typeface is not null && spokeCount > 0 ? theme.LabelSize + 6f : 0f;
+ var radius = (MathF.Min(availW, availH) / 2f) - labelRoom;
+ if (radius <= 0f)
+ return;
+
+ var cx = width / 2f;
+ var cy = top + (availH / 2f);
+
+ DrawRadarGrid(canvas, theme, cx, cy, radius, spokeCount, antialias);
+ DrawRadarSpokeLabels(canvas, chart, theme, cx, cy, radius, spokeCount, typeface, antialias);
+
+ var palette = chart.Palette ?? ChartPalettes.Default;
+
+ for (var si = 0; si < chart.Series.Count; si++)
+ {
+ var data = chart.Series[si].Data;
+ if (data.Count == 0)
+ continue;
+
+ var color = ColorParser.Parse(palette.ColorAt(si));
+
+ using var path = new SKPath();
+ for (var i = 0; i < spokeCount; i++)
+ {
+ var value = i < data.Count ? data[i] : scale.Min;
+ var fraction = span <= 0d ? 0f : (float)((value - scale.Min) / span);
+ var p = RadarGeometry.Project(cx, cy, radius, i, spokeCount, fraction);
+ if (i == 0)
+ path.MoveTo(p.X, p.Y);
+ else
+ path.LineTo(p.X, p.Y);
+ }
+ path.Close();
+
+ using var fillPaint = new SKPaint
+ {
+ Color = color.WithAlpha(70),
+ Style = SKPaintStyle.Fill,
+ IsAntialias = antialias
+ };
+ canvas.DrawPath(path, fillPaint);
+
+ using var strokePaint = new SKPaint
+ {
+ Color = color,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = theme.LineWidth,
+ IsAntialias = antialias,
+ StrokeJoin = SKStrokeJoin.Round
+ };
+ canvas.DrawPath(path, strokePaint);
+ }
+ }
+
+ /// Draws the radar's concentric grid rings and radial spoke lines.
+ private static void DrawRadarGrid(
+ SKCanvas canvas, ChartTheme theme, float cx, float cy, float radius, int spokeCount, bool antialias)
+ {
+ using var gridPaint = new SKPaint
+ {
+ Color = ColorParser.Parse(theme.GridColor),
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = 1f,
+ IsAntialias = antialias
+ };
+
+ const int rings = 4;
+ for (var ring = 1; ring <= rings; ring++)
+ {
+ var r = radius * ring / rings;
+ using var ringPath = new SKPath();
+ for (var i = 0; i < spokeCount; i++)
+ {
+ var p = RadarGeometry.Project(cx, cy, r, i, spokeCount, 1f);
+ if (i == 0)
+ ringPath.MoveTo(p.X, p.Y);
+ else
+ ringPath.LineTo(p.X, p.Y);
+ }
+ ringPath.Close();
+ canvas.DrawPath(ringPath, gridPaint);
+ }
+
+ for (var i = 0; i < spokeCount; i++)
+ {
+ var p = RadarGeometry.Project(cx, cy, radius, i, spokeCount, 1f);
+ canvas.DrawLine(cx, cy, p.X, p.Y, gridPaint);
+ }
+ }
+
+ /// Draws each category label just outside its spoke's outer end, when a typeface is available.
+ private static void DrawRadarSpokeLabels(
+ SKCanvas canvas, ChartElement chart, ChartTheme theme, float cx, float cy, float radius,
+ int spokeCount, SKTypeface? typeface, bool antialias)
+ {
+ if (typeface is null || chart.Categories.Count == 0)
+ return;
+
+ using var font = new SKFont(typeface, theme.LabelSize);
+ using var paint = new SKPaint { Color = ColorParser.Parse(theme.LabelColor), IsAntialias = antialias };
+
+ for (var i = 0; i < spokeCount && i < chart.Categories.Count; i++)
+ {
+ var label = chart.Categories[i];
+ // Project slightly past the rim so the text sits outside the outer ring.
+ var p = RadarGeometry.Project(cx, cy, radius + (theme.LabelSize * 0.6f), i, spokeCount, 1f);
+ var tw = font.MeasureText(label);
+ // Center the text horizontally on its anchor; nudge vertically by a third of the label size.
+ canvas.DrawText(label, p.X - (tw / 2f), p.Y + (theme.LabelSize / 3f), SKTextAlign.Left, font, paint);
+ }
+ }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartRadarRenderTests"`
+Expected: PASS (2 cases).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs tests/FlexRender.Tests/Rendering/ChartRadarRenderTests.cs
+git commit --no-gpg-sign -m "feat(skia): render radar charts"
+```
+
+---
+
+## Task 9: End-to-end smoke tests via the YAML pipeline
+
+Verify heatmap and radar render through the full parse → measure → render path (not just direct `ChartRenderer.Draw`).
+
+**Files:**
+- Modify: `tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs`
+
+- [ ] **Step 1: Add the failing smoke tests**
+
+Append these two methods inside the `ChartRenderSmokeTests` class (before its closing brace). They reuse the existing private `Render`/`HasNonBackgroundPixel` helpers:
+
+```csharp
+ [Fact]
+ public async Task HeatmapChart_FromYaml_DrawsPixels()
+ {
+ const string yaml = """
+ canvas:
+ width: 320
+ height: 240
+ background: "#ffffff"
+ layout:
+ - type: chart
+ chart-type: heatmap
+ width: 320
+ height: 240
+ x-labels: [Mon, Tue, Wed]
+ y-labels: [AM, PM]
+ cell-values: true
+ series:
+ - data: [1, 5, 9]
+ - data: [7, 3, 2]
+ legend: none
+ palette: ocean
+ """;
+
+ var template = _parser.Parse(yaml);
+ using var bitmap = await Render(template, new ObjectValue());
+
+ Assert.True(HasNonBackgroundPixel(bitmap), "Expected heatmap to draw.");
+ }
+
+ [Fact]
+ public async Task RadarChart_FromYaml_DrawsPixels()
+ {
+ const string yaml = """
+ canvas:
+ width: 300
+ height: 300
+ background: "#ffffff"
+ layout:
+ - type: chart
+ chart-type: radar
+ width: 300
+ height: 300
+ categories: [Speed, Power, Range, Agility, Armor]
+ series:
+ - label: A
+ data: [4, 3, 5, 2, 4]
+ - label: B
+ data: [3, 5, 2, 4, 3]
+ legend: none
+ palette: vivid
+ """;
+
+ var template = _parser.Parse(yaml);
+ using var bitmap = await Render(template, new ObjectValue());
+
+ Assert.True(HasNonBackgroundPixel(bitmap), "Expected radar to draw.");
+ }
+```
+
+- [ ] **Step 2: Run the smoke tests**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartRenderSmokeTests"`
+Expected: PASS (all cases, including the pre-existing Phase-2/3 cases).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs
+git commit --no-gpg-sign -m "test(skia): end-to-end smoke tests for heatmap and radar"
+```
+
+---
+
+## Task 10: Snapshot goldens for heatmap and radar
+
+**Files:**
+- Modify: `tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs`
+- Create (generated): `tests/FlexRender.Tests/Snapshots/golden/chart_heatmap_light.png`, `chart_radar_dark.png`
+
+> Confirm the golden directory path before generating. If `tests/FlexRender.Tests/Snapshots/golden/` does not exist, check where the existing Phase-2/3 goldens live (e.g. `ls tests/FlexRender.Tests/Snapshots/golden/ | grep chart_bar`) and use that same directory in the `git add` of Step 5.
+
+- [ ] **Step 1: Add the snapshot tests**
+
+Append these two methods inside the `ChartSnapshotTests` class (before its closing brace):
+
+```csharp
+ [Fact]
+ public async Task HeatmapChart_Light()
+ {
+ var template = Parser.Parse("""
+ canvas:
+ width: 420
+ background: "#ffffff"
+ layout:
+ - type: chart
+ chart-type: heatmap
+ width: 420
+ height: 300
+ x-labels: [Mon, Tue, Wed, Thu, Fri]
+ y-labels: [Morning, Afternoon, Evening]
+ cell-values: true
+ series:
+ - data: [2, 8, 4, 10, 6]
+ - data: [5, 3, 9, 7, 1]
+ - data: [8, 6, 2, 4, 9]
+ title: Activity
+ legend: none
+ palette: ocean
+ """);
+ await AssertSnapshot("chart_heatmap_light", template, new ObjectValue());
+ }
+
+ [Fact]
+ public async Task RadarChart_Dark()
+ {
+ var template = Parser.Parse("""
+ canvas:
+ width: 360
+ background: "#1e1e1e"
+ layout:
+ - type: chart
+ chart-type: radar
+ width: 360
+ height: 360
+ categories: [Speed, Power, Range, Agility, Armor, Stealth]
+ series:
+ - label: Alpha
+ data: [4, 3, 5, 2, 4, 3]
+ - label: Bravo
+ data: [3, 5, 2, 4, 3, 5]
+ theme: dark
+ legend: bottom
+ palette: vivid
+ """);
+ await AssertSnapshot("chart_radar_dark", template, new ObjectValue());
+ }
+```
+
+- [ ] **Step 2: Run the snapshot tests to confirm they fail (no golden yet)**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSnapshotTests"`
+Expected: FAIL for the two new tests with "Golden image not found" (earlier snapshots still pass).
+
+- [ ] **Step 3: Generate the goldens**
+
+Run: `UPDATE_SNAPSHOTS=true dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSnapshotTests"`
+Expected: PASS (goldens written). Confirm two new PNGs (`chart_heatmap_light.png`, `chart_radar_dark.png`) exist in the golden directory.
+
+- [ ] **Step 4: Re-run without update to confirm they match**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSnapshotTests"`
+Expected: PASS (all, including the two new goldens).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs tests/FlexRender.Tests/Snapshots/golden/chart_heatmap_light.png tests/FlexRender.Tests/Snapshots/golden/chart_radar_dark.png
+git commit --no-gpg-sign -m "test(charts): snapshot goldens for heatmap and radar"
+```
+
+---
+
+## Task 11: Full build + full test gate
+
+**Files:** none (verification only)
+
+- [ ] **Step 1: Clean build**
+
+Run: `dotnet build FlexRender.slnx`
+Expected: BUILD SUCCEEDED, zero warnings (TreatWarningsAsErrors). If CA1859 / unused-using / unused-param errors appear, fix them in the offending file and rebuild. Likely watch points: the `RadarPoint`/`RadarGeometry` public types (XML docs required), unused `using` in new files.
+
+- [ ] **Step 2: Full test suite**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0`
+Expected: PASS — all tests green (Phase-1, Phase-2, Phase-3, and the new Phase-4 tests).
+
+- [ ] **Step 3: Commit (only if a fix was needed in Step 1)**
+
+```bash
+git add -A
+git commit --no-gpg-sign -m "chore(charts): resolve analyzer warnings for heatmap and radar"
+```
+
+If no fix was needed, skip this commit.
+
+---
+
+## Task 12: Docs — Playground schema + autocomplete
+
+**Files:**
+- Modify: `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json`
+- Modify: `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs`
+
+- [ ] **Step 1: Extend the schema chart-type enum**
+
+In `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json`, find the chart `"chart-type"` enum (around line 883):
+
+```json
+ "enum": ["bar", "line", "area", "pie", "donut", "scatter", "bubble", "gauge", "progress", "sparkline"]
+```
+
+Replace it with:
+
+```json
+ "enum": ["bar", "line", "area", "pie", "donut", "scatter", "bubble", "gauge", "progress", "sparkline", "heatmap", "radar"]
+```
+
+- [ ] **Step 2: Update the chart element description**
+
+In the same file, the `chartElement` `"description"` (around line 874) reads:
+
+```json
+ "description": "Renders a bar, line, area, pie, or donut chart. Empty/missing series renders a 'no data' placeholder.",
+```
+
+Replace it with:
+
+```json
+ "description": "Renders a bar, line, area, pie, donut, scatter, bubble, gauge, progress, sparkline, heatmap, or radar chart. Empty/missing series renders a 'no data' placeholder.",
+```
+
+- [ ] **Step 3: Add the heatmap props to the chart schema**
+
+In the chart `properties` block, after the `"label"` property block (the gauge caption, ends at the `}` before the `properties` block's closing `}` near line 964), add three new properties (mind the trailing comma on the preceding `"label"` block's closing brace):
+
+```json
+ "label": {
+ "description": "Gauge/progress only. Centered caption under the value.",
+ "type": "string"
+ },
+ "x-labels": {
+ "description": "Heatmap only. Column (x-axis) labels, left to right.",
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "y-labels": {
+ "description": "Heatmap only. Row (y-axis) labels, top to bottom (one per series/row).",
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "cell-values": {
+ "description": "Heatmap only. Draw each cell's numeric value inside the cell.",
+ "type": "boolean"
+ }
+```
+
+- [ ] **Step 4: Update autocomplete if it enumerates chart-type values**
+
+Inspect `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs` for any literal list of chart-type values (`bar`, `donut`, `sparkline`, etc.). If such a list exists, add `heatmap` and `radar`. If the file derives completions from the schema (no hardcoded chart-type list), no edit is needed — note that in the commit body.
+
+Verify with: `grep -n "sparkline" src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs` (no result means schema-driven; nothing to change).
+
+- [ ] **Step 5: Validate the JSON parses**
+
+Run: `node -e "JSON.parse(require('fs').readFileSync('src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json','utf8')); console.log('ok')"`
+Expected: prints `ok`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs
+git commit --no-gpg-sign -m "docs(playground): add heatmap and radar to schema and autocomplete"
+```
+
+---
+
+## Task 13: Docs — wiki Element-Reference + Visual-Reference
+
+**Files:**
+- Modify: `docs/wiki/Element-Reference.md`
+- Modify: `docs/wiki/Visual-Reference.md`
+
+- [ ] **Step 1: Update the chart intro + chart-type row in Element-Reference**
+
+In `docs/wiki/Element-Reference.md`, in the `## chart` section:
+
+1. Update the intro sentence (around line 1462). It currently lists `bar`, `line`, `area`, `pie`, `donut`, `scatter`, `bubble`, `gauge`, `progress`, and `sparkline`. Append heatmap and radar so it reads:
+
+```
+A declarative chart element that renders `bar`, `line`, `area`, `pie`, `donut`, `scatter`, `bubble`, `gauge`, `progress`, `sparkline`, `heatmap`, and `radar` charts into a fixed `width` by `height` box.
+```
+
+2. Update the `ChartType` property-table row (around line 1480) to:
+
+```
+| ChartType | `chart-type` | string | `bar` | bar, line, area, pie, donut, scatter, bubble, gauge, progress, sparkline, heatmap, radar | No | Chart kind. |
+```
+
+3. Add three rows to the chart property table immediately after the `ValueLabel` (`label`) row:
+
+```
+| XLabels | `x-labels` | list | `[]` | List of strings | No | **Heatmap only.** Column (x-axis) labels; falls back to `categories`. |
+| YLabels | `y-labels` | list | `[]` | List of strings | No | **Heatmap only.** Row (y-axis) labels, one per series/row. |
+| ShowCellValues | `cell-values` | bool | `false` | true, false | No | **Heatmap only.** Draw each cell's numeric value inside the cell. |
+```
+
+4. Add a note paragraph after the existing "Scatter / bubble data" note documenting heatmap/radar data:
+
+```
+> **Heatmap data:** each entry in `series` is one ROW; `series[i].data` holds that row's column values. `x-labels` label the columns (or `categories` if `x-labels` is omitted) and `y-labels` label the rows. Cell color encodes the value, interpolated between the palette's first ("low") and second ("high") colors; set `cell-values: true` to print the number in each cell.
+
+> **Radar data:** `categories` become the spokes (one axis per category, starting at the top and advancing clockwise). Each entry in `series` is a closed polygon connecting that series' values along the spokes — filled semi-transparently and stroked. Values share one radial scale anchored at zero at the center.
+```
+
+- [ ] **Step 2: Add examples to Visual-Reference**
+
+In `docs/wiki/Visual-Reference.md`, find the chart section and append two examples after the existing sparkline example block:
+
+````
+### Heatmap
+
+```yaml
+- type: chart
+ chart-type: heatmap
+ width: 420
+ height: 300
+ x-labels: [Mon, Tue, Wed, Thu, Fri]
+ y-labels: [Morning, Afternoon, Evening]
+ cell-values: true
+ series:
+ - data: [2, 8, 4, 10, 6]
+ - data: [5, 3, 9, 7, 1]
+ - data: [8, 6, 2, 4, 9]
+ legend: none
+ palette: ocean
+```
+
+### Radar
+
+```yaml
+- type: chart
+ chart-type: radar
+ width: 360
+ height: 360
+ categories: [Speed, Power, Range, Agility, Armor, Stealth]
+ series:
+ - label: Alpha
+ data: [4, 3, 5, 2, 4, 3]
+ - label: Bravo
+ data: [3, 5, 2, 4, 3, 5]
+ legend: bottom
+ palette: vivid
+```
+````
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add docs/wiki/Element-Reference.md docs/wiki/Visual-Reference.md
+git commit --no-gpg-sign -m "docs(wiki): document heatmap and radar chart types"
+```
+
+---
+
+## Task 14: Docs — llms.txt + llms-full.txt
+
+**Files:**
+- Modify: `llms.txt`
+- Modify: `llms-full.txt`
+
+- [ ] **Step 1: Update the chart-type enumeration in both files**
+
+In both `llms.txt` and `llms-full.txt`, find each text that enumerates chart-types (search for `sparkline`) and update the list to append `heatmap, radar`. For example, the `llms.txt` chart row lists `bar/line/area/pie/donut/scatter/bubble/gauge/progress/sparkline` — change it to `bar/line/area/pie/donut/scatter/bubble/gauge/progress/sparkline/heatmap/radar`. In `llms-full.txt`, update the `chart-type | string | bar | Chart kind: ...` table row and the intro sentence the same way.
+
+- [ ] **Step 2: Add a Phase-4 usage note in llms-full.txt**
+
+In `llms-full.txt`, in the chart section, add this note after the existing scatter/bubble/gauge/progress/sparkline description:
+
+```
+Heatmap takes 2D data: each `series` entry is one ROW (`series[i].data` = that row's column values), `x-labels` label the columns (or `categories` if omitted), `y-labels` label the rows, and `cell-values: true` prints each cell's number. Cell color is interpolated between the palette's first ("low") and second ("high") colors. Radar uses `categories` as spokes (one axis per category, starting at the top); each `series` is a closed polygon along the spokes, filled semi-transparently and stroked, sharing one radial scale anchored at zero at the center.
+```
+
+- [ ] **Step 3: Verify the strings landed**
+
+Run: `grep -c "heatmap" llms.txt llms-full.txt`
+Expected: a non-zero count for each file.
+
+Run: `grep -c "radar" llms.txt llms-full.txt`
+Expected: a non-zero count for each file.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add llms.txt llms-full.txt
+git commit --no-gpg-sign -m "docs(llms): document heatmap and radar chart types"
+```
+
+---
+
+## Task 15: Final verification
+
+**Files:** none (verification only)
+
+- [ ] **Step 1: Clean build**
+
+Run: `dotnet build FlexRender.slnx`
+Expected: BUILD SUCCEEDED, zero warnings.
+
+- [ ] **Step 2: Full test suite (authoritative framework)**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0`
+Expected: PASS — every test green.
+
+- [ ] **Step 3: Confirm git state is clean**
+
+Run: `git status`
+Expected: working tree clean (all Phase-4 work committed). The branch remains `feature/charts-and-shapes`; do NOT merge to `main`.
+
+---
+
+## Self-review
+
+**Spec coverage (each Phase-4 type → task):**
+- `heatmap` — type enum (Task 1); `XLabels`/`YLabels`/`ShowCellValues` props (Task 2); `x-labels`/`y-labels`/`cell-values` parsing + KnownProperties + chart-type error message (Task 3); empty → no-data placeholder (Task 4); two-color value→color scale (Task 5); grid + labels + optional cell values rendering with each series as a row (Task 6); smoke (Task 9); snapshot (Task 10). 2D data is modeled as series-as-rows (no new data structure), Categories/XLabels as columns, YLabels as rows — matching the spec's "2D `data`, `x-labels`, `y-labels`" and the recommended representation.
+- `radar` — type enum (Task 1); reuses `Series` (polygons) + `Categories` (spokes), no new data structure (Task 3 test confirms); empty → no-data placeholder (Task 4); spoke geometry helper (Task 7); concentric rings + spokes + category labels + per-series closed filled+stroked polygon with radial AxisScale mapping (Task 8); smoke (Task 9); snapshot (Task 10).
+- Error handling: unknown chart-type lists `heatmap`/`radar` (Task 3); unknown-prop typo suggestions covered by `KnownProperties` validation with the three new props registered (Task 3, typo test included); non-numeric/non-finite data rejected by the existing per-series flat-data validation in `ParseOneSeries` (unchanged — heatmap/radar use flat `Data`); empty data → no-data placeholder (Task 4). Degenerate geometry (radar < 3 categories, all-zero values; heatmap min == max) handled in Tasks 5/8 with explicit tests.
+- Resource limits reused, not weakened: heatmap rows are series (bounded by `MaxSeriesPerChart`), columns are data points per row (bounded by `MaxDataPointsPerSeries`) via the existing checks in `ParseSeries`/`ParseOneSeries`; no new limit and no limit weakened.
+- Docs: schema + autocomplete (Task 12), wiki Element-Reference + Visual-Reference (Task 13), llms.txt + llms-full.txt (Task 14).
+
+**Placeholder scan:** No `TBD`/`TODO`/"add appropriate"/"similar to Task N" — every code step shows complete, compilable code. The two conditional notes (Task 3 KnownProperties order tolerance; Task 4 no-change-expected guard; Task 10 golden-directory confirmation; Task 12 schema-driven autocomplete) are fully specified with exact conditions and concrete fallback actions.
+
+**Type consistency:**
+- `ChartType.Heatmap` / `ChartType.Radar` — added Task 1, referenced in `DrawSeries` arms (Tasks 6, 8) and parser error message (Task 3).
+- `ChartElement.XLabels` / `YLabels` (`IReadOnlyList`, default `Array.Empty()`) and `ShowCellValues` (`bool`) — defined Task 2, set in Task 3 (from `List` returned by `ParseStringList`, assignable to `IReadOnlyList`; `GetBoolValue` returns `bool`), consumed in Task 6 (`chart.XLabels`, `chart.YLabels`, `chart.ShowCellValues`).
+- `HeatmapColorScale.Map(double value, double min, double max, SKColor low, SKColor high) → SKColor` — defined Task 5, called identically in Task 6.
+- `RadarGeometry.SpokeAngle(int, int) → float` and `RadarGeometry.Project(float, float, float, int, int, float) → RadarPoint` with `RadarPoint(float X, float Y)` — defined Task 7, called with the same argument order in Task 8 (`DrawRadar`, `DrawRadarGrid`, `DrawRadarSpokeLabels`).
+- `DrawHeatmap(canvas, chart, theme, width, height, typeface, antialias)` and `DrawRadar(...)` signatures match their `DrawSeries` call sites (Tasks 6, 8) and the existing draw-method convention.
+- `ParseStringList(YamlMappingNode, string) → List` — defined Task 3, used for both `x-labels` and `y-labels` in the same task; `ParseCategories` left intact for `Categories`.
+- `DataBounds(chart)` (existing) returns `(double Min, double Max)` — reused unchanged in Tasks 6 and 8.
diff --git a/docs/superpowers/plans/2026-06-13-xml-parser-phase5.md b/docs/superpowers/plans/2026-06-13-xml-parser-phase5.md
new file mode 100644
index 0000000..104acf0
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-13-xml-parser-phase5.md
@@ -0,0 +1,1981 @@
+# FlexRender.Xml Parser (Phase 5) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a new `FlexRender.Xml` package that parses an XML template syntax into the exact same `Template` AST the YAML parser produces, via the same `ITemplateParser` abstraction, with zero new elements and zero renderer changes.
+
+**Architecture:** XML is translated into the same `YamlDotNet.RepresentationModel.YamlMappingNode` document tree that `FlexRender.Yaml.TemplateParser` already consumes internally, then handed to a new `internal` entry point on `TemplateParser` so that **all** existing element/chart/shape parsing, `KnownProperties` validation, typo suggestions, and `ResourceLimits` enforcement are reused verbatim. `FlexRender.Xml` references `FlexRender.Yaml`; `FlexRender.Yaml` grants `InternalsVisibleTo("FlexRender.Xml")`. Only the XML→node mapping, the public `XmlTemplateParser : ITemplateParser`, `RenderXml` extension methods, and docs are new.
+
+**Tech Stack:** .NET (net8.0;net10.0), `System.Xml.Linq` (`XDocument`/`XElement` — AOT-safe, no reflection, NO `XmlSerializer`), YamlDotNet RepresentationModel (already a transitive dependency via `FlexRender.Yaml`), xUnit v3 + AwesomeAssertions for tests.
+
+---
+
+## XML Syntax Mapping (LOCKED DECISION)
+
+The XML parser does **not** reimplement element parsing. It converts the XML tree into the equivalent `YamlMappingNode` structure and reuses the YAML element parsers. The mapping rules below define that conversion. They are designed to be LLM-friendly (attributes over indentation) while round-tripping cleanly to the existing AST.
+
+### Document shape
+
+| YAML | XML |
+| --- | --- |
+| top-level `template:`, `canvas:`, `fonts:`, `layout:` | a single root element `` |
+| `canvas: { width: 300, ... }` | `` child of `` |
+| `template: { name, version, culture }` | attributes on ``: `name`, `version`, `culture` |
+| `layout: [ ...elements ]` | every **other** child element of `` (in order) is a layout element |
+| `fonts: { default: "x.ttf" }` | `` child containing `` entries |
+
+### Element shape
+
+- **Element type = XML local element name.** `` → `type: text`, `` → `type: flex`, ``, ``, ``, ``, ``, etc. The set of names is exactly the YAML element types.
+- **Scalar properties = XML attributes.** `` → `size: 1em`, `color: "#ff0000"`. Attribute names are identical to YAML property names (kebab-case and camelCase both pass through unchanged, e.g. `chart-type`, `stroke-width`, `min-width`, `borderColor`). Validation against `KnownProperties` is unchanged.
+- **`text` content** may be given either as the `content` attribute **or** as the element's inner text: `Hello` ≡ ``. If both are present, the `content` attribute wins. (svg `content` follows the same rule.)
+- **Child-element containers** map to YAML list properties. The container is expressed by repeating child elements directly inside the parent — no wrapper:
+ - `flex` children → child layout elements directly inside ``.
+ - `each` body → child layout elements directly inside `` (YAML `children`).
+ - `if` → `` and `` wrapper children, each holding layout elements; optional `` wrapper holds a single nested `if` (YAML `elseIf`). Comparison operators (`equals`, `in`, `greaterThan`, …) remain attributes on ``.
+ - `table` → `` wrapper holding `` entries; optional `` wrapper holding `
` entries. Column/row fields are attributes.
+ - `chart` → `` entries (each a child of ``), `` wrapper holding `- value
` entries, ``/`` wrappers holding `- ` entries, `` wrapper holding `#fff` entries (or a `palette="ocean"` attribute for a named palette).
+ - `draw` → `` wrapper holding one-shape-per-child elements ``, ``, ``, ``, ``.
+- **Scalar lists** (chart `series.data`, `categories`, polyline `points`):
+ - `series` numeric data: `data="12,30,22,48"` attribute (comma-separated) → YAML `data: [12,30,22,48]`. An expression `data="{{ sales }}"` passes through as a scalar string.
+ - tuple data (scatter/bubble): `points="1,2; 3,4; 5,6"` attribute on `` → YAML `data: [[1,2],[3,4],[5,6]]`. (Semicolon separates points, comma separates components.)
+ - `draw polyline` `points="10,10; 50,50"` → YAML `points: [[10,10],[50,50]]`.
+
+### How the converter decides attribute vs. list-container
+
+The converter is generic and data-driven. For each XML element it produces a `YamlMappingNode` with:
+1. `type` = the element local-name (except the special wrappers below).
+2. one scalar entry per attribute (name → value), with comma/semicolon list attributes expanded into `YamlSequenceNode` when the attribute name is a known list property (`data`, `points`, `categories`, `palette`, `x-labels`, `y-labels`).
+3. one entry per recognised **child wrapper**: child elements named `then`/`else`/`else-if`/`columns`/`rows`/`series`/`categories`/`x-labels`/`y-labels`/`palette`/`shapes` become the corresponding YAML sequence/mapping; all **other** child elements are collected into the container's natural list key (`children` for `flex`/`each`, `layout` for the root, the shape list for `draw`, etc.).
+
+### Side-by-side example (flex + chart)
+
+YAML:
+
+```yaml
+canvas:
+ width: 400
+layout:
+ - type: flex
+ direction: row
+ gap: 8
+ children:
+ - type: text
+ content: "Quarterly sales"
+ size: 1.2em
+ - type: chart
+ chart-type: bar
+ categories: [Q1, Q2, Q3, Q4]
+ series:
+ - label: "2024"
+ data: [12, 30, 22, 48]
+ - label: "2025"
+ data: [18, 26, 31, 40]
+```
+
+XML:
+
+```xml
+
+
+
+ Quarterly sales
+
+
+
- Q1
- Q2
- Q3
- Q4
+
+
+
+
+
+
+```
+
+Both parse to the identical `Template` AST.
+
+---
+
+## File Structure
+
+- `src/FlexRender.Xml/FlexRender.Xml.csproj` — new package; references `FlexRender.Yaml`.
+- `src/FlexRender.Xml/XmlTemplateParser.cs` — public `ITemplateParser` for XML.
+- `src/FlexRender.Xml/XmlToYamlNodeConverter.cs` — internal XElement → YamlMappingNode tree converter (all the mapping rules above).
+- `src/FlexRender.Xml/XmlFlexRenderExtensions.cs` — `RenderXml` extension methods (mirror `RenderYaml`).
+- `src/FlexRender.Yaml/Parsing/TemplateParser.cs` — add `internal Template ParseDocumentRoot(YamlMappingNode root)` + `InternalsVisibleTo`.
+- `tests/FlexRender.Tests/Parsing/Xml/*.cs` — XML parser tests + YAML-vs-XML AST cross-checks.
+- Docs: `docs/wiki/Xml-Syntax.md`, `AGENTS.md`, `llms.txt`, `llms-full.txt`.
+
+---
+
+### Task 1: Create FlexRender.Xml project and wire it into the solution
+
+**Files:**
+- Create: `src/FlexRender.Xml/FlexRender.Xml.csproj`
+- Create: `src/FlexRender.Xml/Placeholder.cs`
+- Modify: `FlexRender.slnx`
+
+- [ ] **Step 1: Create the project file**
+
+Create `src/FlexRender.Xml/FlexRender.Xml.csproj`:
+
+```xml
+
+
+
+ FlexRender.Xml
+ XML template parsing for FlexRender (alternative to YAML).
+ true
+
+
+
+
+
+
+
+```
+
+(No extra `PackageReference` is needed: `System.Xml.Linq` is in the framework reference, and YamlDotNet comes transitively through `FlexRender.Yaml`.)
+
+- [ ] **Step 2: Create a temporary placeholder type so the project compiles**
+
+Create `src/FlexRender.Xml/Placeholder.cs`:
+
+```csharp
+namespace FlexRender.Xml;
+
+///
+/// Temporary placeholder so the empty project compiles. Removed in a later task.
+///
+internal static class Placeholder
+{
+}
+```
+
+- [ ] **Step 3: Register the project in the solution**
+
+In `FlexRender.slnx`, inside the `` element (which currently contains only `FlexRender.Yaml`), add a second project line directly after the existing one so the folder reads:
+
+```xml
+
+
+
+
+```
+
+- [ ] **Step 4: Build the solution**
+
+Run: `dotnet build FlexRender.slnx`
+Expected: Build succeeded, 0 errors. `FlexRender.Xml` appears in the build output.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Xml/FlexRender.Xml.csproj src/FlexRender.Xml/Placeholder.cs FlexRender.slnx
+git commit --no-gpg-sign -m "build: scaffold FlexRender.Xml project"
+```
+
+---
+
+### Task 2: Expose an internal AST entry point on TemplateParser
+
+This lets `FlexRender.Xml` reuse all element/chart/shape parsing, `KnownProperties` validation, and `ResourceLimits` without duplication. `TemplateParser.Parse(string)` already builds a `YamlMappingNode root` and then parses metadata/fonts/canvas/layout from it (see lines 120-157). We extract that tail into an internal method.
+
+**Files:**
+- Modify: `src/FlexRender.Yaml/Parsing/TemplateParser.cs`
+- Create: `src/FlexRender.Yaml/AssemblyInfo.cs`
+- Test: `tests/FlexRender.Tests/Parsing/Xml/InternalEntryPointTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Parsing/Xml/InternalEntryPointTests.cs`:
+
+```csharp
+using FlexRender.Parsing;
+using FlexRender.Parsing.Ast;
+using YamlDotNet.RepresentationModel;
+using Xunit;
+
+namespace FlexRender.Tests.Parsing.Xml;
+
+///
+/// Tests for the internal entry point
+/// used by the XML parser to reuse YAML element parsing.
+///
+public class InternalEntryPointTests
+{
+ ///
+ /// Verifies that a programmatically built YamlMappingNode root produces the same AST
+ /// as parsing the equivalent YAML string.
+ ///
+ [Fact]
+ public void ParseDocumentRoot_BuiltNode_ProducesEquivalentAst()
+ {
+ // Build: { canvas: { width: 300 }, layout: [ { type: text, content: Hi } ] }
+ var canvas = new YamlMappingNode();
+ canvas.Add("width", "300");
+
+ var textNode = new YamlMappingNode();
+ textNode.Add("type", "text");
+ textNode.Add("content", "Hi");
+
+ var layout = new YamlSequenceNode();
+ layout.Add(textNode);
+
+ var root = new YamlMappingNode();
+ root.Add("canvas", canvas);
+ root.Add("layout", layout);
+
+ var template = new TemplateParser().ParseDocumentRootForTests(root);
+
+ var text = Assert.IsType(Assert.Single(template.Elements));
+ Assert.Equal("Hi", text.Content);
+ Assert.Equal(300, template.Canvas.Width);
+ }
+}
+```
+
+The test calls `ParseDocumentRootForTests` — an internal test shim we add next to the real internal method (the test project already has `InternalsVisibleTo` to `FlexRender.Tests`? It does not yet; we add it in Step 3 alongside the XML one).
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~InternalEntryPointTests"`
+Expected: FAIL — does not compile, `ParseDocumentRootForTests` is not defined.
+
+- [ ] **Step 3: Add the internal entry point and InternalsVisibleTo**
+
+In `src/FlexRender.Yaml/Parsing/TemplateParser.cs`, replace the body of `Parse(string content)` (the metadata/fonts/canvas/layout section, currently lines 120-157) so that after `root` is obtained it delegates to a new method. Specifically, change the `Parse(string)` method's tail (everything from `var template = new Template();` through `return template;`) to:
+
+```csharp
+ return ParseDocumentRoot(root);
+ }
+
+ ///
+ /// Builds a from an already-parsed YAML document root.
+ /// Shared by the YAML string/stream entry points and by the XML parser, which
+ /// translates XML into the equivalent tree.
+ ///
+ /// The document root mapping node.
+ /// The parsed template.
+ /// Thrown when is null.
+ /// Thrown when required sections are missing or invalid.
+ internal Template ParseDocumentRoot(YamlMappingNode root)
+ {
+ ArgumentNullException.ThrowIfNull(root);
+
+ var template = new Template();
+
+ if (TryGetMapping(root, "template", out var templateNode))
+ {
+ template.Name = GetStringValue(templateNode, "name");
+ template.Version = GetIntValue(templateNode, "version", 1);
+ template.Culture = GetStringValue(templateNode, "culture");
+ }
+
+ var fontsKey = new YamlScalarNode("fonts");
+ if (root.Children.TryGetValue(fontsKey, out var fontsYamlNode))
+ {
+ template.Fonts = fontsYamlNode switch
+ {
+ YamlMappingNode fontsMapping => ParseFonts(fontsMapping),
+ YamlSequenceNode fontsSequence => ParseFontsList(fontsSequence),
+ _ => throw new TemplateParseException(
+ "Invalid 'fonts' section. Expected a mapping (name: path) or a list of font entries.")
+ };
+ }
+
+ if (!TryGetMapping(root, "canvas", out var canvasNode))
+ {
+ throw new TemplateParseException("Missing required 'canvas' section");
+ }
+
+ template.Canvas = ParseCanvas(canvasNode);
+
+ if (TryGetSequence(root, "layout", out var layoutNode))
+ {
+ template.Elements = ParseElements(layoutNode);
+ }
+
+ return template;
+ }
+
+ ///
+ /// Test-only shim exposing to the test assembly.
+ ///
+ /// The document root mapping node.
+ /// The parsed template.
+ internal Template ParseDocumentRootForTests(YamlMappingNode root) => ParseDocumentRoot(root);
+```
+
+Make sure the original `Parse(string)` no longer contains the duplicated metadata/canvas/layout block (it now ends with `return ParseDocumentRoot(root);`).
+
+Create `src/FlexRender.Yaml/AssemblyInfo.cs`:
+
+```csharp
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("FlexRender.Xml")]
+[assembly: InternalsVisibleTo("FlexRender.Tests")]
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~InternalEntryPointTests"`
+Expected: PASS (1 test).
+
+Then run the full YAML parser suite to confirm the refactor changed nothing:
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~Parsing.TemplateParser"`
+Expected: PASS (all existing parser tests green).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/FlexRender.Yaml/Parsing/TemplateParser.cs src/FlexRender.Yaml/AssemblyInfo.cs tests/FlexRender.Tests/Parsing/Xml/InternalEntryPointTests.cs
+git commit --no-gpg-sign -m "refactor: extract ParseDocumentRoot entry point for parser reuse"
+```
+
+---
+
+### Task 3: XmlTemplateParser skeleton + canvas + simple text/separator elements
+
+Introduce the public parser and the converter, covering the document shape, `canvas`, and the two simplest elements. Delete the placeholder.
+
+**Files:**
+- Create: `src/FlexRender.Xml/XmlToYamlNodeConverter.cs`
+- Create: `src/FlexRender.Xml/XmlTemplateParser.cs`
+- Delete: `src/FlexRender.Xml/Placeholder.cs`
+- Test: `tests/FlexRender.Tests/Parsing/Xml/XmlTemplateParserBasicTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Parsing/Xml/XmlTemplateParserBasicTests.cs`:
+
+```csharp
+using FlexRender.Parsing;
+using FlexRender.Parsing.Ast;
+using FlexRender.Xml;
+using Xunit;
+
+namespace FlexRender.Tests.Parsing.Xml;
+
+///
+/// Tests for basic XML template parsing (canvas, text, separator).
+///
+public class XmlTemplateParserBasicTests
+{
+ private readonly XmlTemplateParser _parser = new();
+
+ [Fact]
+ public void Parse_CanvasAndText_ContentAttribute()
+ {
+ const string xml = """
+
+
+
+
+ """;
+
+ var template = _parser.Parse(xml);
+
+ Assert.Equal(300, template.Canvas.Width);
+ var text = Assert.IsType(Assert.Single(template.Elements));
+ Assert.Equal("Hello World", text.Content);
+ Assert.Equal("1.5em", text.Size.Value);
+ Assert.Equal("#ff0000", text.Color.Value);
+ }
+
+ [Fact]
+ public void Parse_TextInnerTextUsedAsContent()
+ {
+ const string xml = """
+
+
+ Inline body
+
+ """;
+
+ var text = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements));
+ Assert.Equal("Inline body", text.Content);
+ }
+
+ [Fact]
+ public void Parse_Separator()
+ {
+ const string xml = """
+
+
+
+
+ """;
+
+ var sep = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements));
+ Assert.Equal(SeparatorOrientation.Horizontal, sep.Orientation);
+ Assert.Equal(SeparatorStyle.Dashed, sep.Style);
+ Assert.Equal(2f, sep.Thickness);
+ }
+
+ [Fact]
+ public void Parse_NullContent_Throws()
+ {
+ Assert.Throws(() => _parser.Parse((string)null!));
+ }
+
+ [Fact]
+ public void Parse_MissingCanvas_Throws()
+ {
+ const string xml = "";
+ var ex = Assert.Throws(() => _parser.Parse(xml));
+ Assert.Contains("canvas", ex.Message, System.StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Parse_MalformedXml_ThrowsTemplateParseException()
+ {
+ const string xml = "";
+ Assert.Throws(() => _parser.Parse(xml));
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~XmlTemplateParserBasicTests"`
+Expected: FAIL — `XmlTemplateParser` does not exist.
+
+- [ ] **Step 3: Implement the converter and parser**
+
+Delete the placeholder: `git rm src/FlexRender.Xml/Placeholder.cs` (or remove the file).
+
+Create `src/FlexRender.Xml/XmlToYamlNodeConverter.cs`:
+
+```csharp
+using System.Globalization;
+using System.Xml.Linq;
+using FlexRender.Parsing;
+using YamlDotNet.RepresentationModel;
+
+namespace FlexRender.Xml;
+
+///
+/// Converts a FlexRender XML template tree into the equivalent
+/// document root that the YAML
+/// consumes, so all element/chart/shape parsing and
+/// validation is reused without duplication.
+///
+internal static class XmlToYamlNodeConverter
+{
+ /// The root element local-name.
+ private const string RootName = "flexrender";
+
+ ///
+ /// Wrapper child element names that map to dedicated YAML list/branch keys rather than
+ /// being treated as nested layout elements.
+ ///
+ private static readonly HashSet WrapperNames = new(StringComparer.Ordinal)
+ {
+ "then", "else", "else-if", "columns", "rows", "series",
+ "categories", "x-labels", "y-labels", "palette", "shapes"
+ };
+
+ ///
+ /// Attribute names whose comma/semicolon values expand into YAML sequences.
+ ///
+ private static readonly HashSet ListAttributes = new(StringComparer.Ordinal)
+ {
+ "data", "points", "categories", "palette", "x-labels", "y-labels"
+ };
+
+ ///
+ /// Parses the XML string and builds the YAML document root mapping.
+ ///
+ /// The raw XML template.
+ /// The equivalent document root mapping node.
+ /// Thrown when the XML is malformed or the root element is wrong.
+ internal static YamlMappingNode Convert(string xml)
+ {
+ XDocument doc;
+ try
+ {
+ doc = XDocument.Parse(xml, LoadOptions.None);
+ }
+ catch (System.Xml.XmlException ex)
+ {
+ throw new TemplateParseException($"Invalid XML: {ex.Message}", ex);
+ }
+
+ var rootEl = doc.Root
+ ?? throw new TemplateParseException("XML template has no root element.");
+
+ if (!string.Equals(rootEl.Name.LocalName, RootName, StringComparison.Ordinal))
+ {
+ throw new TemplateParseException(
+ $"XML template root must be <{RootName}>, but was <{rootEl.Name.LocalName}>.");
+ }
+
+ var root = new YamlMappingNode();
+
+ // template metadata from root attributes
+ var metadata = new YamlMappingNode();
+ AddAttrIfPresent(rootEl, "name", metadata);
+ AddAttrIfPresent(rootEl, "version", metadata);
+ AddAttrIfPresent(rootEl, "culture", metadata);
+ if (metadata.Children.Count > 0)
+ {
+ root.Add("template", metadata);
+ }
+
+ var layout = new YamlSequenceNode();
+
+ foreach (var child in rootEl.Elements())
+ {
+ var localName = child.Name.LocalName;
+ switch (localName)
+ {
+ case "canvas":
+ root.Add("canvas", AttributesToMapping(child));
+ break;
+ case "fonts":
+ root.Add("fonts", ConvertFonts(child));
+ break;
+ default:
+ layout.Add(ConvertElement(child));
+ break;
+ }
+ }
+
+ root.Add("layout", layout);
+ return root;
+ }
+
+ ///
+ /// Builds a mapping node containing only the element's attributes (no type, no children).
+ ///
+ private static YamlMappingNode AttributesToMapping(XElement el)
+ {
+ var node = new YamlMappingNode();
+ foreach (var attr in el.Attributes())
+ {
+ node.Add(attr.Name.LocalName, new YamlScalarNode(attr.Value));
+ }
+ return node;
+ }
+
+ ///
+ /// Converts a single layout element (recursively) into a YAML mapping node with a
+ /// type entry, scalar attributes, and child-derived list properties.
+ ///
+ private static YamlMappingNode ConvertElement(XElement el)
+ {
+ var type = el.Name.LocalName;
+ var node = new YamlMappingNode();
+ node.Add("type", new YamlScalarNode(type));
+
+ // Attributes -> scalar or list entries.
+ foreach (var attr in el.Attributes())
+ {
+ var name = attr.Name.LocalName;
+ if (ListAttributes.Contains(name))
+ {
+ node.Add(name, ExpandListAttribute(attr.Value));
+ }
+ else
+ {
+ node.Add(name, new YamlScalarNode(attr.Value));
+ }
+ }
+
+ // Inner text -> content (only when no content attribute and there are no child elements).
+ if (el.Attribute("content") is null && !el.HasElements)
+ {
+ var inner = el.Value;
+ if (!string.IsNullOrWhiteSpace(inner))
+ {
+ node.Add("content", new YamlScalarNode(inner.Trim()));
+ }
+ }
+
+ // Child elements.
+ var naturalList = new YamlSequenceNode();
+ foreach (var child in el.Elements())
+ {
+ var childName = child.Name.LocalName;
+ if (WrapperNames.Contains(childName))
+ {
+ AddWrapper(node, type, child);
+ }
+ else
+ {
+ naturalList.Add(ConvertElement(child));
+ }
+ }
+
+ if (naturalList.Children.Count > 0)
+ {
+ node.Add(NaturalListKey(type), naturalList);
+ }
+
+ return node;
+ }
+
+ ///
+ /// Maps an element type to the YAML key its directly-nested layout children belong under.
+ ///
+ private static string NaturalListKey(string type) => type switch
+ {
+ "each" => "children",
+ _ => "children" // flex and any other container use 'children'
+ };
+
+ ///
+ /// Adds a recognised wrapper child (then/else/columns/series/...) to the node under its YAML key.
+ ///
+ private static void AddWrapper(YamlMappingNode node, string parentType, XElement wrapper)
+ {
+ var name = wrapper.Name.LocalName;
+ switch (name)
+ {
+ case "then":
+ case "else":
+ node.Add(name, ConvertElementSequence(wrapper));
+ break;
+ case "else-if":
+ var inner = wrapper.Elements().FirstOrDefault();
+ if (inner is not null)
+ {
+ node.Add("elseIf", ConvertElement(inner));
+ }
+ break;
+ case "columns":
+ node.Add("columns", ConvertAttributeItemSequence(wrapper));
+ break;
+ case "rows":
+ node.Add("rows", ConvertAttributeItemSequence(wrapper));
+ break;
+ case "series":
+ // Handled at element level for charts; placeholder (overridden in chart task).
+ break;
+ case "categories":
+ case "x-labels":
+ case "y-labels":
+ node.Add(name, ConvertScalarItemSequence(wrapper));
+ break;
+ case "palette":
+ node.Add("palette", ConvertScalarItemSequence(wrapper));
+ break;
+ case "shapes":
+ // Handled in the draw task.
+ break;
+ }
+ }
+
+ /// Converts child layout elements of a wrapper into a YAML sequence of mappings.
+ private static YamlSequenceNode ConvertElementSequence(XElement wrapper)
+ {
+ var seq = new YamlSequenceNode();
+ foreach (var child in wrapper.Elements())
+ {
+ seq.Add(ConvertElement(child));
+ }
+ return seq;
+ }
+
+ /// Converts child elements whose attributes become mapping fields (table column/row).
+ private static YamlSequenceNode ConvertAttributeItemSequence(XElement wrapper)
+ {
+ var seq = new YamlSequenceNode();
+ foreach (var child in wrapper.Elements())
+ {
+ seq.Add(AttributesToMapping(child));
+ }
+ return seq;
+ }
+
+ /// Converts <item>value</item> / <color> children into a scalar sequence.
+ private static YamlSequenceNode ConvertScalarItemSequence(XElement wrapper)
+ {
+ var seq = new YamlSequenceNode();
+ foreach (var child in wrapper.Elements())
+ {
+ seq.Add(new YamlScalarNode(child.Value.Trim()));
+ }
+ return seq;
+ }
+
+ /// Converts a <fonts> wrapper into a YAML sequence of font entry mappings.
+ private static YamlSequenceNode ConvertFonts(XElement fonts)
+ {
+ var seq = new YamlSequenceNode();
+ foreach (var font in fonts.Elements())
+ {
+ seq.Add(AttributesToMapping(font));
+ }
+ return seq;
+ }
+
+ ///
+ /// Expands a comma-separated (or "x,y; x,y" tuple) attribute value into a YAML sequence.
+ /// A value containing a template expression is left as a scalar.
+ ///
+ private static YamlNode ExpandListAttribute(string value)
+ {
+ if (value.Contains("{{", StringComparison.Ordinal))
+ {
+ return new YamlScalarNode(value);
+ }
+
+ // Tuple list: "1,2; 3,4" -> [[1,2],[3,4]]
+ if (value.Contains(';', StringComparison.Ordinal))
+ {
+ var outer = new YamlSequenceNode();
+ foreach (var part in value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ var inner = new YamlSequenceNode();
+ foreach (var comp in part.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ inner.Add(new YamlScalarNode(comp));
+ }
+ outer.Add(inner);
+ }
+ return outer;
+ }
+
+ var seq = new YamlSequenceNode();
+ foreach (var item in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ seq.Add(new YamlScalarNode(item));
+ }
+ return seq;
+ }
+
+ /// Adds an XML attribute to a mapping node when present and non-empty.
+ private static void AddAttrIfPresent(XElement el, string name, YamlMappingNode node)
+ {
+ var attr = el.Attribute(name);
+ if (attr is not null && !string.IsNullOrEmpty(attr.Value))
+ {
+ node.Add(name, new YamlScalarNode(attr.Value));
+ }
+ }
+
+ /// Unused parameter guard for invariant-culture formatting helpers (kept for future numeric formatting).
+ private static string FormatInvariant(double value) => value.ToString(CultureInfo.InvariantCulture);
+}
+```
+
+> NOTE: `series` and `shapes` wrappers are intentionally left unhandled in `AddWrapper` here; Tasks 7 (shapes) and 8 (chart) add their handling. `FormatInvariant` is included to keep one invariant-culture helper available; if the analyzer flags it as unused (IDE0051/CA1823), inline-delete it in Task 8 when numeric formatting is actually used, or remove it now — see Step 4.
+
+Create `src/FlexRender.Xml/XmlTemplateParser.cs`:
+
+```csharp
+using FlexRender.Abstractions;
+using FlexRender.Configuration;
+using FlexRender.Parsing;
+using FlexRender.Parsing.Ast;
+
+namespace FlexRender.Xml;
+
+///
+/// Parses FlexRender XML templates into the same AST as the YAML parser.
+/// XML is translated into the equivalent document tree and handed to the shared
+/// so all element parsing, validation, and resource limits are reused.
+///
+public sealed class XmlTemplateParser : ITemplateParser
+{
+ private readonly TemplateParser _inner;
+
+ ///
+ /// Initializes a new instance of the class with default resource limits.
+ ///
+ public XmlTemplateParser() : this(new ResourceLimits())
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with custom resource limits.
+ ///
+ /// The resource limits to apply.
+ /// Thrown when is null.
+ public XmlTemplateParser(ResourceLimits limits)
+ {
+ ArgumentNullException.ThrowIfNull(limits);
+ _inner = new TemplateParser(limits);
+ }
+
+ ///
+ /// Parses an XML template string into a AST.
+ ///
+ /// The XML template content.
+ /// The parsed template.
+ /// Thrown when is null.
+ /// Thrown when the XML is malformed or invalid.
+ public Template Parse(string content)
+ {
+ ArgumentNullException.ThrowIfNull(content);
+
+ if (string.IsNullOrWhiteSpace(content))
+ {
+ throw new TemplateParseException("Template XML is empty or whitespace");
+ }
+
+ var root = XmlToYamlNodeConverter.Convert(content);
+ return _inner.ParseDocumentRoot(root);
+ }
+
+ ///
+ /// Parses an XML template from a stream.
+ ///
+ /// The stream containing XML content.
+ /// The parsed template.
+ /// Thrown when is null.
+ /// Thrown when the XML is malformed or invalid.
+ public Template Parse(Stream stream)
+ {
+ ArgumentNullException.ThrowIfNull(stream);
+ using var reader = new StreamReader(stream);
+ return Parse(reader.ReadToEnd());
+ }
+}
+```
+
+- [ ] **Step 4: Remove the unused FormatInvariant helper if the build flags it**
+
+Run: `dotnet build FlexRender.slnx`
+If the build fails on `FormatInvariant` (IDE0051 / CA1823, `TreatWarningsAsErrors`), delete the `FormatInvariant` method and its doc comment from `XmlToYamlNodeConverter.cs`. Re-run until: Build succeeded, 0 errors.
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~XmlTemplateParserBasicTests"`
+Expected: PASS (6 tests).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/FlexRender.Xml/ tests/FlexRender.Tests/Parsing/Xml/XmlTemplateParserBasicTests.cs
+git commit --no-gpg-sign -m "feat: XML template parser with canvas, text, separator"
+```
+
+---
+
+### Task 4: Flex nesting and remaining leaf elements (qr/barcode/image/content)
+
+The converter already recurses into non-wrapper children as `children`. Add tests proving flex nesting and the other leaf elements work through the shared parser.
+
+**Files:**
+- Test: `tests/FlexRender.Tests/Parsing/Xml/XmlFlexNestingTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Parsing/Xml/XmlFlexNestingTests.cs`:
+
+```csharp
+using FlexRender.Layout;
+using FlexRender.Parsing.Ast;
+using FlexRender.Xml;
+using Xunit;
+
+namespace FlexRender.Tests.Parsing.Xml;
+
+///
+/// Tests for nested flex containers and leaf elements via the XML parser.
+///
+public class XmlFlexNestingTests
+{
+ private readonly XmlTemplateParser _parser = new();
+
+ [Fact]
+ public void Parse_NestedFlexWithChildren()
+ {
+ const string xml = """
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ var flex = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements));
+ Assert.Equal(FlexDirection.Row, flex.Direction);
+ Assert.Equal(JustifyContent.Center, flex.Justify);
+ Assert.Equal(2, flex.Children.Count);
+
+ var inner = Assert.IsType(flex.Children[1]);
+ Assert.Equal(FlexDirection.Column, inner.Direction);
+ Assert.Equal(2, inner.Children.Count);
+ Assert.Equal("A", Assert.IsType(inner.Children[0]).Content);
+ }
+
+ [Fact]
+ public void Parse_QrBarcodeImage()
+ {
+ const string xml = """
+
+
+
+
+
+
+ """;
+
+ var elements = _parser.Parse(xml).Elements;
+ var qr = Assert.IsType(elements[0]);
+ Assert.Equal("hello", qr.Data);
+ Assert.Equal(ErrorCorrectionLevel.H, qr.ErrorCorrection);
+
+ var barcode = Assert.IsType(elements[1]);
+ Assert.Equal(BarcodeFormat.Ean13, barcode.Format);
+
+ var image = Assert.IsType(elements[2]);
+ Assert.Equal("logo.png", image.Src);
+ Assert.Equal(ImageFit.Cover, image.Fit);
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails or passes**
+
+Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~XmlFlexNestingTests"`
+Expected: PASS — the converter already handles nested children and attribute mapping. If any assertion fails, fix `ConvertElement`/`NaturalListKey` until green. (This task is primarily a coverage lock for the generic recursion.)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add tests/FlexRender.Tests/Parsing/Xml/XmlFlexNestingTests.cs
+git commit --no-gpg-sign -m "test: XML flex nesting and qr/barcode/image elements"
+```
+
+---
+
+### Task 5: Control flow — each / if (then/else/else-if)
+
+**Files:**
+- Test: `tests/FlexRender.Tests/Parsing/Xml/XmlControlFlowTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/FlexRender.Tests/Parsing/Xml/XmlControlFlowTests.cs`:
+
+```csharp
+using FlexRender.Parsing.Ast;
+using FlexRender.Xml;
+using Xunit;
+
+namespace FlexRender.Tests.Parsing.Xml;
+
+///
+/// Tests for each/if control-flow elements via the XML parser.
+///
+public class XmlControlFlowTests
+{
+ private readonly XmlTemplateParser _parser = new();
+
+ [Fact]
+ public void Parse_Each_WithChildren()
+ {
+ const string xml = """
+
+
+
+
+
+
+ """;
+
+ var each = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements));
+ Assert.Equal("items", each.ArrayPath);
+ Assert.Equal("item", each.ItemVariable);
+ Assert.Single(each.ItemTemplate);
+ Assert.IsType(each.ItemTemplate[0]);
+ }
+
+ [Fact]
+ public void Parse_If_ThenElse()
+ {
+ const string xml = """
+
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ var ifEl = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements));
+ Assert.Equal("paid", ifEl.ConditionPath);
+ Assert.Equal(ConditionOperator.Equals, ifEl.Operator);
+ Assert.Equal("true", ifEl.CompareValue);
+ Assert.Single(ifEl.ThenBranch);
+ Assert.Single(ifEl.ElseBranch);
+ Assert.Equal("PAID", Assert.IsType(ifEl.ThenBranch[0]).Content);
+ Assert.Equal("DUE", Assert.IsType(ifEl.ElseBranch[0]).Content);
+ }
+
+ [Fact]
+ public void Parse_If_ElseIf()
+ {
+ const string xml = """
+
+
+
+
+
+
+