diff --git a/.opencode/skills/data-viz/SKILL.md b/.opencode/skills/data-viz/SKILL.md
index ad6a02169..44f85fedc 100644
--- a/.opencode/skills/data-viz/SKILL.md
+++ b/.opencode/skills/data-viz/SKILL.md
@@ -100,6 +100,8 @@ A single insight might just be one chart with a headline and annotation. Scale c
- **Responsive**: `min-h-[VALUE]` on all charts. Grid stacks on mobile
- **Animation**: Entry transitions only, `duration-300` to `duration-500`. Never continuous
- **Accessibility**: `aria-label` on charts, WCAG AA contrast, don't rely on color alone
+- **Dynamic color safety**: When colors come from external sources (brand palettes, category maps, API data, user config), never apply them directly as text color without a contrast check. Dark colors are invisible on dark card backgrounds. Safe pattern: use the external color only for non-text elements (left border, dot, underline); always use the standard text color (white / `var(--text)`) for the label itself. If color-coded text is required, apply a minimum lightness floor: `color: hsl(from brandColor h s max(l, 60%))`
+- **Icon semantics**: Verify every icon matches its label's actual meaning, not just its visual shape. Common traps: using a rising-trend icon (📈) for metrics where lower is better (latency, error rate, cost); using achievement icons (🏆) for plain counts. When in doubt, use a neutral descriptive icon over a thematic one that could mislead
### Step 5: Interactivity & Annotations
@@ -133,3 +135,16 @@ A single insight might just be one chart with a headline and annotation. Scale c
- Pie charts > 5 slices — use horizontal bar
- Unlabeled dual y-axes — use two separate charts
- Truncated bar axes — always start at zero
+- Filtering or mapping over a field not confirmed to exist in the data export — an undefined field in `.filter()` or `.map()` produces empty arrays or NaN silently, not an error; always validate the exported schema matches what the chart code consumes
+
+## Pre-Delivery Checklist
+
+Before marking a dashboard complete:
+
+- [ ] Every tab / view activated — all charts render (no blank canvases, no unexpected 0–1 axes)
+- [ ] Every field referenced in chart/filter code confirmed present in the data export
+- [ ] All text readable on its background — check explicitly when colors come from external data
+- [ ] All icons match their label's meaning
+- [ ] Tooltips appear on hover for every chart
+- [ ] No chart silently receives an empty dataset — add a visible empty state or console warning
+- [ ] Mobile: grid stacks correctly, no body-level horizontal overflow
diff --git a/.opencode/skills/data-viz/references/component-guide.md b/.opencode/skills/data-viz/references/component-guide.md
index 8f792da07..28a6ce46d 100644
--- a/.opencode/skills/data-viz/references/component-guide.md
+++ b/.opencode/skills/data-viz/references/component-guide.md
@@ -392,3 +392,70 @@ const CalloutLabel = ({ viewBox, label, color = "#1e293b" }: { viewBox?: { x: nu
```
**Rules:** Never overlap data. Use `position: "insideTopRight"/"insideTopLeft"` on labels. Pair annotations with tooltips — annotation names the event, tooltip shows the value.
+
+---
+
+## Multi-Tab Dashboard — Lazy Chart Initialization
+
+Charts initialized inside a hidden container (`display:none`) render blank. Chart.js, Recharts, and Nivo all read container dimensions at mount time — a hidden container measures as `0×0`.
+
+**Rule: never initialize a chart until its container is visible.**
+
+```js
+// Vanilla JS pattern
+var _inited = {};
+
+function activateTab(name) {
+ // 1. make the tab visible first
+ document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
+ document.getElementById('tab-' + name).classList.add('active');
+ // 2. then initialize charts — only on first visit
+ if (!_inited[name]) {
+ _inited[name] = true;
+ initChartsFor(name);
+ }
+}
+
+activateTab('overview'); // init the default visible tab on page load
+```
+
+Library-specific notes:
+- **Chart.js**: canvas reads as `0×0` inside `display:none` — bars/lines never appear
+- **Recharts `ResponsiveContainer`**: reads `clientWidth = 0` — chart collapses to nothing
+- **Nivo `Responsive*`**: uses `ResizeObserver` — fires once at `0×0`, never re-fires on show
+- **React conditional rendering**: prefer `visibility:hidden` + `position:absolute` over toggling `display:none` if you want charts to stay mounted and pre-rendered
+
+---
+
+## Programmatic Dashboard Generation — Data-Code Separation
+
+When generating a standalone HTML dashboard from a script (Python, shell, etc.), never embed JSON data inside a template string that also contains JavaScript. Curly-brace collisions in f-strings / template literals cause silent JS parse failures that are hard to debug.
+
+**Wrong** — data and JS logic share one f-string, every `{` in JS must be escaped as `{{`:
+
+```python
+html = f"""
+
+"""
+```
+
+**Right** — separate data from logic entirely:
+
+```python
+# Step 1: write data to its own file — no template string needed
+with open('data.js', 'w') as f:
+ f.write('const DATA = ' + json.dumps(data) + ';')
+
+# Step 2: HTML loads both files; app.js is static and never needs escaping
+```
+
+```html
+
+
+```
+
+Benefits: `app.js` is static and independently testable; `data.js` is regenerated without touching logic; no escaping required in either file.