emberflow-skills 1.10.1 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,8 @@ Generate a **structured JSON** explainer that the Emberflow platform renders as
10
10
 
11
11
  You choose the best visualization type for each slide. A single explainer can mix different viz types across slides — a timeline on slide 2, a data table on slide 3, a chart on slide 4.
12
12
 
13
+ **Visual diversity is critical.** Every explainer should feel bespoke. Don't fall back on "grid of stat cards" or "list of bordered boxes" as the default — those are just one option among many. Think about what shape the data actually has: is it a flow? Use a Sankey or funnel. A ranking? Use a horizontal bar chart or podium layout. A distribution? Use a heatmap or scatter. A before/after? Use a split comparison. A hierarchy? Use a tree or nested rings. A process? Use a swimlane or animated pipeline. Match the visualization to the shape of the information, not the other way around.
14
+
13
15
  ## Output
14
16
 
15
17
  Write a single JSON file to `{topic-slug}-explainer.json` in the current working directory. Then publish it to Emberflow.
@@ -163,32 +165,47 @@ No `script` field is needed for diagram slides — the platform handles all anim
163
165
 
164
166
  ## B. Visualization Primitive Catalog
165
167
 
166
- Choose from these primitives. Compose them freely combine, nest, or invent new ones as the topic demands.
168
+ This is a **starting point, not a menu to order from**. The best explainers invent visualizations that fit the content perfectly. Combine, adapt, or create entirely new primitives. If none of these fit — build something original.
167
169
 
168
170
  ### Data Display
169
171
 
170
- - **KPI / stat cards** — Grid of boxes with label, large value, subtitle. Use for overview slides. `display: grid; grid-template-columns: 1fr 1fr; gap: 12px`
171
- - **Data table** — `<table>` with muted uppercase headers, monospace numbers, color-coded values (green positive, red negative). Category dots on row labels.
172
- - **Comparison matrix** — Table with checkmark/cross SVG icons per cell. Column headers are options, rows are features. Active column highlighted with accent border.
172
+ - **KPI / stat cards** — Grid of boxes with label, large value, subtitle. Good for overview slides, but don't default to this for everything.
173
+ - **Data table** — `<table>` with muted uppercase headers, monospace numbers, color-coded values. Category dots on row labels.
174
+ - **Comparison matrix** — Table with checkmark/cross SVG icons per cell. Active column highlighted with accent border.
175
+ - **Heatmap grid** — CSS grid where cell background intensity maps to a value. Use color gradients (green→yellow→red or cool→warm). Great for showing density, coverage, or correlation.
176
+ - **Scorecard** — Single large metric in a ring or gauge, with trend sparkline below. Different from KPI cards — more visual weight, fewer items.
173
177
 
174
178
  ### Charts
175
179
 
176
- - **Vertical bar chart** — Flex row of bars, height as percentage of max. Color-code by threshold (green/orange/red). Hover reveals value label. `transition: height 0.6s cubic-bezier(0.4, 0, 0.2, 1)`
177
- - **Horizontal bar chart** — Rows with label left, bar extending right. Good for ranked lists. Bar width as percentage via `flex` layout.
178
- - **Donut / ring chart** — SVG circle with `stroke-dasharray`/`stroke-dashoffset`. Percentage label centered absolutely.
179
- - **Funnel diagram** — Stacked horizontal bars decreasing in width, centered. Labels and conversion percentages on each stage.
180
+ - **Vertical bar chart** — Flex row of bars, height as percentage of max. Color-code by threshold. Animate height on entrance.
181
+ - **Horizontal bar chart** — Rows with label left, bar extending right. Good for ranked lists, leaderboards, survey results.
182
+ - **Donut / ring chart** — SVG circle with `stroke-dasharray`/`stroke-dashoffset`. Percentage label centered.
183
+ - **Funnel diagram** — Stacked horizontal bars decreasing in width, centered. Show conversion rates between stages.
184
+ - **Waterfall chart** — Bars that float from the previous bar's end. Show how values build up or break down (budget additions/subtractions, funnel drop-offs).
185
+ - **Sparklines / mini line charts** — SVG polylines inside cards or rows. Show trends without axes. Great for time-series context within other layouts.
186
+ - **Stacked bar / segmented bar** — Single horizontal bar divided into colored segments. Show composition at a glance (e.g., traffic sources, time allocation).
187
+ - **Scatter / bubble plot** — SVG circles positioned by x/y data. Size encodes a third dimension. Good for correlation or distribution.
188
+
189
+ ### Flows & Processes
190
+
191
+ - **Sankey diagram** — SVG paths flowing from left to right with varying widths. Show how quantities split and merge across stages (budget allocation, user flow, data pipeline).
192
+ - **Swimlane diagram** — Horizontal lanes (rows) representing actors/systems. Steps flow left-to-right across lanes showing handoffs. Great for cross-team processes.
193
+ - **Animated pipeline** — Horizontal or vertical sequence of stages with animated dots/particles moving through. Show data or request flow in real-time feel.
194
+ - **Decision tree** — Binary branching layout. Each node is a question, branches lead to outcomes. Clickable to explore paths.
180
195
 
181
196
  ### Timelines & Sequences
182
197
 
183
198
  - **Vertical timeline** — Left border line with dot markers. Each event has date, title, description, optional progress bar and status badge.
184
199
  - **Horizontal timeline** — Flex row of connected nodes along a horizontal line. Good for fewer items (3-6).
185
200
  - **Progress stepper** — Numbered circles connected by lines. Active step highlighted, completed steps filled.
201
+ - **Gantt-style chart** — Rows of horizontal bars on a time axis. Show overlapping phases, dependencies, or parallel workstreams.
202
+ - **Spiral timeline** — Events arranged along a spiral path (SVG). Unusual and memorable for cyclical or long-spanning histories.
186
203
 
187
204
  ### Relationships & Structure (Auto-Layout Diagrams)
188
205
 
189
206
  For any node-and-edge visualization, use a **declarative diagram object** as the `viz` field. The platform auto-positions nodes and routes edges — no manual coordinate math needed.
190
207
 
191
- - **Architecture diagram** — Use `viz: { nodes, edges, groups }` with color-coded nodes, meaningful icons, and labeled groups. The platform lays out each group as a horizontal row, stacks groups vertically, and draws cross-group edges as smooth beziers. See Section A → "Architecture diagrams".
208
+ - **Architecture diagram** — Use `viz: { nodes, edges, groups }` with color-coded nodes, meaningful icons, and labeled groups. See Section A → "Architecture diagrams".
192
209
  - **Flowchart** — Same declarative format with a single group or no groups. Set `direction: "TB"` for top-to-bottom flow.
193
210
  - **Org chart / hierarchy** — Use `direction: "TB"` and groups for departments. Color-code by role.
194
211
  - **Network / data flow** — Multiple groups connected by cross-group edges. Color edges by data type.
@@ -197,17 +214,27 @@ For any node-and-edge visualization, use a **declarative diagram object** as the
197
214
 
198
215
  - **Periodic table / grid** — CSS grid of cards with colored top bar per category. Hover shows detail.
199
216
  - **Kanban board** — Columns with card items. Cards can highlight or shift between columns per slide.
217
+ - **Tier list** — Labeled rows (S/A/B/C/F) with items placed in each tier. Color-coded by rank. Great for evaluations and comparisons.
200
218
 
201
219
  ### Status & Indicators
202
220
 
203
221
  - **Risk cards** — Stacked cards with severity SVG icon, title, description, colored severity badge.
204
222
  - **Stat delta** — Large number with up/down arrow SVG and percentage change.
223
+ - **Gauge / meter** — SVG arc filled to a percentage. More visual impact than a number alone.
205
224
  - **Utilization bars** — Rows with label, horizontal progress bar, percentage.
206
225
  - **Checklist** — Items with check/cross SVG icons. Grouped by category.
207
226
 
227
+ ### Comparisons & Layouts
228
+
229
+ - **Split comparison** — Two panels side-by-side (before/after, option A/option B). Highlight differences with color.
230
+ - **Podium / ranking** — Three items in a 2-1-3 podium arrangement (second, first, third). Great for top-3 results.
231
+ - **Concentric rings** — Nested circles or rings showing layers (e.g., onion architecture, security perimeters, impact radius).
232
+ - **Quadrant chart** — 2×2 grid with labeled axes. Place items as dots or cards in quadrants (e.g., effort/impact, urgency/importance).
233
+ - **Radar / spider chart** — SVG polygon on radial axes. Compare multiple dimensions of a single entity or overlay two entities.
234
+
208
235
  ### Inventing New Primitives
209
236
 
210
- The catalog above is a starting point. If the topic calls for a visualization not listed, invent one. Combine primitives freely. The only constraints are the design tokens and the slide-based interaction model.
237
+ The catalog above is a starting point. **The best explainers often use visualizations not on this list.** If the topic calls for something new — a custom game board, an animated state machine, a nested ring diagram, a radial burst — build it. The only constraints are the design tokens and the slide-based interaction model.
211
238
 
212
239
  ---
213
240
 
@@ -265,6 +292,8 @@ Before writing any code, plan 4-7 slides:
265
292
  2. **Slides 2-6** = Each focuses on one concept or subset. Zoom in, highlight, or switch viz type.
266
293
  3. **Last slide** (optional) = Summary or call-to-action.
267
294
 
295
+ **Vary the viz type across slides.** If slide 1 uses a diagram, slide 2 should use something different (a chart, timeline, table, etc.). Repeating the same viz structure on every slide makes the explainer feel static and monotonous. Each slide transition should feel like a new lens on the topic.
296
+
268
297
  For each slide, define:
269
298
  - **label**: Short uppercase label
270
299
  - **title**: Heading text
@@ -389,14 +418,16 @@ Or use the Emberflow MCP/CLI to publish with `content_type: 'explainer'`.
389
418
 
390
419
  ## H. Reference Templates
391
420
 
392
- Before generating, read the template that best matches your planned visualization:
421
+ Read **one** template before generating to understand the JSON structure, field conventions, and script patterns:
393
422
 
394
423
  ```
395
- Read templates/architecture-explainer.json for a flowchart example (SVG nodes + edges, show/active/dimmed states, staggered entrance).
424
+ Read templates/diverse-viz-explainer.json RECOMMENDED: showcases 5 different viz types across slides (funnel, heatmap, radar chart, waterfall, gauges). Start here if you're unsure which template to use.
425
+
426
+ Read templates/architecture-explainer.json — declarative diagram example (nodes + edges, active/dimmed states, staggered entrance).
396
427
 
397
- Read templates/project-overview-explainer.json for a mixed-viz example (KPIs, timeline, budget table, donut ring, risk cards — different viz type per slide).
428
+ Read templates/project-overview-explainer.json mixed-viz example (KPIs, timeline, budget table, donut ring, risk cards — different viz type per slide).
398
429
 
399
- Read templates/dashboard-explainer.json for a data viz example (animated bar chart, color-coded values, dataset switching per slide, insight callouts).
430
+ Read templates/dashboard-explainer.json data viz example (animated bar chart, color-coded values, dataset switching, insight callouts).
400
431
  ```
401
432
 
402
- Follow the patterns in the template closely. The templates are the ground truth for JSON structure, CSS conventions, and script patterns.
433
+ Use the templates for **structural reference only** — JSON shape, field naming, script scoping, and animation timing. Do NOT copy their visual style. Your explainer should look distinct from every template. Choose visualization types that match your specific content, not the template's content.
@@ -0,0 +1,40 @@
1
+ {
2
+ "css": ".fn { display: flex; flex-direction: column; gap: 6px; width: 100%; }\n.fn-stage { display: flex; align-items: center; gap: 12px; opacity: 0; transform: translateX(-12px); transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1); }\n.fn-stage.visible { opacity: 1; transform: translateX(0); }\n.fn-bar { height: 38px; border-radius: 6px; width: 0; transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1); flex-shrink: 0; }\n.fn-info { min-width: 90px; }\n.fn-name { font-size: 13px; font-weight: 600; color: var(--text); display: block; }\n.fn-sub { font-size: 11px; color: var(--text-muted); }\n.fn-rate { font-size: 12px; font-weight: 600; margin-left: auto; padding: 2px 10px; border-radius: 12px; white-space: nowrap; }\n.fn-drop { font-size: 10px; color: var(--text-muted); text-align: center; padding: 2px 0; opacity: 0; transition: opacity 0.4s; }\n.fn-drop.visible { opacity: 1; }\n\n.hm { width: 100%; }\n.hm-grid { display: flex; flex-direction: column; gap: 3px; }\n.hm-row { display: grid; grid-template-columns: 44px repeat(7, 1fr); gap: 3px; }\n.hm-label { font-size: 10px; color: var(--text-muted); display: flex; align-items: center; }\n.hm-head { font-size: 10px; color: var(--text-muted); text-align: center; font-weight: 600; text-transform: uppercase; }\n.hm-cell { aspect-ratio: 1.6; border-radius: 3px; opacity: 0; transition: opacity 0.3s; cursor: pointer; position: relative; }\n.hm-cell.visible { opacity: 1; }\n.hm-cell:hover { outline: 1px solid var(--text-muted); }\n.hm-cell:hover::after { content: attr(data-val); position: absolute; top: -20px; left: 50%; transform: translateX(-50%); font-size: 9px; color: var(--text); background: var(--bg); padding: 1px 5px; border-radius: 3px; border: 1px solid var(--border); white-space: nowrap; z-index: 1; }\n.hm-scale { display: flex; align-items: center; gap: 6px; margin-top: 8px; justify-content: flex-end; }\n.hm-scale-bar { width: 80px; height: 8px; border-radius: 4px; background: linear-gradient(90deg, rgba(234,88,12,0.05), rgba(234,88,12,0.9)); }\n.hm-scale span { font-size: 9px; color: var(--text-muted); }\n\n.rd { width: 100%; display: flex; flex-direction: column; align-items: center; }\n.rd-chart { position: relative; }\n.rd-axis { stroke: var(--border); stroke-width: 1; }\n.rd-ring { fill: none; stroke: var(--border); stroke-width: 0.5; stroke-dasharray: 4 4; }\n.rd-poly { fill-opacity: 0; stroke-width: 2; stroke-dasharray: 500; stroke-dashoffset: 500; transition: stroke-dashoffset 1.2s cubic-bezier(0.4, 0, 0.2, 1), fill-opacity 0.6s 0.8s; }\n.rd-poly.visible { stroke-dashoffset: 0; fill-opacity: 0.12; }\n.rd-poly.cur { stroke: #ea580c; fill: #ea580c; }\n.rd-poly.prev { stroke: #3b82f6; fill: #3b82f6; }\n.rd-dot { r: 3; fill: var(--bg); stroke-width: 2; opacity: 0; transition: opacity 0.3s 1s; }\n.rd-dot.visible { opacity: 1; }\n.rd-dot.cur { stroke: #ea580c; }\n.rd-dot.prev { stroke: #3b82f6; }\n.rd-lbl { font-size: 10px; fill: var(--text-muted); text-anchor: middle; }\n.rd-legend { display: flex; gap: 20px; justify-content: center; margin-top: 16px; font-size: 12px; color: var(--text-muted); }\n.rd-legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-right: 6px; vertical-align: middle; }\n\n.wf { width: 100%; }\n.wf-chart { display: flex; align-items: flex-end; gap: 4px; height: 240px; padding-bottom: 24px; position: relative; }\n.wf-chart::before { content: ''; position: absolute; left: 0; right: 0; bottom: 24px; height: 1px; background: var(--border); }\n.wf-col { flex: 1; position: relative; height: 100%; display: flex; flex-direction: column; justify-content: flex-end; align-items: center; }\n.wf-block { width: 65%; border-radius: 3px; position: relative; opacity: 0; transform: scaleY(0); transform-origin: bottom; transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1); }\n.wf-block.visible { opacity: 1; transform: scaleY(1); }\n.wf-block.add { background: #22c55e; }\n.wf-block.sub { background: #ef4444; transform-origin: top; }\n.wf-block.total { background: var(--link); }\n.wf-val { font-size: 10px; font-weight: 600; color: var(--text); text-align: center; margin-bottom: 4px; opacity: 0; transition: opacity 0.3s 0.4s; white-space: nowrap; }\n.wf-val.visible { opacity: 1; }\n.wf-lbl { font-size: 9px; color: var(--text-muted); text-align: center; white-space: nowrap; position: absolute; bottom: -18px; }\n.wf-connector { position: absolute; right: -4px; width: 8px; border-top: 1px dashed var(--text-muted); opacity: 0.4; }\n\n.gg-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; width: 100%; max-width: 360px; margin: 0 auto; }\n.gg-item { display: flex; flex-direction: column; align-items: center; gap: 6px; opacity: 0; transform: scale(0.9); transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1); }\n.gg-item.visible { opacity: 1; transform: scale(1); }\n.gg-track { fill: none; stroke: var(--border); stroke-width: 10; stroke-linecap: round; }\n.gg-fill { fill: none; stroke-width: 10; stroke-linecap: round; transition: stroke-dashoffset 1.2s cubic-bezier(0.4, 0, 0.2, 1); }\n.gg-val { font-size: 18px; font-weight: 700; text-anchor: middle; fill: var(--text); }\n.gg-lbl { font-size: 12px; color: var(--text-muted); text-align: center; font-weight: 600; }\n.gg-sub { font-size: 10px; color: var(--text-muted); text-align: center; }",
3
+ "slides": [
4
+ {
5
+ "label": "Funnel",
6
+ "title": "Signup Conversion Funnel",
7
+ "prose": "<p>The signup funnel tracks users from first visit through to paid conversion. Each stage represents a meaningful commitment — from anonymous browsing to entering payment details.</p><p>The <strong>activation step</strong> (completing onboarding) is the biggest single drop-off at 44% loss. Users who activate convert to paid at a strong 36% rate, suggesting the product delivers value once users get past initial setup.</p><p>Overall visitor-to-paid conversion sits at <strong>6.5%</strong>, competitive for a self-serve SaaS product.</p>",
8
+ "viz": "<div class=\"fn\"><div class=\"fn-stage\"><div class=\"fn-bar\" style=\"background: #3b82f6;\" data-w=\"100%\"></div><div class=\"fn-info\"><span class=\"fn-name\">Visitors</span><span class=\"fn-sub\">24,500 unique</span></div><span class=\"fn-rate\" style=\"background: rgba(59,130,246,0.12); color: #3b82f6;\">100%</span></div><div class=\"fn-drop\" data-text=\"\u2193 68% drop-off\"></div><div class=\"fn-stage\"><div class=\"fn-bar\" style=\"background: #8b5cf6;\" data-w=\"32%\"></div><div class=\"fn-info\"><span class=\"fn-name\">Signups</span><span class=\"fn-sub\">7,840 accounts</span></div><span class=\"fn-rate\" style=\"background: rgba(139,92,246,0.12); color: #8b5cf6;\">32%</span></div><div class=\"fn-drop\" data-text=\"\u2193 44% drop-off\"></div><div class=\"fn-stage\"><div class=\"fn-bar\" style=\"background: #ea580c;\" data-w=\"18%\"></div><div class=\"fn-info\"><span class=\"fn-name\">Activated</span><span class=\"fn-sub\">4,410 onboarded</span></div><span class=\"fn-rate\" style=\"background: rgba(234,88,12,0.12); color: #ea580c;\">18%</span></div><div class=\"fn-drop\" data-text=\"\u2193 64% drop-off\"></div><div class=\"fn-stage\"><div class=\"fn-bar\" style=\"background: #22c55e;\" data-w=\"6.5%\"></div><div class=\"fn-info\"><span class=\"fn-name\">Paying</span><span class=\"fn-sub\">1,590 customers</span></div><span class=\"fn-rate\" style=\"background: rgba(34,197,94,0.12); color: #22c55e;\">6.5%</span></div></div>",
9
+ "script": "var stages = container.querySelectorAll('.fn-stage');\nvar drops = container.querySelectorAll('.fn-drop');\nstages.forEach(function(s, i) {\n setTimeout(function() {\n s.classList.add('visible');\n var bar = s.querySelector('.fn-bar');\n bar.style.width = bar.getAttribute('data-w');\n if (i > 0 && drops[i - 1]) {\n drops[i - 1].textContent = drops[i - 1].getAttribute('data-text');\n drops[i - 1].classList.add('visible');\n }\n }, 200 + i * 180);\n});"
10
+ },
11
+ {
12
+ "label": "Activity",
13
+ "title": "Signup Heatmap",
14
+ "prose": "<p>Signups cluster heavily around weekday lunchtimes and early evenings. <strong>Tuesday 12\u2013\u20131pm</strong> is the single hottest slot, likely driven by users discovering the product during work breaks.</p><p>Weekend activity is uniformly low, suggesting a B2B-leaning user base. Late-night signups (10pm+) are near zero across all days.</p><p>This pattern informs ad scheduling — concentrating spend on weekday 11am\u20132pm captures peak intent with minimal waste.</p>",
15
+ "viz": "<div class=\"hm\"><div class=\"hm-grid\"><div class=\"hm-row\"><span></span><span class=\"hm-head\">Mon</span><span class=\"hm-head\">Tue</span><span class=\"hm-head\">Wed</span><span class=\"hm-head\">Thu</span><span class=\"hm-head\">Fri</span><span class=\"hm-head\">Sat</span><span class=\"hm-head\">Sun</span></div><div class=\"hm-row\"><span class=\"hm-label\">6\u2013\u20139am</span><span class=\"hm-cell\" data-val=\"42\" style=\"background:rgba(234,88,12,0.12)\"></span><span class=\"hm-cell\" data-val=\"38\" style=\"background:rgba(234,88,12,0.10)\"></span><span class=\"hm-cell\" data-val=\"45\" style=\"background:rgba(234,88,12,0.13)\"></span><span class=\"hm-cell\" data-val=\"40\" style=\"background:rgba(234,88,12,0.11)\"></span><span class=\"hm-cell\" data-val=\"35\" style=\"background:rgba(234,88,12,0.09)\"></span><span class=\"hm-cell\" data-val=\"12\" style=\"background:rgba(234,88,12,0.03)\"></span><span class=\"hm-cell\" data-val=\"8\" style=\"background:rgba(234,88,12,0.02)\"></span></div><div class=\"hm-row\"><span class=\"hm-label\">9\u2013\u201312pm</span><span class=\"hm-cell\" data-val=\"120\" style=\"background:rgba(234,88,12,0.45)\"></span><span class=\"hm-cell\" data-val=\"135\" style=\"background:rgba(234,88,12,0.52)\"></span><span class=\"hm-cell\" data-val=\"118\" style=\"background:rgba(234,88,12,0.44)\"></span><span class=\"hm-cell\" data-val=\"125\" style=\"background:rgba(234,88,12,0.48)\"></span><span class=\"hm-cell\" data-val=\"110\" style=\"background:rgba(234,88,12,0.40)\"></span><span class=\"hm-cell\" data-val=\"28\" style=\"background:rgba(234,88,12,0.07)\"></span><span class=\"hm-cell\" data-val=\"20\" style=\"background:rgba(234,88,12,0.05)\"></span></div><div class=\"hm-row\"><span class=\"hm-label\">12\u2013\u20133pm</span><span class=\"hm-cell\" data-val=\"180\" style=\"background:rgba(234,88,12,0.72)\"></span><span class=\"hm-cell\" data-val=\"210\" style=\"background:rgba(234,88,12,0.90)\"></span><span class=\"hm-cell\" data-val=\"175\" style=\"background:rgba(234,88,12,0.70)\"></span><span class=\"hm-cell\" data-val=\"190\" style=\"background:rgba(234,88,12,0.78)\"></span><span class=\"hm-cell\" data-val=\"160\" style=\"background:rgba(234,88,12,0.62)\"></span><span class=\"hm-cell\" data-val=\"35\" style=\"background:rgba(234,88,12,0.09)\"></span><span class=\"hm-cell\" data-val=\"22\" style=\"background:rgba(234,88,12,0.06)\"></span></div><div class=\"hm-row\"><span class=\"hm-label\">3\u2013\u20136pm</span><span class=\"hm-cell\" data-val=\"145\" style=\"background:rgba(234,88,12,0.56)\"></span><span class=\"hm-cell\" data-val=\"155\" style=\"background:rgba(234,88,12,0.60)\"></span><span class=\"hm-cell\" data-val=\"140\" style=\"background:rgba(234,88,12,0.54)\"></span><span class=\"hm-cell\" data-val=\"150\" style=\"background:rgba(234,88,12,0.58)\"></span><span class=\"hm-cell\" data-val=\"130\" style=\"background:rgba(234,88,12,0.50)\"></span><span class=\"hm-cell\" data-val=\"30\" style=\"background:rgba(234,88,12,0.08)\"></span><span class=\"hm-cell\" data-val=\"18\" style=\"background:rgba(234,88,12,0.04)\"></span></div><div class=\"hm-row\"><span class=\"hm-label\">6\u2013\u201310pm</span><span class=\"hm-cell\" data-val=\"85\" style=\"background:rgba(234,88,12,0.28)\"></span><span class=\"hm-cell\" data-val=\"92\" style=\"background:rgba(234,88,12,0.32)\"></span><span class=\"hm-cell\" data-val=\"78\" style=\"background:rgba(234,88,12,0.25)\"></span><span class=\"hm-cell\" data-val=\"88\" style=\"background:rgba(234,88,12,0.30)\"></span><span class=\"hm-cell\" data-val=\"95\" style=\"background:rgba(234,88,12,0.34)\"></span><span class=\"hm-cell\" data-val=\"45\" style=\"background:rgba(234,88,12,0.13)\"></span><span class=\"hm-cell\" data-val=\"40\" style=\"background:rgba(234,88,12,0.11)\"></span></div></div><div class=\"hm-scale\"><span>Low</span><div class=\"hm-scale-bar\"></div><span>High</span></div></div>",
16
+ "script": "var cells = container.querySelectorAll('.hm-cell');\ncells.forEach(function(c, i) {\n setTimeout(function() {\n c.classList.add('visible');\n }, 50 + i * 20);\n});"
17
+ },
18
+ {
19
+ "label": "Channels",
20
+ "title": "Channel Quality Radar",
21
+ "prose": "<p>Not all acquisition channels are equal. The radar chart compares six dimensions across Q1 (orange) vs the previous quarter (blue).</p><p><strong>Organic search</strong> improved dramatically in volume and retention, driven by new SEO landing pages. <strong>Paid social</strong> scores high on volume but low on retention — users from Instagram ads churn 3x faster than organic users.</p><p>The ideal channel scores high on all axes. Organic search and referrals are closest to that ideal, while paid channels trade retention for volume.</p>",
22
+ "viz": "<div class=\"rd\"><div class=\"rd-chart\"><svg viewBox=\"0 0 300 300\" width=\"280\" height=\"280\"><g transform=\"translate(150,150)\"><circle class=\"rd-ring\" r=\"36\" /><circle class=\"rd-ring\" r=\"72\" /><circle class=\"rd-ring\" r=\"108\" /><line class=\"rd-axis\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"-115\" /><line class=\"rd-axis\" x1=\"0\" y1=\"0\" x2=\"99.6\" y2=\"-57.5\" /><line class=\"rd-axis\" x1=\"0\" y1=\"0\" x2=\"99.6\" y2=\"57.5\" /><line class=\"rd-axis\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"115\" /><line class=\"rd-axis\" x1=\"0\" y1=\"0\" x2=\"-99.6\" y2=\"57.5\" /><line class=\"rd-axis\" x1=\"0\" y1=\"0\" x2=\"-99.6\" y2=\"-57.5\" /><polygon class=\"rd-poly prev\" points=\"0,-65 69,-44 58,47 0,75 -43,52 -58,-37\" /><polygon class=\"rd-poly cur\" points=\"0,-97 76,-35 52,62 0,54 -86,50 -81,-29\" /><circle class=\"rd-dot prev\" cx=\"0\" cy=\"-65\" /><circle class=\"rd-dot prev\" cx=\"69\" cy=\"-44\" /><circle class=\"rd-dot prev\" cx=\"58\" cy=\"47\" /><circle class=\"rd-dot prev\" cx=\"0\" cy=\"75\" /><circle class=\"rd-dot prev\" cx=\"-43\" cy=\"52\" /><circle class=\"rd-dot prev\" cx=\"-58\" cy=\"-37\" /><circle class=\"rd-dot cur\" cx=\"0\" cy=\"-97\" /><circle class=\"rd-dot cur\" cx=\"76\" cy=\"-35\" /><circle class=\"rd-dot cur\" cx=\"52\" cy=\"62\" /><circle class=\"rd-dot cur\" cx=\"0\" cy=\"54\" /><circle class=\"rd-dot cur\" cx=\"-86\" cy=\"50\" /><circle class=\"rd-dot cur\" cx=\"-81\" cy=\"-29\" /><text class=\"rd-lbl\" x=\"0\" y=\"-122\">Volume</text><text class=\"rd-lbl\" x=\"115\" y=\"-57\">Speed</text><text class=\"rd-lbl\" x=\"115\" y=\"68\">Cost Eff.</text><text class=\"rd-lbl\" x=\"0\" y=\"132\">Retention</text><text class=\"rd-lbl\" x=\"-115\" y=\"68\">Quality</text><text class=\"rd-lbl\" x=\"-115\" y=\"-57\">LTV</text></g></svg></div><div class=\"rd-legend\"><span><span class=\"rd-legend-dot\" style=\"background:#ea580c\"></span>Q1 (current)</span><span><span class=\"rd-legend-dot\" style=\"background:#3b82f6\"></span>Q4 (previous)</span></div></div>",
23
+ "script": "var polys = container.querySelectorAll('.rd-poly');\nvar dots = container.querySelectorAll('.rd-dot');\npolys.forEach(function(p, i) {\n setTimeout(function() { p.classList.add('visible'); }, 200 + i * 300);\n});\ndots.forEach(function(d, i) {\n setTimeout(function() { d.classList.add('visible'); }, 400);\n});"
24
+ },
25
+ {
26
+ "label": "Revenue",
27
+ "title": "Revenue Waterfall",
28
+ "prose": "<p>Revenue grew from <strong>$180k</strong> base to <strong>$200k</strong> net — a healthy 11% increase. The waterfall breaks down each contributing factor.</p><p>New customer acquisition added <strong>$40k</strong>, and upsells from existing accounts contributed another <strong>$15k</strong>. These gains were partially offset by <strong>$25k</strong> in churn and <strong>$10k</strong> in downgrades.</p><p>The churn figure is the primary concern — it erodes 63% of new revenue. Reducing churn by even 20% would shift net growth from 11% to 14%.</p>",
29
+ "viz": "<div class=\"wf\"><div class=\"wf-chart\"><div class=\"wf-col\"><div class=\"wf-val\" data-v=\"$180k\"></div><div class=\"wf-block total\" style=\"height: 76.6%;\" data-h=\"76.6%\"></div><span class=\"wf-lbl\">Base</span></div><div class=\"wf-col\"><div class=\"wf-val\" data-v=\"+$40k\"></div><div class=\"wf-block add\" style=\"height: 17%; margin-bottom: 76.6%;\" data-h=\"17%\"></div><span class=\"wf-lbl\">New</span></div><div class=\"wf-col\"><div class=\"wf-val\" data-v=\"+$15k\"></div><div class=\"wf-block add\" style=\"height: 6.4%; margin-bottom: 93.6%;\" data-h=\"6.4%\"></div><span class=\"wf-lbl\">Upsell</span></div><div class=\"wf-col\"><div class=\"wf-val\" data-v=\"-$25k\"></div><div class=\"wf-block sub\" style=\"height: 10.6%; margin-bottom: 85.1%;\" data-h=\"10.6%\"></div><span class=\"wf-lbl\">Churn</span></div><div class=\"wf-col\"><div class=\"wf-val\" data-v=\"-$10k\"></div><div class=\"wf-block sub\" style=\"height: 4.3%; margin-bottom: 85.1%;\" data-h=\"4.3%\"></div><span class=\"wf-lbl\">Down</span></div><div class=\"wf-col\"><div class=\"wf-val\" data-v=\"$200k\"></div><div class=\"wf-block total\" style=\"height: 85.1%;\" data-h=\"85.1%\"></div><span class=\"wf-lbl\">Net</span></div></div></div>",
30
+ "script": "var blocks = container.querySelectorAll('.wf-block');\nvar vals = container.querySelectorAll('.wf-val');\nblocks.forEach(function(b, i) {\n var h = b.style.height;\n b.style.height = '0';\n setTimeout(function() {\n b.style.height = h;\n b.classList.add('visible');\n }, 150 + i * 120);\n});\nvals.forEach(function(v, i) {\n setTimeout(function() {\n v.textContent = v.getAttribute('data-v');\n v.classList.add('visible');\n }, 300 + i * 120);\n});"
31
+ },
32
+ {
33
+ "label": "Health",
34
+ "title": "System Health Gauges",
35
+ "prose": "<p>Platform health metrics show the infrastructure is holding up well under growth. <strong>Uptime</strong> at 99.7% includes two brief outages totaling 2.2 hours — both from database connection pool exhaustion during traffic spikes.</p><p><strong>Response time</strong> (P95) sits at 210ms, well within the 500ms target. <strong>Error rate</strong> is low at 0.3%, though upload-related 504s account for nearly half of all errors.</p><p><strong>Throughput headroom</strong> at 62% means the system can absorb roughly 1.6x current load before needing horizontal scaling.</p>",
36
+ "viz": "<div class=\"gg-grid\"><div class=\"gg-item\"><svg viewBox=\"0 0 120 75\" width=\"120\" height=\"75\"><path class=\"gg-track\" d=\"M 15 65 A 45 45 0 0 1 105 65\" /><path class=\"gg-fill\" d=\"M 15 65 A 45 45 0 0 1 105 65\" stroke=\"#22c55e\" data-pct=\"99.7\" style=\"stroke-dasharray: 141.4; stroke-dashoffset: 141.4;\" /><text class=\"gg-val\" x=\"60\" y=\"60\" data-v=\"99.7%\"></text></svg><div class=\"gg-lbl\">Uptime</div><div class=\"gg-sub\">Target: 99.9%</div></div><div class=\"gg-item\"><svg viewBox=\"0 0 120 75\" width=\"120\" height=\"75\"><path class=\"gg-track\" d=\"M 15 65 A 45 45 0 0 1 105 65\" /><path class=\"gg-fill\" d=\"M 15 65 A 45 45 0 0 1 105 65\" stroke=\"#22c55e\" data-pct=\"92\" style=\"stroke-dasharray: 141.4; stroke-dashoffset: 141.4;\" /><text class=\"gg-val\" x=\"60\" y=\"60\" data-v=\"210ms\"></text></svg><div class=\"gg-lbl\">P95 Latency</div><div class=\"gg-sub\">Target: &lt;500ms</div></div><div class=\"gg-item\"><svg viewBox=\"0 0 120 75\" width=\"120\" height=\"75\"><path class=\"gg-track\" d=\"M 15 65 A 45 45 0 0 1 105 65\" /><path class=\"gg-fill\" d=\"M 15 65 A 45 45 0 0 1 105 65\" stroke=\"#22c55e\" data-pct=\"97\" style=\"stroke-dasharray: 141.4; stroke-dashoffset: 141.4;\" /><text class=\"gg-val\" x=\"60\" y=\"60\" data-v=\"0.3%\"></text></svg><div class=\"gg-lbl\">Error Rate</div><div class=\"gg-sub\">Target: &lt;1%</div></div><div class=\"gg-item\"><svg viewBox=\"0 0 120 75\" width=\"120\" height=\"75\"><path class=\"gg-track\" d=\"M 15 65 A 45 45 0 0 1 105 65\" /><path class=\"gg-fill\" d=\"M 15 65 A 45 45 0 0 1 105 65\" stroke=\"#ea580c\" data-pct=\"62\" style=\"stroke-dasharray: 141.4; stroke-dashoffset: 141.4;\" /><text class=\"gg-val\" x=\"60\" y=\"60\" data-v=\"62%\"></text></svg><div class=\"gg-lbl\">Headroom</div><div class=\"gg-sub\">Scale at &lt;30%</div></div></div>",
37
+ "script": "var items = container.querySelectorAll('.gg-item');\nitems.forEach(function(item, i) {\n setTimeout(function() {\n item.classList.add('visible');\n var fill = item.querySelector('.gg-fill');\n var pct = parseFloat(fill.getAttribute('data-pct')) / 100;\n var totalLen = 141.4;\n fill.style.strokeDashoffset = totalLen * (1 - pct);\n var valEl = item.querySelector('.gg-val');\n valEl.textContent = valEl.getAttribute('data-v');\n }, 200 + i * 150);\n});"
38
+ }
39
+ ]
40
+ }
package/README.md CHANGED
@@ -1,21 +1,28 @@
1
1
  # Emberflow Skills
2
2
 
3
- Publish beautiful docs from your AI coding tools to [Emberflow](https://www.emberflow.ai) — architecture diagrams, tables, and markdown, hosted instantly.
3
+ Publish beautiful docs from your AI coding tools to [Emberflow](https://www.emberflow.ai) — architecture diagrams, interactive data viewers, JSON explorers, and markdown, hosted instantly.
4
4
 
5
5
  ## Install
6
6
 
7
- The fastest way to get started:
8
-
9
7
  ```bash
10
8
  npx emberflow-skills
11
9
  ```
12
10
 
13
- That's it. The installer auto-detects your project type (Claude Code or Cursor) and copies the skill to the right directory. You'll be publishing docs in under 10 seconds.
11
+ The installer auto-detects which AI coding tools you use and installs skills in the correct format for each.
12
+
13
+ | Tool | Format | How to use |
14
+ |------|--------|------------|
15
+ | **Claude Code** | `.claude/skills/` | `/ember-publish [topic]` |
16
+ | **Cursor** | `.cursor/rules/*.mdc` | "publish this to Emberflow" |
17
+ | **Windsurf** | `.windsurf/rules/*.md` | "publish this to Emberflow" |
18
+ | **Codex** | `.codex/*.md` | "publish this to Emberflow" |
19
+
20
+ If multiple tools are detected, skills are installed for all of them.
14
21
 
15
22
  ### Options
16
23
 
17
24
  ```bash
18
- # Install to current project (default)
25
+ # Install to current project (default — auto-detects tools)
19
26
  npx emberflow-skills
20
27
 
21
28
  # Install globally for Claude Code (available in all projects)
@@ -24,28 +31,73 @@ npx emberflow-skills --global
24
31
 
25
32
  ### What the installer does
26
33
 
27
- 1. Detects if you're in a Claude Code project (`.claude/`) or Cursor project (`.cursor/`)
28
- 2. Copies the `ember-publish` skill into your project's skills directory
29
- 3. Done use `/ember-publish` in your next conversation
34
+ 1. Detects tool configs in your project (`.claude/`, `.cursor/`, `.windsurf/`, `.codex/`)
35
+ 2. Converts skills to each tool's native format (SKILL.md, .mdc rules, markdown agents)
36
+ 3. Copies reference templates for the explainer skill
37
+ 4. Done — use the skills in your next conversation
38
+
39
+ ## Skills
30
40
 
31
- ## Usage
41
+ ### `/ember-publish`
32
42
 
33
- In Claude Code or Cursor, just type:
43
+ The smart router automatically picks the right format based on your content. Just describe what you want and it figures out whether to publish a document, JSON explorer, explainer, dataset, or Space.
34
44
 
35
45
  ```
36
46
  /ember-publish architecture overview for the payments service
37
47
  ```
38
48
 
39
- Your AI writes the markdown, generates Mermaid diagrams, and publishes it. You get back a shareable URL.
49
+ ### `/ember-publish-doc`
50
+
51
+ Publish a markdown document with interactive emberDiagrams (flowcharts, architecture maps, decision trees) — auto-laid-out with animations, no coordinates needed.
52
+
53
+ ```
54
+ /ember-publish-doc the authentication flow for our API
55
+ ```
56
+
57
+ ### `/ember-publish-json`
58
+
59
+ Publish JSON data as an interactive explorer with a node-graph visualization. Expand/collapse nodes, pan and zoom, search across keys and values, and switch between multiple payloads.
60
+
61
+ ```
62
+ /ember-publish-json the API response from /api/users
63
+ ```
64
+
65
+ ### `/ember-publish-dataset`
66
+
67
+ Publish CSV files as an interactive dataset viewer with virtual scroll (handles 10k+ rows), click-to-sort, per-column filters, search with highlighting, and CSV export.
68
+
69
+ ```
70
+ /ember-publish-dataset sales data from data/transactions.csv
71
+ ```
72
+
73
+ ### `/ember-publish-explainer`
74
+
75
+ Generate interactive visual explainers — the AI chooses the best visualization type (funnel, heatmap, radar chart, waterfall, timeline, architecture diagram, etc.) for the topic. Slide-based with animated transitions.
76
+
77
+ ```
78
+ /ember-publish-explainer how our CI/CD pipeline works
79
+ ```
40
80
 
41
- ## What gets published
81
+ ### `/ember-publish-space`
42
82
 
43
- - Live Mermaid diagrams with zoom, pan, and fullscreen
83
+ Publish a directory of markdown files as a multi-page docs site with sidebar navigation, collapsible sections, and SPA page transitions.
84
+
85
+ ```
86
+ /ember-publish-space the docs/ directory as API documentation
87
+ ```
88
+
89
+ ## What you get
90
+
91
+ - Interactive emberDiagrams with zoom, pan, and fullscreen
92
+ - Virtual-scroll dataset tables with sort, filter, and export
93
+ - JSON node-graph explorer with drag, collapse, and search
94
+ - Slide-based visual explainers with animated charts and timelines
95
+ - Multi-page Spaces with sidebar navigation
44
96
  - Syntax-highlighted code blocks (190+ languages)
45
97
  - Auto-generated table of contents
46
98
  - Per-block inline comments and discussions
47
- - Dark mode with font selection
48
- - Public or private docs with secret links
99
+ - Dark/light mode with font selection
100
+ - Public or private docs with link sharing
49
101
 
50
102
  ## Manual install
51
103
 
@@ -53,12 +105,19 @@ If you prefer not to use npx:
53
105
 
54
106
  ```bash
55
107
  git clone https://github.com/pmccurley87/emberflow-skills.git
56
- cp -r emberflow-skills/skills/ember-publish .claude/skills/
108
+
109
+ # Claude Code
110
+ cp -r emberflow-skills/skills/* .claude/skills/
111
+
112
+ # Cursor — copy as .mdc rules
113
+ # Windsurf — copy to .windsurf/rules/
114
+ # Codex — copy to .codex/
57
115
  ```
58
116
 
59
117
  ## Works with
60
118
 
61
- - Claude Code
62
- - Cursor
63
- - Codex CLI
64
- - Any tool that supports the SKILL.md format
119
+ - **Claude Code** — native skill system with `/slash-commands`
120
+ - **Cursor** — auto-attached rules via `.cursor/rules/`
121
+ - **Windsurf** — rules via `.windsurf/rules/`
122
+ - **Codex** agent instructions via `.codex/`
123
+ - **Any MCP-compatible tool** — Emberflow also provides an MCP server
package/bin/install.js CHANGED
@@ -7,7 +7,7 @@ const http = require('http');
7
7
  const readline = require('readline');
8
8
  const os = require('os');
9
9
 
10
- const SKILL_NAMES = ['ember-publish', 'ember-publish-doc', 'ember-publish-explainer', 'ember-publish-json', 'ember-publish-space'];
10
+ const SKILL_NAMES = ['ember-publish', 'ember-publish-doc', 'ember-publish-dataset', 'ember-publish-explainer', 'ember-publish-json', 'ember-publish-space'];
11
11
  const SKILLS_DIR = path.join(__dirname, '..', 'skills');
12
12
  const EMBERFLOW_URL = 'https://www.emberflow.ai';
13
13
  const TOKEN_PATH = path.join(os.homedir(), '.emberflow', 'token.json');
@@ -18,13 +18,38 @@ const green = (s) => `\x1b[32m${s}\x1b[0m`;
18
18
  const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
19
19
  const orange = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
20
20
 
21
- const targets = [
22
- { dir: '.claude/skills', label: 'Claude Code (project)' },
23
- { dir: '.cursor/skills', label: 'Cursor (project)' },
24
- ];
25
-
26
- const globalTargets = [
27
- { dir: path.join(os.homedir(), '.claude', 'skills'), label: 'Claude Code (global)' },
21
+ // ── Tool definitions ──
22
+
23
+ const TOOLS = [
24
+ {
25
+ type: 'claude',
26
+ detect: ['.claude'],
27
+ projectDir: '.claude/skills',
28
+ globalDir: path.join(os.homedir(), '.claude', 'skills'),
29
+ label: 'Claude Code',
30
+ usage: '/ember-publish',
31
+ },
32
+ {
33
+ type: 'cursor',
34
+ detect: ['.cursor', '.cursorrules'],
35
+ projectDir: '.cursor/rules',
36
+ label: 'Cursor',
37
+ usage: '"publish this to Emberflow"',
38
+ },
39
+ {
40
+ type: 'windsurf',
41
+ detect: ['.windsurf', '.windsurfrules'],
42
+ projectDir: '.windsurf/rules',
43
+ label: 'Windsurf',
44
+ usage: '"publish this to Emberflow"',
45
+ },
46
+ {
47
+ type: 'codex',
48
+ detect: ['.codex', 'AGENTS.md'],
49
+ projectDir: '.codex',
50
+ label: 'Codex',
51
+ usage: '"publish this to Emberflow"',
52
+ },
28
53
  ];
29
54
 
30
55
  const args = process.argv.slice(2);
@@ -77,7 +102,22 @@ function sleep(ms) {
77
102
  return new Promise((r) => setTimeout(r, ms));
78
103
  }
79
104
 
80
- // ── Skill installer ──
105
+ // ── SKILL.md parsing ──
106
+
107
+ function parseFrontmatter(content) {
108
+ const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
109
+ if (!match) return { meta: {}, body: content };
110
+ const meta = {};
111
+ for (const line of match[1].split('\n')) {
112
+ const idx = line.indexOf(':');
113
+ if (idx > 0) {
114
+ meta[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
115
+ }
116
+ }
117
+ return { meta, body: match[2] };
118
+ }
119
+
120
+ // ── File helpers ──
81
121
 
82
122
  function copyDirRecursive(src, dest) {
83
123
  fs.mkdirSync(dest, { recursive: true });
@@ -92,25 +132,147 @@ function copyDirRecursive(src, dest) {
92
132
  }
93
133
  }
94
134
 
95
- function install(destDir, label) {
135
+ function rewriteTemplatePaths(body, templatesRelPath) {
136
+ // Rewrite "Read templates/" to use the full relative path from project root
137
+ return body.replace(
138
+ /Read templates\//g,
139
+ `Read ${templatesRelPath}/`
140
+ );
141
+ }
142
+
143
+ // ── Installers per tool type ──
144
+
145
+ function installClaude(name, destDir) {
146
+ const srcDir = path.join(SKILLS_DIR, name);
147
+ const skillDir = path.join(destDir, name);
148
+ fs.mkdirSync(skillDir, { recursive: true });
149
+ fs.copyFileSync(path.join(srcDir, 'SKILL.md'), path.join(skillDir, 'SKILL.md'));
150
+
151
+ const templatesDir = path.join(srcDir, 'templates');
152
+ if (fs.existsSync(templatesDir)) {
153
+ copyDirRecursive(templatesDir, path.join(skillDir, 'templates'));
154
+ }
155
+ }
156
+
157
+ function installCursor(name, destDir, cwd) {
158
+ const srcDir = path.join(SKILLS_DIR, name);
159
+ const skillMd = fs.readFileSync(path.join(srcDir, 'SKILL.md'), 'utf8');
160
+ const { meta, body } = parseFrontmatter(skillMd);
161
+
162
+ // Copy templates to .cursor/rules/<name>/templates/
163
+ const templatesDir = path.join(srcDir, 'templates');
164
+ const hasTemplates = fs.existsSync(templatesDir);
165
+ if (hasTemplates) {
166
+ copyDirRecursive(templatesDir, path.join(destDir, name, 'templates'));
167
+ }
168
+
169
+ // Rewrite template paths relative to project root
170
+ let content = body;
171
+ if (hasTemplates) {
172
+ const relPath = path.relative(cwd, path.join(destDir, name, 'templates'));
173
+ content = rewriteTemplatePaths(content, relPath);
174
+ }
175
+
176
+ // Write .mdc file with Cursor frontmatter
177
+ const description = meta.description || name;
178
+ const hint = meta['argument-hint'] ? ` — ${meta['argument-hint']}` : '';
179
+ const mdc = `---
180
+ description: ${description}${hint}
181
+ globs:
182
+ alwaysApply: false
183
+ ---
184
+
185
+ ${content}`;
186
+
187
+ fs.mkdirSync(destDir, { recursive: true });
188
+ fs.writeFileSync(path.join(destDir, `${name}.mdc`), mdc);
189
+ }
190
+
191
+ function installWindsurf(name, destDir, cwd) {
192
+ const srcDir = path.join(SKILLS_DIR, name);
193
+ const skillMd = fs.readFileSync(path.join(srcDir, 'SKILL.md'), 'utf8');
194
+ const { body } = parseFrontmatter(skillMd);
195
+
196
+ // Copy templates
197
+ const templatesDir = path.join(srcDir, 'templates');
198
+ const hasTemplates = fs.existsSync(templatesDir);
199
+ if (hasTemplates) {
200
+ copyDirRecursive(templatesDir, path.join(destDir, name, 'templates'));
201
+ }
202
+
203
+ // Rewrite template paths
204
+ let content = body;
205
+ if (hasTemplates) {
206
+ const relPath = path.relative(cwd, path.join(destDir, name, 'templates'));
207
+ content = rewriteTemplatePaths(content, relPath);
208
+ }
209
+
210
+ fs.mkdirSync(destDir, { recursive: true });
211
+ fs.writeFileSync(path.join(destDir, `${name}.md`), content);
212
+ }
213
+
214
+ function installCodex(name, destDir, cwd) {
215
+ const srcDir = path.join(SKILLS_DIR, name);
216
+ const skillMd = fs.readFileSync(path.join(srcDir, 'SKILL.md'), 'utf8');
217
+ const { meta, body } = parseFrontmatter(skillMd);
218
+
219
+ // Copy templates
220
+ const templatesDir = path.join(srcDir, 'templates');
221
+ const hasTemplates = fs.existsSync(templatesDir);
222
+ if (hasTemplates) {
223
+ copyDirRecursive(templatesDir, path.join(destDir, name, 'templates'));
224
+ }
225
+
226
+ // Rewrite template paths
227
+ let content = body;
228
+ if (hasTemplates) {
229
+ const relPath = path.relative(cwd, path.join(destDir, name, 'templates'));
230
+ content = rewriteTemplatePaths(content, relPath);
231
+ }
232
+
233
+ fs.mkdirSync(destDir, { recursive: true });
234
+ fs.writeFileSync(path.join(destDir, `${name}.md`), content);
235
+ }
236
+
237
+ // ── Main install function ──
238
+
239
+ function install(destDir, tool, cwd) {
96
240
  for (const name of SKILL_NAMES) {
97
- const srcDir = path.join(SKILLS_DIR, name);
98
- const skillDir = path.join(destDir, name);
99
- const destFile = path.join(skillDir, 'SKILL.md');
100
- fs.mkdirSync(skillDir, { recursive: true });
101
- fs.copyFileSync(path.join(srcDir, 'SKILL.md'), destFile);
102
-
103
- // Copy templates directory if it exists
104
- const templatesDir = path.join(srcDir, 'templates');
105
- if (fs.existsSync(templatesDir)) {
106
- copyDirRecursive(templatesDir, path.join(skillDir, 'templates'));
241
+ switch (tool.type) {
242
+ case 'claude':
243
+ installClaude(name, destDir);
244
+ break;
245
+ case 'cursor':
246
+ installCursor(name, destDir, cwd);
247
+ break;
248
+ case 'windsurf':
249
+ installWindsurf(name, destDir, cwd);
250
+ break;
251
+ case 'codex':
252
+ installCodex(name, destDir, cwd);
253
+ break;
107
254
  }
108
-
109
- console.log(` ${green('✓')} Installed ${name} to ${path.relative(process.cwd(), skillDir) || skillDir} ${dim(`(${label})`)}`);
255
+ const relDest = path.relative(cwd, destDir) || destDir;
256
+ console.log(` ${green('✓')} ${name} ${dim(relDest)} ${dim(`(${tool.label})`)}`);
110
257
  }
111
258
  return true;
112
259
  }
113
260
 
261
+ // ── Detection ──
262
+
263
+ function detectTools(cwd) {
264
+ const detected = [];
265
+ for (const tool of TOOLS) {
266
+ for (const marker of tool.detect) {
267
+ if (fs.existsSync(path.join(cwd, marker))) {
268
+ detected.push(tool);
269
+ break;
270
+ }
271
+ }
272
+ }
273
+ return detected;
274
+ }
275
+
114
276
  // ── Auth flow ──
115
277
 
116
278
  function hasValidToken() {
@@ -128,7 +290,6 @@ async function authenticate() {
128
290
  console.log(` ${dim('Your published docs will be attributed to your account.')}`);
129
291
  console.log();
130
292
 
131
- // Request device code
132
293
  let resp;
133
294
  try {
134
295
  resp = await request('POST', `${EMBERFLOW_URL}/api/device-code`);
@@ -151,7 +312,6 @@ async function authenticate() {
151
312
  console.log(` Your code: ${bold(code)}`);
152
313
  console.log();
153
314
 
154
- // Try to open the URL automatically
155
315
  try {
156
316
  const { exec } = require('child_process');
157
317
  if (process.platform === 'win32') {
@@ -165,8 +325,7 @@ async function authenticate() {
165
325
 
166
326
  process.stdout.write(` ${dim('Waiting for approval...')}`);
167
327
 
168
- // Poll for approval
169
- const maxAttempts = 60; // 3 minutes at 3s intervals
328
+ const maxAttempts = 60;
170
329
  for (let i = 0; i < maxAttempts; i++) {
171
330
  await sleep(3000);
172
331
 
@@ -174,7 +333,6 @@ async function authenticate() {
174
333
  const status = await request('GET', `${EMBERFLOW_URL}/api/device-code/${code}`);
175
334
 
176
335
  if (status.data.status === 'approved' && status.data.session_token) {
177
- // Strip cookie name prefix if present (e.g. "__Secure-better-auth.session_token=VALUE" -> "VALUE")
178
336
  const raw = status.data.session_token.replace(/^(?:__Secure-)?better-auth\.session_token=/, '');
179
337
  fs.mkdirSync(path.dirname(TOKEN_PATH), { recursive: true });
180
338
  fs.writeFileSync(TOKEN_PATH, JSON.stringify({ token: raw }, null, 2));
@@ -194,7 +352,6 @@ async function authenticate() {
194
352
  // Network error, keep polling
195
353
  }
196
354
 
197
- // Spinner
198
355
  const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
199
356
  process.stdout.clearLine(0);
200
357
  process.stdout.cursorTo(0);
@@ -217,38 +374,48 @@ async function main() {
217
374
  console.log();
218
375
 
219
376
  if (!authOnly) {
377
+ const cwd = process.cwd();
220
378
  let installed = 0;
221
379
 
222
380
  if (isGlobal) {
223
- for (const t of globalTargets) {
224
- install(t.dir, t.label);
225
- installed++;
226
- }
381
+ // Global install Claude Code only
382
+ const claudeTool = TOOLS[0];
383
+ install(claudeTool.globalDir, claudeTool, cwd);
384
+ installed++;
227
385
  } else {
228
- const cwd = process.cwd();
229
- const detected = [];
230
-
231
- for (const t of targets) {
232
- const parent = path.dirname(path.join(cwd, t.dir));
233
- if (fs.existsSync(path.join(cwd, t.dir)) || fs.existsSync(parent)) {
234
- detected.push(t);
235
- }
236
- }
386
+ const detected = detectTools(cwd);
237
387
 
238
388
  if (detected.length === 0) {
239
- detected.push(targets[0]);
389
+ // Default to Claude Code
390
+ detected.push(TOOLS[0]);
391
+ console.log(` ${dim('No tool config detected — defaulting to Claude Code')}`);
392
+ console.log();
393
+ } else {
394
+ console.log(` ${dim(`Detected: ${detected.map(t => t.label).join(', ')}`)}`);
395
+ console.log();
240
396
  }
241
397
 
242
- for (const t of detected) {
243
- install(path.join(cwd, t.dir), t.label);
398
+ for (const tool of detected) {
399
+ const destDir = path.join(cwd, tool.projectDir);
400
+ install(destDir, tool, cwd);
244
401
  installed++;
402
+ console.log();
245
403
  }
246
404
  }
247
405
 
248
406
  if (installed > 0) {
249
- console.log();
250
- console.log(` Use: ${cyan('/ember-publish')} ${dim('[topic]')} — auto-picks format (doc, JSON, Space, or explainer)`);
251
- console.log(` ${cyan('/ember-publish-doc')} ${dim('[topic]')} ${cyan('/ember-publish-json')} ${dim('[data]')} ${cyan('/ember-publish-space')} ${dim('[directory]')} ${cyan('/ember-publish-explainer')} ${dim('[topic]')}`);
407
+ const detected = isGlobal ? [TOOLS[0]] : (detectTools(cwd).length > 0 ? detectTools(cwd) : [TOOLS[0]]);
408
+ const claudeDetected = detected.some(t => t.type === 'claude');
409
+ const othersDetected = detected.filter(t => t.type !== 'claude');
410
+
411
+ if (claudeDetected) {
412
+ console.log(` ${bold('Claude Code:')} ${cyan('/ember-publish')} ${dim('[topic]')} — auto-picks format`);
413
+ console.log(` ${cyan('/ember-publish-doc')} ${dim('[topic]')} ${cyan('/ember-publish-json')} ${dim('[data]')} ${cyan('/ember-publish-explainer')} ${dim('[topic]')}`);
414
+ }
415
+ if (othersDetected.length > 0) {
416
+ const labels = othersDetected.map(t => t.label).join(', ');
417
+ console.log(` ${bold(`${labels}:`)} Just ask ${cyan('"publish this to Emberflow"')} or ${cyan('"create an Emberflow explainer about X"')}`);
418
+ }
252
419
  }
253
420
  }
254
421
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "emberflow-skills",
3
- "version": "1.10.1",
3
+ "version": "1.12.0",
4
4
  "description": "Install Emberflow skills for AI coding tools",
5
5
  "bin": {
6
6
  "emberflow-skills": "./bin/install.js"
@@ -0,0 +1,134 @@
1
+ ---
2
+ name: ember-publish-dataset
3
+ description: Publish one or more CSV files to Emberflow as an interactive dataset viewer with multi-table tabs, search, sort, filter, pagination, and CSV export
4
+ argument-hint: [CSV file path(s) and a title]
5
+ ---
6
+
7
+ # Emberflow Dataset Publisher
8
+
9
+ Publish tabular CSV data to Emberflow at **https://emberflow.ai** as an interactive dataset viewer with:
10
+ - Multi-table tabs (one per CSV file)
11
+ - Cross-column search with highlighted matches
12
+ - Click-to-sort on any column (asc/desc)
13
+ - Column show/hide filter
14
+ - Paginated rows (100 per page)
15
+ - CSV export of filtered data
16
+
17
+ ## Step 1: Prepare your CSV files
18
+
19
+ Each CSV file becomes one tab in the viewer. The first row is treated as column headers. Example:
20
+
21
+ ```
22
+ Region,Q1,Q2,Total
23
+ North America,142000,158000,300000
24
+ Europe,98000,112000,210000
25
+ ```
26
+
27
+ ## Step 2: Authenticate (if needed)
28
+
29
+ Session tokens are stored at `~/.emberflow/token.json`. Check if a valid session exists:
30
+
31
+ ```bash
32
+ cat ~/.emberflow/token.json 2>/dev/null
33
+ ```
34
+
35
+ If no session exists or the token is expired, authenticate using the device flow:
36
+
37
+ ```bash
38
+ EMBERFLOW_URL="https://emberflow.ai"
39
+
40
+ # Step 1: Request a device code
41
+ RESP=$(curl -s -X POST "$EMBERFLOW_URL/api/device-code")
42
+ CODE=$(echo "$RESP" | jq -r .code)
43
+ URL=$(echo "$RESP" | jq -r .verification_url)
44
+ echo "Open in browser: $URL"
45
+ ```
46
+
47
+ Tell the user to open the URL and sign in. Then poll until approved:
48
+
49
+ ```bash
50
+ # Step 2: Poll until approved (every 3s)
51
+ while true; do
52
+ STATUS=$(curl -s "$EMBERFLOW_URL/api/device-code/$CODE")
53
+ S=$(echo "$STATUS" | jq -r .status)
54
+ if [ "$S" = "approved" ]; then
55
+ TOKEN=$(echo "$STATUS" | jq -r .session_token)
56
+ mkdir -p ~/.emberflow
57
+ echo "{\"token\":\"$TOKEN\"}" > ~/.emberflow/token.json
58
+ break
59
+ fi
60
+ if [ "$S" = "expired" ]; then echo "Code expired. Try again."; break; fi
61
+ sleep 3
62
+ done
63
+ ```
64
+
65
+ ## Step 3: Build and publish the dataset
66
+
67
+ Parse each CSV and build the dataset JSON, then POST with `content_type: "dataset"`.
68
+
69
+ ### Data format
70
+
71
+ ```json
72
+ {
73
+ "tables": [
74
+ {
75
+ "name": "Sales by Region",
76
+ "columns": ["Region", "Q1", "Q2", "Total"],
77
+ "rows": [
78
+ ["North America", 142000, 158000, 300000],
79
+ ["Europe", 98000, 112000, 210000]
80
+ ]
81
+ }
82
+ ]
83
+ }
84
+ ```
85
+
86
+ - `columns`: array of header strings
87
+ - `rows`: array of arrays — numeric values as numbers, strings as strings
88
+ - Multiple tables → multiple tabs in the viewer
89
+
90
+ ### Publish with curl
91
+
92
+ ```bash
93
+ EMBERFLOW_URL="https://emberflow.ai"
94
+ TITLE="My Dataset"
95
+ SLUG=$(echo "$TITLE" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-//;s/-$//')
96
+ TOKEN=$(jq -r .token ~/.emberflow/token.json)
97
+
98
+ # Build dataset JSON from one CSV file (requires python3 or similar)
99
+ CONTENT=$(python3 -c "
100
+ import csv, json, sys
101
+ with open('data.csv') as f:
102
+ rows = list(csv.reader(f))
103
+ cols = rows[0]
104
+ data = []
105
+ for r in rows[1:]:
106
+ row = []
107
+ for v in r:
108
+ try: row.append(float(v) if '.' in v else int(v))
109
+ except: row.append(v)
110
+ data.append(row)
111
+ print(json.dumps({'tables': [{'name': 'Data', 'columns': cols, 'rows': data}]}))
112
+ ")
113
+
114
+ curl -s -X POST "$EMBERFLOW_URL/api/docs" \
115
+ -H 'Content-Type: application/json' \
116
+ -H "Authorization: Bearer $TOKEN" \
117
+ -d "$(jq -n --arg slug "$SLUG" --arg title "$TITLE" --arg content "$CONTENT" \
118
+ '{slug: $slug, title: $title, content: $content, content_type: "dataset", visibility: "public"}')"
119
+ ```
120
+
121
+ The response JSON includes a `url` field. The dataset is viewable at:
122
+ - `https://emberflow.ai/d/<shortId>/<slug>`
123
+
124
+ To **update** an existing dataset, publish again with the same slug — the API upserts for the same author.
125
+
126
+ ### Other operations
127
+
128
+ ```bash
129
+ # List all your documents
130
+ curl -s -H "Authorization: Bearer $TOKEN" "$EMBERFLOW_URL/api/docs"
131
+
132
+ # Delete a dataset
133
+ curl -s -X DELETE -H "Authorization: Bearer $TOKEN" "$EMBERFLOW_URL/api/docs/SLUG_HERE"
134
+ ```
@@ -10,6 +10,8 @@ Generate a **structured JSON** explainer that the Emberflow platform renders as
10
10
 
11
11
  You choose the best visualization type for each slide. A single explainer can mix different viz types across slides — a timeline on slide 2, a data table on slide 3, a chart on slide 4.
12
12
 
13
+ **Visual diversity is critical.** Every explainer should feel bespoke. Don't fall back on "grid of stat cards" or "list of bordered boxes" as the default — those are just one option among many. Think about what shape the data actually has: is it a flow? Use a Sankey or funnel. A ranking? Use a horizontal bar chart or podium layout. A distribution? Use a heatmap or scatter. A before/after? Use a split comparison. A hierarchy? Use a tree or nested rings. A process? Use a swimlane or animated pipeline. Match the visualization to the shape of the information, not the other way around.
14
+
13
15
  ## Output
14
16
 
15
17
  Write a single JSON file to `{topic-slug}-explainer.json` in the current working directory. Then publish it to Emberflow.
@@ -163,32 +165,47 @@ No `script` field is needed for diagram slides — the platform handles all anim
163
165
 
164
166
  ## B. Visualization Primitive Catalog
165
167
 
166
- Choose from these primitives. Compose them freely combine, nest, or invent new ones as the topic demands.
168
+ This is a **starting point, not a menu to order from**. The best explainers invent visualizations that fit the content perfectly. Combine, adapt, or create entirely new primitives. If none of these fit — build something original.
167
169
 
168
170
  ### Data Display
169
171
 
170
- - **KPI / stat cards** — Grid of boxes with label, large value, subtitle. Use for overview slides. `display: grid; grid-template-columns: 1fr 1fr; gap: 12px`
171
- - **Data table** — `<table>` with muted uppercase headers, monospace numbers, color-coded values (green positive, red negative). Category dots on row labels.
172
- - **Comparison matrix** — Table with checkmark/cross SVG icons per cell. Column headers are options, rows are features. Active column highlighted with accent border.
172
+ - **KPI / stat cards** — Grid of boxes with label, large value, subtitle. Good for overview slides, but don't default to this for everything.
173
+ - **Data table** — `<table>` with muted uppercase headers, monospace numbers, color-coded values. Category dots on row labels.
174
+ - **Comparison matrix** — Table with checkmark/cross SVG icons per cell. Active column highlighted with accent border.
175
+ - **Heatmap grid** — CSS grid where cell background intensity maps to a value. Use color gradients (green→yellow→red or cool→warm). Great for showing density, coverage, or correlation.
176
+ - **Scorecard** — Single large metric in a ring or gauge, with trend sparkline below. Different from KPI cards — more visual weight, fewer items.
173
177
 
174
178
  ### Charts
175
179
 
176
- - **Vertical bar chart** — Flex row of bars, height as percentage of max. Color-code by threshold (green/orange/red). Hover reveals value label. `transition: height 0.6s cubic-bezier(0.4, 0, 0.2, 1)`
177
- - **Horizontal bar chart** — Rows with label left, bar extending right. Good for ranked lists. Bar width as percentage via `flex` layout.
178
- - **Donut / ring chart** — SVG circle with `stroke-dasharray`/`stroke-dashoffset`. Percentage label centered absolutely.
179
- - **Funnel diagram** — Stacked horizontal bars decreasing in width, centered. Labels and conversion percentages on each stage.
180
+ - **Vertical bar chart** — Flex row of bars, height as percentage of max. Color-code by threshold. Animate height on entrance.
181
+ - **Horizontal bar chart** — Rows with label left, bar extending right. Good for ranked lists, leaderboards, survey results.
182
+ - **Donut / ring chart** — SVG circle with `stroke-dasharray`/`stroke-dashoffset`. Percentage label centered.
183
+ - **Funnel diagram** — Stacked horizontal bars decreasing in width, centered. Show conversion rates between stages.
184
+ - **Waterfall chart** — Bars that float from the previous bar's end. Show how values build up or break down (budget additions/subtractions, funnel drop-offs).
185
+ - **Sparklines / mini line charts** — SVG polylines inside cards or rows. Show trends without axes. Great for time-series context within other layouts.
186
+ - **Stacked bar / segmented bar** — Single horizontal bar divided into colored segments. Show composition at a glance (e.g., traffic sources, time allocation).
187
+ - **Scatter / bubble plot** — SVG circles positioned by x/y data. Size encodes a third dimension. Good for correlation or distribution.
188
+
189
+ ### Flows & Processes
190
+
191
+ - **Sankey diagram** — SVG paths flowing from left to right with varying widths. Show how quantities split and merge across stages (budget allocation, user flow, data pipeline).
192
+ - **Swimlane diagram** — Horizontal lanes (rows) representing actors/systems. Steps flow left-to-right across lanes showing handoffs. Great for cross-team processes.
193
+ - **Animated pipeline** — Horizontal or vertical sequence of stages with animated dots/particles moving through. Show data or request flow in real-time feel.
194
+ - **Decision tree** — Binary branching layout. Each node is a question, branches lead to outcomes. Clickable to explore paths.
180
195
 
181
196
  ### Timelines & Sequences
182
197
 
183
198
  - **Vertical timeline** — Left border line with dot markers. Each event has date, title, description, optional progress bar and status badge.
184
199
  - **Horizontal timeline** — Flex row of connected nodes along a horizontal line. Good for fewer items (3-6).
185
200
  - **Progress stepper** — Numbered circles connected by lines. Active step highlighted, completed steps filled.
201
+ - **Gantt-style chart** — Rows of horizontal bars on a time axis. Show overlapping phases, dependencies, or parallel workstreams.
202
+ - **Spiral timeline** — Events arranged along a spiral path (SVG). Unusual and memorable for cyclical or long-spanning histories.
186
203
 
187
204
  ### Relationships & Structure (Auto-Layout Diagrams)
188
205
 
189
206
  For any node-and-edge visualization, use a **declarative diagram object** as the `viz` field. The platform auto-positions nodes and routes edges — no manual coordinate math needed.
190
207
 
191
- - **Architecture diagram** — Use `viz: { nodes, edges, groups }` with color-coded nodes, meaningful icons, and labeled groups. The platform lays out each group as a horizontal row, stacks groups vertically, and draws cross-group edges as smooth beziers. See Section A → "Architecture diagrams".
208
+ - **Architecture diagram** — Use `viz: { nodes, edges, groups }` with color-coded nodes, meaningful icons, and labeled groups. See Section A → "Architecture diagrams".
192
209
  - **Flowchart** — Same declarative format with a single group or no groups. Set `direction: "TB"` for top-to-bottom flow.
193
210
  - **Org chart / hierarchy** — Use `direction: "TB"` and groups for departments. Color-code by role.
194
211
  - **Network / data flow** — Multiple groups connected by cross-group edges. Color edges by data type.
@@ -197,17 +214,27 @@ For any node-and-edge visualization, use a **declarative diagram object** as the
197
214
 
198
215
  - **Periodic table / grid** — CSS grid of cards with colored top bar per category. Hover shows detail.
199
216
  - **Kanban board** — Columns with card items. Cards can highlight or shift between columns per slide.
217
+ - **Tier list** — Labeled rows (S/A/B/C/F) with items placed in each tier. Color-coded by rank. Great for evaluations and comparisons.
200
218
 
201
219
  ### Status & Indicators
202
220
 
203
221
  - **Risk cards** — Stacked cards with severity SVG icon, title, description, colored severity badge.
204
222
  - **Stat delta** — Large number with up/down arrow SVG and percentage change.
223
+ - **Gauge / meter** — SVG arc filled to a percentage. More visual impact than a number alone.
205
224
  - **Utilization bars** — Rows with label, horizontal progress bar, percentage.
206
225
  - **Checklist** — Items with check/cross SVG icons. Grouped by category.
207
226
 
227
+ ### Comparisons & Layouts
228
+
229
+ - **Split comparison** — Two panels side-by-side (before/after, option A/option B). Highlight differences with color.
230
+ - **Podium / ranking** — Three items in a 2-1-3 podium arrangement (second, first, third). Great for top-3 results.
231
+ - **Concentric rings** — Nested circles or rings showing layers (e.g., onion architecture, security perimeters, impact radius).
232
+ - **Quadrant chart** — 2×2 grid with labeled axes. Place items as dots or cards in quadrants (e.g., effort/impact, urgency/importance).
233
+ - **Radar / spider chart** — SVG polygon on radial axes. Compare multiple dimensions of a single entity or overlay two entities.
234
+
208
235
  ### Inventing New Primitives
209
236
 
210
- The catalog above is a starting point. If the topic calls for a visualization not listed, invent one. Combine primitives freely. The only constraints are the design tokens and the slide-based interaction model.
237
+ The catalog above is a starting point. **The best explainers often use visualizations not on this list.** If the topic calls for something new — a custom game board, an animated state machine, a nested ring diagram, a radial burst — build it. The only constraints are the design tokens and the slide-based interaction model.
211
238
 
212
239
  ---
213
240
 
@@ -265,6 +292,8 @@ Before writing any code, plan 4-7 slides:
265
292
  2. **Slides 2-6** = Each focuses on one concept or subset. Zoom in, highlight, or switch viz type.
266
293
  3. **Last slide** (optional) = Summary or call-to-action.
267
294
 
295
+ **Vary the viz type across slides.** If slide 1 uses a diagram, slide 2 should use something different (a chart, timeline, table, etc.). Repeating the same viz structure on every slide makes the explainer feel static and monotonous. Each slide transition should feel like a new lens on the topic.
296
+
268
297
  For each slide, define:
269
298
  - **label**: Short uppercase label
270
299
  - **title**: Heading text
@@ -389,14 +418,16 @@ Or use the Emberflow MCP/CLI to publish with `content_type: 'explainer'`.
389
418
 
390
419
  ## H. Reference Templates
391
420
 
392
- Before generating, read the template that best matches your planned visualization:
421
+ Read **one** template before generating to understand the JSON structure, field conventions, and script patterns:
393
422
 
394
423
  ```
395
- Read templates/architecture-explainer.json for a flowchart example (SVG nodes + edges, show/active/dimmed states, staggered entrance).
424
+ Read templates/diverse-viz-explainer.json RECOMMENDED: showcases 5 different viz types across slides (funnel, heatmap, radar chart, waterfall, gauges). Start here if you're unsure which template to use.
425
+
426
+ Read templates/architecture-explainer.json — declarative diagram example (nodes + edges, active/dimmed states, staggered entrance).
396
427
 
397
- Read templates/project-overview-explainer.json for a mixed-viz example (KPIs, timeline, budget table, donut ring, risk cards — different viz type per slide).
428
+ Read templates/project-overview-explainer.json mixed-viz example (KPIs, timeline, budget table, donut ring, risk cards — different viz type per slide).
398
429
 
399
- Read templates/dashboard-explainer.json for a data viz example (animated bar chart, color-coded values, dataset switching per slide, insight callouts).
430
+ Read templates/dashboard-explainer.json data viz example (animated bar chart, color-coded values, dataset switching, insight callouts).
400
431
  ```
401
432
 
402
- Follow the patterns in the template closely. The templates are the ground truth for JSON structure, CSS conventions, and script patterns.
433
+ Use the templates for **structural reference only** — JSON shape, field naming, script scoping, and animation timing. Do NOT copy their visual style. Your explainer should look distinct from every template. Choose visualization types that match your specific content, not the template's content.
@@ -0,0 +1,40 @@
1
+ {
2
+ "css": ".fn { display: flex; flex-direction: column; gap: 6px; width: 100%; }\n.fn-stage { display: flex; align-items: center; gap: 12px; opacity: 0; transform: translateX(-12px); transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1); }\n.fn-stage.visible { opacity: 1; transform: translateX(0); }\n.fn-bar { height: 38px; border-radius: 6px; width: 0; transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1); flex-shrink: 0; }\n.fn-info { min-width: 90px; }\n.fn-name { font-size: 13px; font-weight: 600; color: var(--text); display: block; }\n.fn-sub { font-size: 11px; color: var(--text-muted); }\n.fn-rate { font-size: 12px; font-weight: 600; margin-left: auto; padding: 2px 10px; border-radius: 12px; white-space: nowrap; }\n.fn-drop { font-size: 10px; color: var(--text-muted); text-align: center; padding: 2px 0; opacity: 0; transition: opacity 0.4s; }\n.fn-drop.visible { opacity: 1; }\n\n.hm { width: 100%; }\n.hm-grid { display: flex; flex-direction: column; gap: 3px; }\n.hm-row { display: grid; grid-template-columns: 44px repeat(7, 1fr); gap: 3px; }\n.hm-label { font-size: 10px; color: var(--text-muted); display: flex; align-items: center; }\n.hm-head { font-size: 10px; color: var(--text-muted); text-align: center; font-weight: 600; text-transform: uppercase; }\n.hm-cell { aspect-ratio: 1.6; border-radius: 3px; opacity: 0; transition: opacity 0.3s; cursor: pointer; position: relative; }\n.hm-cell.visible { opacity: 1; }\n.hm-cell:hover { outline: 1px solid var(--text-muted); }\n.hm-cell:hover::after { content: attr(data-val); position: absolute; top: -20px; left: 50%; transform: translateX(-50%); font-size: 9px; color: var(--text); background: var(--bg); padding: 1px 5px; border-radius: 3px; border: 1px solid var(--border); white-space: nowrap; z-index: 1; }\n.hm-scale { display: flex; align-items: center; gap: 6px; margin-top: 8px; justify-content: flex-end; }\n.hm-scale-bar { width: 80px; height: 8px; border-radius: 4px; background: linear-gradient(90deg, rgba(234,88,12,0.05), rgba(234,88,12,0.9)); }\n.hm-scale span { font-size: 9px; color: var(--text-muted); }\n\n.rd { width: 100%; display: flex; flex-direction: column; align-items: center; }\n.rd-chart { position: relative; }\n.rd-axis { stroke: var(--border); stroke-width: 1; }\n.rd-ring { fill: none; stroke: var(--border); stroke-width: 0.5; stroke-dasharray: 4 4; }\n.rd-poly { fill-opacity: 0; stroke-width: 2; stroke-dasharray: 500; stroke-dashoffset: 500; transition: stroke-dashoffset 1.2s cubic-bezier(0.4, 0, 0.2, 1), fill-opacity 0.6s 0.8s; }\n.rd-poly.visible { stroke-dashoffset: 0; fill-opacity: 0.12; }\n.rd-poly.cur { stroke: #ea580c; fill: #ea580c; }\n.rd-poly.prev { stroke: #3b82f6; fill: #3b82f6; }\n.rd-dot { r: 3; fill: var(--bg); stroke-width: 2; opacity: 0; transition: opacity 0.3s 1s; }\n.rd-dot.visible { opacity: 1; }\n.rd-dot.cur { stroke: #ea580c; }\n.rd-dot.prev { stroke: #3b82f6; }\n.rd-lbl { font-size: 10px; fill: var(--text-muted); text-anchor: middle; }\n.rd-legend { display: flex; gap: 20px; justify-content: center; margin-top: 16px; font-size: 12px; color: var(--text-muted); }\n.rd-legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-right: 6px; vertical-align: middle; }\n\n.wf { width: 100%; }\n.wf-chart { display: flex; align-items: flex-end; gap: 4px; height: 240px; padding-bottom: 24px; position: relative; }\n.wf-chart::before { content: ''; position: absolute; left: 0; right: 0; bottom: 24px; height: 1px; background: var(--border); }\n.wf-col { flex: 1; position: relative; height: 100%; display: flex; flex-direction: column; justify-content: flex-end; align-items: center; }\n.wf-block { width: 65%; border-radius: 3px; position: relative; opacity: 0; transform: scaleY(0); transform-origin: bottom; transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1); }\n.wf-block.visible { opacity: 1; transform: scaleY(1); }\n.wf-block.add { background: #22c55e; }\n.wf-block.sub { background: #ef4444; transform-origin: top; }\n.wf-block.total { background: var(--link); }\n.wf-val { font-size: 10px; font-weight: 600; color: var(--text); text-align: center; margin-bottom: 4px; opacity: 0; transition: opacity 0.3s 0.4s; white-space: nowrap; }\n.wf-val.visible { opacity: 1; }\n.wf-lbl { font-size: 9px; color: var(--text-muted); text-align: center; white-space: nowrap; position: absolute; bottom: -18px; }\n.wf-connector { position: absolute; right: -4px; width: 8px; border-top: 1px dashed var(--text-muted); opacity: 0.4; }\n\n.gg-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; width: 100%; max-width: 360px; margin: 0 auto; }\n.gg-item { display: flex; flex-direction: column; align-items: center; gap: 6px; opacity: 0; transform: scale(0.9); transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1); }\n.gg-item.visible { opacity: 1; transform: scale(1); }\n.gg-track { fill: none; stroke: var(--border); stroke-width: 10; stroke-linecap: round; }\n.gg-fill { fill: none; stroke-width: 10; stroke-linecap: round; transition: stroke-dashoffset 1.2s cubic-bezier(0.4, 0, 0.2, 1); }\n.gg-val { font-size: 18px; font-weight: 700; text-anchor: middle; fill: var(--text); }\n.gg-lbl { font-size: 12px; color: var(--text-muted); text-align: center; font-weight: 600; }\n.gg-sub { font-size: 10px; color: var(--text-muted); text-align: center; }",
3
+ "slides": [
4
+ {
5
+ "label": "Funnel",
6
+ "title": "Signup Conversion Funnel",
7
+ "prose": "<p>The signup funnel tracks users from first visit through to paid conversion. Each stage represents a meaningful commitment — from anonymous browsing to entering payment details.</p><p>The <strong>activation step</strong> (completing onboarding) is the biggest single drop-off at 44% loss. Users who activate convert to paid at a strong 36% rate, suggesting the product delivers value once users get past initial setup.</p><p>Overall visitor-to-paid conversion sits at <strong>6.5%</strong>, competitive for a self-serve SaaS product.</p>",
8
+ "viz": "<div class=\"fn\"><div class=\"fn-stage\"><div class=\"fn-bar\" style=\"background: #3b82f6;\" data-w=\"100%\"></div><div class=\"fn-info\"><span class=\"fn-name\">Visitors</span><span class=\"fn-sub\">24,500 unique</span></div><span class=\"fn-rate\" style=\"background: rgba(59,130,246,0.12); color: #3b82f6;\">100%</span></div><div class=\"fn-drop\" data-text=\"\u2193 68% drop-off\"></div><div class=\"fn-stage\"><div class=\"fn-bar\" style=\"background: #8b5cf6;\" data-w=\"32%\"></div><div class=\"fn-info\"><span class=\"fn-name\">Signups</span><span class=\"fn-sub\">7,840 accounts</span></div><span class=\"fn-rate\" style=\"background: rgba(139,92,246,0.12); color: #8b5cf6;\">32%</span></div><div class=\"fn-drop\" data-text=\"\u2193 44% drop-off\"></div><div class=\"fn-stage\"><div class=\"fn-bar\" style=\"background: #ea580c;\" data-w=\"18%\"></div><div class=\"fn-info\"><span class=\"fn-name\">Activated</span><span class=\"fn-sub\">4,410 onboarded</span></div><span class=\"fn-rate\" style=\"background: rgba(234,88,12,0.12); color: #ea580c;\">18%</span></div><div class=\"fn-drop\" data-text=\"\u2193 64% drop-off\"></div><div class=\"fn-stage\"><div class=\"fn-bar\" style=\"background: #22c55e;\" data-w=\"6.5%\"></div><div class=\"fn-info\"><span class=\"fn-name\">Paying</span><span class=\"fn-sub\">1,590 customers</span></div><span class=\"fn-rate\" style=\"background: rgba(34,197,94,0.12); color: #22c55e;\">6.5%</span></div></div>",
9
+ "script": "var stages = container.querySelectorAll('.fn-stage');\nvar drops = container.querySelectorAll('.fn-drop');\nstages.forEach(function(s, i) {\n setTimeout(function() {\n s.classList.add('visible');\n var bar = s.querySelector('.fn-bar');\n bar.style.width = bar.getAttribute('data-w');\n if (i > 0 && drops[i - 1]) {\n drops[i - 1].textContent = drops[i - 1].getAttribute('data-text');\n drops[i - 1].classList.add('visible');\n }\n }, 200 + i * 180);\n});"
10
+ },
11
+ {
12
+ "label": "Activity",
13
+ "title": "Signup Heatmap",
14
+ "prose": "<p>Signups cluster heavily around weekday lunchtimes and early evenings. <strong>Tuesday 12\u2013\u20131pm</strong> is the single hottest slot, likely driven by users discovering the product during work breaks.</p><p>Weekend activity is uniformly low, suggesting a B2B-leaning user base. Late-night signups (10pm+) are near zero across all days.</p><p>This pattern informs ad scheduling — concentrating spend on weekday 11am\u20132pm captures peak intent with minimal waste.</p>",
15
+ "viz": "<div class=\"hm\"><div class=\"hm-grid\"><div class=\"hm-row\"><span></span><span class=\"hm-head\">Mon</span><span class=\"hm-head\">Tue</span><span class=\"hm-head\">Wed</span><span class=\"hm-head\">Thu</span><span class=\"hm-head\">Fri</span><span class=\"hm-head\">Sat</span><span class=\"hm-head\">Sun</span></div><div class=\"hm-row\"><span class=\"hm-label\">6\u2013\u20139am</span><span class=\"hm-cell\" data-val=\"42\" style=\"background:rgba(234,88,12,0.12)\"></span><span class=\"hm-cell\" data-val=\"38\" style=\"background:rgba(234,88,12,0.10)\"></span><span class=\"hm-cell\" data-val=\"45\" style=\"background:rgba(234,88,12,0.13)\"></span><span class=\"hm-cell\" data-val=\"40\" style=\"background:rgba(234,88,12,0.11)\"></span><span class=\"hm-cell\" data-val=\"35\" style=\"background:rgba(234,88,12,0.09)\"></span><span class=\"hm-cell\" data-val=\"12\" style=\"background:rgba(234,88,12,0.03)\"></span><span class=\"hm-cell\" data-val=\"8\" style=\"background:rgba(234,88,12,0.02)\"></span></div><div class=\"hm-row\"><span class=\"hm-label\">9\u2013\u201312pm</span><span class=\"hm-cell\" data-val=\"120\" style=\"background:rgba(234,88,12,0.45)\"></span><span class=\"hm-cell\" data-val=\"135\" style=\"background:rgba(234,88,12,0.52)\"></span><span class=\"hm-cell\" data-val=\"118\" style=\"background:rgba(234,88,12,0.44)\"></span><span class=\"hm-cell\" data-val=\"125\" style=\"background:rgba(234,88,12,0.48)\"></span><span class=\"hm-cell\" data-val=\"110\" style=\"background:rgba(234,88,12,0.40)\"></span><span class=\"hm-cell\" data-val=\"28\" style=\"background:rgba(234,88,12,0.07)\"></span><span class=\"hm-cell\" data-val=\"20\" style=\"background:rgba(234,88,12,0.05)\"></span></div><div class=\"hm-row\"><span class=\"hm-label\">12\u2013\u20133pm</span><span class=\"hm-cell\" data-val=\"180\" style=\"background:rgba(234,88,12,0.72)\"></span><span class=\"hm-cell\" data-val=\"210\" style=\"background:rgba(234,88,12,0.90)\"></span><span class=\"hm-cell\" data-val=\"175\" style=\"background:rgba(234,88,12,0.70)\"></span><span class=\"hm-cell\" data-val=\"190\" style=\"background:rgba(234,88,12,0.78)\"></span><span class=\"hm-cell\" data-val=\"160\" style=\"background:rgba(234,88,12,0.62)\"></span><span class=\"hm-cell\" data-val=\"35\" style=\"background:rgba(234,88,12,0.09)\"></span><span class=\"hm-cell\" data-val=\"22\" style=\"background:rgba(234,88,12,0.06)\"></span></div><div class=\"hm-row\"><span class=\"hm-label\">3\u2013\u20136pm</span><span class=\"hm-cell\" data-val=\"145\" style=\"background:rgba(234,88,12,0.56)\"></span><span class=\"hm-cell\" data-val=\"155\" style=\"background:rgba(234,88,12,0.60)\"></span><span class=\"hm-cell\" data-val=\"140\" style=\"background:rgba(234,88,12,0.54)\"></span><span class=\"hm-cell\" data-val=\"150\" style=\"background:rgba(234,88,12,0.58)\"></span><span class=\"hm-cell\" data-val=\"130\" style=\"background:rgba(234,88,12,0.50)\"></span><span class=\"hm-cell\" data-val=\"30\" style=\"background:rgba(234,88,12,0.08)\"></span><span class=\"hm-cell\" data-val=\"18\" style=\"background:rgba(234,88,12,0.04)\"></span></div><div class=\"hm-row\"><span class=\"hm-label\">6\u2013\u201310pm</span><span class=\"hm-cell\" data-val=\"85\" style=\"background:rgba(234,88,12,0.28)\"></span><span class=\"hm-cell\" data-val=\"92\" style=\"background:rgba(234,88,12,0.32)\"></span><span class=\"hm-cell\" data-val=\"78\" style=\"background:rgba(234,88,12,0.25)\"></span><span class=\"hm-cell\" data-val=\"88\" style=\"background:rgba(234,88,12,0.30)\"></span><span class=\"hm-cell\" data-val=\"95\" style=\"background:rgba(234,88,12,0.34)\"></span><span class=\"hm-cell\" data-val=\"45\" style=\"background:rgba(234,88,12,0.13)\"></span><span class=\"hm-cell\" data-val=\"40\" style=\"background:rgba(234,88,12,0.11)\"></span></div></div><div class=\"hm-scale\"><span>Low</span><div class=\"hm-scale-bar\"></div><span>High</span></div></div>",
16
+ "script": "var cells = container.querySelectorAll('.hm-cell');\ncells.forEach(function(c, i) {\n setTimeout(function() {\n c.classList.add('visible');\n }, 50 + i * 20);\n});"
17
+ },
18
+ {
19
+ "label": "Channels",
20
+ "title": "Channel Quality Radar",
21
+ "prose": "<p>Not all acquisition channels are equal. The radar chart compares six dimensions across Q1 (orange) vs the previous quarter (blue).</p><p><strong>Organic search</strong> improved dramatically in volume and retention, driven by new SEO landing pages. <strong>Paid social</strong> scores high on volume but low on retention — users from Instagram ads churn 3x faster than organic users.</p><p>The ideal channel scores high on all axes. Organic search and referrals are closest to that ideal, while paid channels trade retention for volume.</p>",
22
+ "viz": "<div class=\"rd\"><div class=\"rd-chart\"><svg viewBox=\"0 0 300 300\" width=\"280\" height=\"280\"><g transform=\"translate(150,150)\"><circle class=\"rd-ring\" r=\"36\" /><circle class=\"rd-ring\" r=\"72\" /><circle class=\"rd-ring\" r=\"108\" /><line class=\"rd-axis\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"-115\" /><line class=\"rd-axis\" x1=\"0\" y1=\"0\" x2=\"99.6\" y2=\"-57.5\" /><line class=\"rd-axis\" x1=\"0\" y1=\"0\" x2=\"99.6\" y2=\"57.5\" /><line class=\"rd-axis\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"115\" /><line class=\"rd-axis\" x1=\"0\" y1=\"0\" x2=\"-99.6\" y2=\"57.5\" /><line class=\"rd-axis\" x1=\"0\" y1=\"0\" x2=\"-99.6\" y2=\"-57.5\" /><polygon class=\"rd-poly prev\" points=\"0,-65 69,-44 58,47 0,75 -43,52 -58,-37\" /><polygon class=\"rd-poly cur\" points=\"0,-97 76,-35 52,62 0,54 -86,50 -81,-29\" /><circle class=\"rd-dot prev\" cx=\"0\" cy=\"-65\" /><circle class=\"rd-dot prev\" cx=\"69\" cy=\"-44\" /><circle class=\"rd-dot prev\" cx=\"58\" cy=\"47\" /><circle class=\"rd-dot prev\" cx=\"0\" cy=\"75\" /><circle class=\"rd-dot prev\" cx=\"-43\" cy=\"52\" /><circle class=\"rd-dot prev\" cx=\"-58\" cy=\"-37\" /><circle class=\"rd-dot cur\" cx=\"0\" cy=\"-97\" /><circle class=\"rd-dot cur\" cx=\"76\" cy=\"-35\" /><circle class=\"rd-dot cur\" cx=\"52\" cy=\"62\" /><circle class=\"rd-dot cur\" cx=\"0\" cy=\"54\" /><circle class=\"rd-dot cur\" cx=\"-86\" cy=\"50\" /><circle class=\"rd-dot cur\" cx=\"-81\" cy=\"-29\" /><text class=\"rd-lbl\" x=\"0\" y=\"-122\">Volume</text><text class=\"rd-lbl\" x=\"115\" y=\"-57\">Speed</text><text class=\"rd-lbl\" x=\"115\" y=\"68\">Cost Eff.</text><text class=\"rd-lbl\" x=\"0\" y=\"132\">Retention</text><text class=\"rd-lbl\" x=\"-115\" y=\"68\">Quality</text><text class=\"rd-lbl\" x=\"-115\" y=\"-57\">LTV</text></g></svg></div><div class=\"rd-legend\"><span><span class=\"rd-legend-dot\" style=\"background:#ea580c\"></span>Q1 (current)</span><span><span class=\"rd-legend-dot\" style=\"background:#3b82f6\"></span>Q4 (previous)</span></div></div>",
23
+ "script": "var polys = container.querySelectorAll('.rd-poly');\nvar dots = container.querySelectorAll('.rd-dot');\npolys.forEach(function(p, i) {\n setTimeout(function() { p.classList.add('visible'); }, 200 + i * 300);\n});\ndots.forEach(function(d, i) {\n setTimeout(function() { d.classList.add('visible'); }, 400);\n});"
24
+ },
25
+ {
26
+ "label": "Revenue",
27
+ "title": "Revenue Waterfall",
28
+ "prose": "<p>Revenue grew from <strong>$180k</strong> base to <strong>$200k</strong> net — a healthy 11% increase. The waterfall breaks down each contributing factor.</p><p>New customer acquisition added <strong>$40k</strong>, and upsells from existing accounts contributed another <strong>$15k</strong>. These gains were partially offset by <strong>$25k</strong> in churn and <strong>$10k</strong> in downgrades.</p><p>The churn figure is the primary concern — it erodes 63% of new revenue. Reducing churn by even 20% would shift net growth from 11% to 14%.</p>",
29
+ "viz": "<div class=\"wf\"><div class=\"wf-chart\"><div class=\"wf-col\"><div class=\"wf-val\" data-v=\"$180k\"></div><div class=\"wf-block total\" style=\"height: 76.6%;\" data-h=\"76.6%\"></div><span class=\"wf-lbl\">Base</span></div><div class=\"wf-col\"><div class=\"wf-val\" data-v=\"+$40k\"></div><div class=\"wf-block add\" style=\"height: 17%; margin-bottom: 76.6%;\" data-h=\"17%\"></div><span class=\"wf-lbl\">New</span></div><div class=\"wf-col\"><div class=\"wf-val\" data-v=\"+$15k\"></div><div class=\"wf-block add\" style=\"height: 6.4%; margin-bottom: 93.6%;\" data-h=\"6.4%\"></div><span class=\"wf-lbl\">Upsell</span></div><div class=\"wf-col\"><div class=\"wf-val\" data-v=\"-$25k\"></div><div class=\"wf-block sub\" style=\"height: 10.6%; margin-bottom: 85.1%;\" data-h=\"10.6%\"></div><span class=\"wf-lbl\">Churn</span></div><div class=\"wf-col\"><div class=\"wf-val\" data-v=\"-$10k\"></div><div class=\"wf-block sub\" style=\"height: 4.3%; margin-bottom: 85.1%;\" data-h=\"4.3%\"></div><span class=\"wf-lbl\">Down</span></div><div class=\"wf-col\"><div class=\"wf-val\" data-v=\"$200k\"></div><div class=\"wf-block total\" style=\"height: 85.1%;\" data-h=\"85.1%\"></div><span class=\"wf-lbl\">Net</span></div></div></div>",
30
+ "script": "var blocks = container.querySelectorAll('.wf-block');\nvar vals = container.querySelectorAll('.wf-val');\nblocks.forEach(function(b, i) {\n var h = b.style.height;\n b.style.height = '0';\n setTimeout(function() {\n b.style.height = h;\n b.classList.add('visible');\n }, 150 + i * 120);\n});\nvals.forEach(function(v, i) {\n setTimeout(function() {\n v.textContent = v.getAttribute('data-v');\n v.classList.add('visible');\n }, 300 + i * 120);\n});"
31
+ },
32
+ {
33
+ "label": "Health",
34
+ "title": "System Health Gauges",
35
+ "prose": "<p>Platform health metrics show the infrastructure is holding up well under growth. <strong>Uptime</strong> at 99.7% includes two brief outages totaling 2.2 hours — both from database connection pool exhaustion during traffic spikes.</p><p><strong>Response time</strong> (P95) sits at 210ms, well within the 500ms target. <strong>Error rate</strong> is low at 0.3%, though upload-related 504s account for nearly half of all errors.</p><p><strong>Throughput headroom</strong> at 62% means the system can absorb roughly 1.6x current load before needing horizontal scaling.</p>",
36
+ "viz": "<div class=\"gg-grid\"><div class=\"gg-item\"><svg viewBox=\"0 0 120 75\" width=\"120\" height=\"75\"><path class=\"gg-track\" d=\"M 15 65 A 45 45 0 0 1 105 65\" /><path class=\"gg-fill\" d=\"M 15 65 A 45 45 0 0 1 105 65\" stroke=\"#22c55e\" data-pct=\"99.7\" style=\"stroke-dasharray: 141.4; stroke-dashoffset: 141.4;\" /><text class=\"gg-val\" x=\"60\" y=\"60\" data-v=\"99.7%\"></text></svg><div class=\"gg-lbl\">Uptime</div><div class=\"gg-sub\">Target: 99.9%</div></div><div class=\"gg-item\"><svg viewBox=\"0 0 120 75\" width=\"120\" height=\"75\"><path class=\"gg-track\" d=\"M 15 65 A 45 45 0 0 1 105 65\" /><path class=\"gg-fill\" d=\"M 15 65 A 45 45 0 0 1 105 65\" stroke=\"#22c55e\" data-pct=\"92\" style=\"stroke-dasharray: 141.4; stroke-dashoffset: 141.4;\" /><text class=\"gg-val\" x=\"60\" y=\"60\" data-v=\"210ms\"></text></svg><div class=\"gg-lbl\">P95 Latency</div><div class=\"gg-sub\">Target: &lt;500ms</div></div><div class=\"gg-item\"><svg viewBox=\"0 0 120 75\" width=\"120\" height=\"75\"><path class=\"gg-track\" d=\"M 15 65 A 45 45 0 0 1 105 65\" /><path class=\"gg-fill\" d=\"M 15 65 A 45 45 0 0 1 105 65\" stroke=\"#22c55e\" data-pct=\"97\" style=\"stroke-dasharray: 141.4; stroke-dashoffset: 141.4;\" /><text class=\"gg-val\" x=\"60\" y=\"60\" data-v=\"0.3%\"></text></svg><div class=\"gg-lbl\">Error Rate</div><div class=\"gg-sub\">Target: &lt;1%</div></div><div class=\"gg-item\"><svg viewBox=\"0 0 120 75\" width=\"120\" height=\"75\"><path class=\"gg-track\" d=\"M 15 65 A 45 45 0 0 1 105 65\" /><path class=\"gg-fill\" d=\"M 15 65 A 45 45 0 0 1 105 65\" stroke=\"#ea580c\" data-pct=\"62\" style=\"stroke-dasharray: 141.4; stroke-dashoffset: 141.4;\" /><text class=\"gg-val\" x=\"60\" y=\"60\" data-v=\"62%\"></text></svg><div class=\"gg-lbl\">Headroom</div><div class=\"gg-sub\">Scale at &lt;30%</div></div></div>",
37
+ "script": "var items = container.querySelectorAll('.gg-item');\nitems.forEach(function(item, i) {\n setTimeout(function() {\n item.classList.add('visible');\n var fill = item.querySelector('.gg-fill');\n var pct = parseFloat(fill.getAttribute('data-pct')) / 100;\n var totalLen = 141.4;\n fill.style.strokeDashoffset = totalLen * (1 - pct);\n var valEl = item.querySelector('.gg-val');\n valEl.textContent = valEl.getAttribute('data-v');\n }, 200 + i * 150);\n});"
38
+ }
39
+ ]
40
+ }