From 004c39aeb5345c03e82582e531927ae18326131b Mon Sep 17 00:00:00 2001 From: Min-Gul Kim Date: Wed, 15 Jul 2026 08:49:05 +0900 Subject: [PATCH] feat(recipes): Wave 4 Infograpify structural roles (14 recipes) Re-analyzed local 400-deck Infograpify library (reference --catalog + redacted deep dives). Shipped original recipes for remaining roles: mindmap, journey, PESTLE, RACI, balanced scorecard, hex cluster, puzzle, pillars, stairs, checklist, empathy map, risk heat matrix, circle segments, mission/vision split. 75 total builders. Compose keywords + reference family map updated. Example: python/examples/content.infograpify-roles.deck.json License-safe: no vendor pptx committed. --- .grok/skills/officecli-pptx-designmd/SKILL.md | 7 + CHANGELOG.md | 13 + docs/infograpify-reference.md | 33 +- docs/recipe-coverage-roadmap.md | 28 +- python/designmd_pptx/compose.py | 18 + python/designmd_pptx/recipes.py | 968 +++++++++++++++++- python/designmd_pptx/reference.py | 55 +- python/designmd_pptx/validate.py | 37 + .../content.infograpify-roles.deck.json | 129 +++ python/tests/test_domain_patterns.py | 7 +- python/tests/test_wave2_recipes.py | 4 +- python/tests/test_wave4_infograpify.py | 103 ++ skills/officecli-pptx-designmd/SKILL.md | 7 + 13 files changed, 1371 insertions(+), 38 deletions(-) create mode 100644 python/examples/content.infograpify-roles.deck.json create mode 100644 python/tests/test_wave4_infograpify.py diff --git a/.grok/skills/officecli-pptx-designmd/SKILL.md b/.grok/skills/officecli-pptx-designmd/SKILL.md index 141f543..df319eb 100644 --- a/.grok/skills/officecli-pptx-designmd/SKILL.md +++ b/.grok/skills/officecli-pptx-designmd/SKILL.md @@ -172,6 +172,13 @@ editorial). Prefer patterns first; freeform when NL or vision asks for re-layout **Wave 3 verticals:** `python/examples/content.{business,marketing,health,education,finance}.deck.json` — see `docs/recipe-coverage-roadmap.md` +**Wave 4 Infograpify roles:** **mindmap_branches** · **journey_stages** · +**pestle_grid** · **raci_matrix** · **scorecard_balanced** · **hex_cluster** · +**puzzle_pieces** · **pillar_columns** · **stairs_ascent** · **checklist_board** · +**empathy_map_quad** · **risk_heat_matrix** · **circle_segments** · +**mission_vision_split** — example: `python/examples/content.infograpify-roles.deck.json` +(analyze local packs with `reference` only; never commit vendor `.pptx`). + ### Animation (#40) Optional DESIGN.md frontmatter `animation: { enabled, entrance, transition, … }`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 56278a8..2228023 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to designmd-pptx are documented here. +## [Unreleased] — Wave 4 Infograpify structural roles + +### Added +- **14 recipes (Wave 4)** from re-analysis of local Infograpify 400-deck library + (filename + redacted structural scans only; no vendor bytes): + `mindmap_branches`, `journey_stages`, `pestle_grid`, `raci_matrix`, + `scorecard_balanced`, `hex_cluster`, `puzzle_pieces`, `pillar_columns`, + `stairs_ascent`, `checklist_board`, `empathy_map_quad`, `risk_heat_matrix`, + `circle_segments`, `mission_vision_split`. +- Example deck-spec: `python/examples/content.infograpify-roles.deck.json` +- Compose title keywords + `reference` family suggestions updated +- Catalog now **75** recipe builders (`WAVE4_SEQUENCE`) + ## [2.1.2] — 2026-07-15 Residual fixes after Codex **re-verify** of v2.1.1 (still flagged generative body loss, diff --git a/docs/infograpify-reference.md b/docs/infograpify-reference.md index 21c77d9..0ccd03e 100644 --- a/docs/infograpify-reference.md +++ b/docs/infograpify-reference.md @@ -62,20 +62,27 @@ licensed .pptx (local) Measured on a licensed local library of **400** decks (`infograpify_ppt_templates/`, not in git). Filename → family heuristic only: -| Family | Count | designmd today | Premium gap (#58) | +| Family | Count (2026-07 re-catalog) | designmd recipes | Status | |---|---:|---|---| -| other (long-tail verticals) | 273 | bullets / feature_cards | selective later | -| chart_story | 23 | chart_insight, big_number | `chart_callout_panel`, waterfall insight | -| process_flow | 21 | process, timeline | `funnel_stages`, `chevron_process` | -| timeline_roadmap | 20 | timeline | `story_timeline`, `roadmap_swimlane` | -| geo_map | 17 | image_* | defer (asset-heavy) | -| org_team | 11 | team | richer org tree (SmartArt → groups) | -| kpi_dashboard | 8 | kpi_row, big_number | **`kpi_dashboard_grid`** (priority) | -| narrative_chrome | 7 | cover, section_divider | `agenda_toc`, numbered openers | -| comparison_matrix | 7 | comparison_2col, matrix_2x2 | `quadrant_matrix_rich`, scorecard | -| pricing_table | 6 | pricing, table | multi-tier highlight column | -| hierarchy | 6 | feature_cards | `pyramid_levels` | -| device_mockup | 1 | image_text_2col | defer | +| other (long-tail) | 255 | Wave 2 + **Wave 4** (mindmap, journey, PESTLE, RACI, hex, puzzle, checklist, …) | COVERED / PARTIAL residual | +| process_flow | 30 | process, funnel, chevron, cycle, pipeline, journey, stairs | COVERED | +| chart_story | 23 | chart_insight, callout, waterfall, venn, circle_segments | COVERED | +| timeline_roadmap | 20 | timeline, story_timeline, roadmap, gantt | COVERED | +| geo_map | 17 | geo_callout + image_* (user basemap only) | DEFER basemap | +| hierarchy | 12 | pyramid, iceberg, pillar_columns | COVERED | +| org_team | 11 | team, org_tree, persona | COVERED | +| kpi_dashboard | 8 | kpi_row, kpi_dashboard_grid, big_number | COVERED | +| narrative_chrome | 7 | cover, section_*, agenda, **mission_vision_split** | COVERED | +| comparison_matrix | 7 | comparison, matrix, swot, vs_scorecard, risk_heat, raci | COVERED | +| pricing_table | 6 | pricing, table, appendix_table | COVERED | +| strategy_canvas | 3 | business_canvas, pestle_grid, scorecard_balanced | COVERED | +| device_mockup | 1 | device_frame (user screenshot) | DEFER photo packs | + +**Recipe count:** 75 builders (Wave 0–4 + freeform). Re-run catalog: + +```bash +python -m designmd_pptx reference infograpify_ppt_templates --catalog -o .ref-analysis +``` ## Structural principles (from priority decks) diff --git a/docs/recipe-coverage-roadmap.md b/docs/recipe-coverage-roadmap.md index 540e527..898ef71 100644 --- a/docs/recipe-coverage-roadmap.md +++ b/docs/recipe-coverage-roadmap.md @@ -7,7 +7,7 @@ Infograpify library (local only) — **not** a 1:1 clone of 400 files. |---|---:| | Source decks (filename inventory) | 400 | | Coarse families (`reference` catalog) | 12 | -| Shipped recipes today | 36 | +| Shipped recipes today | 75 (Wave 0–4 + freeform) | | Phase 2 premium shortlist (#58) | 10 / 10 | | Intentional non-goals (asset-heavy) | maps, device mockups, icon packs | @@ -171,6 +171,32 @@ Compose routes `##` titles containing role keywords (OKR, SWOT, pipeline, …) t | **1c** (people/strategy) | `org_tree`, `persona_card`, `business_canvas`, `fishbone_causes`, `iceberg_levels`, `framework_row` | 1a | ✅ shipped (`WAVE1_SEQUENCE`, tests) | | **2** (long-tail roles) | icon_stat_row … device_frame (12 recipes, incl. `pipeline_stages`, `geo_callout`, `device_frame`) | 1 complete | ✅ shipped (`WAVE2_SEQUENCE`, tests) | | **3** (verticals) | 0 recipes | wave 1–2 | ✅ shipped: 5 vertical deck-specs + finance DESIGN skin + compose title keywords | +| **4** (Infograpify long-tail) | 14 recipes | wave 1–3 | ✅ shipped: mindmap, journey, PESTLE, RACI, BSC, hex, puzzle, pillars, stairs, checklist, empathy, risk heat, circle segments, mission/vision | + +### Wave 4 — additional structural roles (2026-07-15 catalog re-analysis) + +Filename + redacted structural scans of the local 400-deck library surfaced +roles still only approximated by `feature_cards` / `bullets`. **Original** +recipes (no vendor geometry): + +| Recipe | Infograpify signals (examples) | +|---|---| +| `mindmap_branches` | Mind Map 01–03, Mindmap-Infographic-* | +| `journey_stages` | Customer Journey V2 | +| `pestle_grid` | PESTEL | +| `raci_matrix` | RACI | +| `scorecard_balanced` | Balanced scorecard packs / matrix analysis | +| `hex_cluster` | Hexagon / honeycomb packs | +| `puzzle_pieces` | Puzzle 1–3 | +| `pillar_columns` | Pillars | +| `stairs_ascent` | Stairs / maturity language | +| `checklist_board` | Checklist | +| `empathy_map_quad` | Empathy Map | +| `risk_heat_matrix` | Risk Management | +| `circle_segments` | Circle Infographics, Analytics Circle | +| `mission_vision_split` | narrative chrome gap from Wave 0 | + +Example deck-spec: `python/examples/content.infograpify-roles.deck.json`. ### Per-recipe checklist (same as premium bar) diff --git a/python/designmd_pptx/compose.py b/python/designmd_pptx/compose.py index d343a57..8e161c2 100644 --- a/python/designmd_pptx/compose.py +++ b/python/designmd_pptx/compose.py @@ -55,6 +55,24 @@ (re.compile(r"\b(finance|p&l|budget|forecast)\b", re.I), "finance_statement"), (re.compile(r"\b(at\s+a\s+glance|stat\s+row|icon\s+stats?)\b", re.I), "icon_stat_row"), (re.compile(r"\b(hub\s+and\s+spoke|bullseye|radial)\b", re.I), "hub_spoke"), + # Wave 4 Infograpify long-tail + (re.compile(r"\b(mind\s*map|mindmap)\b", re.I), "mindmap_branches"), + (re.compile(r"\b(customer\s+journey|user\s+journey|journey\s+map)\b", re.I), + "journey_stages"), + (re.compile(r"\b(pestle|pestel|pest\b)\b", re.I), "pestle_grid"), + (re.compile(r"\b(raci|responsibility\s+matrix)\b", re.I), "raci_matrix"), + (re.compile(r"\b(balanced\s+scorecard|bsc)\b", re.I), "scorecard_balanced"), + (re.compile(r"\b(hexagon|honeycomb|hex\s+cluster)\b", re.I), "hex_cluster"), + (re.compile(r"\b(puzzle|jigsaw)\b", re.I), "puzzle_pieces"), + (re.compile(r"\b(pillar|pillars)\b", re.I), "pillar_columns"), + (re.compile(r"\b(stair|stairs|maturity\s+model|ascent)\b", re.I), "stairs_ascent"), + (re.compile(r"\b(checklist|launch\s+readiness)\b", re.I), "checklist_board"), + (re.compile(r"\b(empathy\s+map)\b", re.I), "empathy_map_quad"), + (re.compile(r"\b(risk\s+matrix|risk\s+heat|heat\s*map\s+risk)\b", re.I), + "risk_heat_matrix"), + (re.compile(r"\b(circle\s+segment|donut\s+legend|ring\s+chart)\b", re.I), + "circle_segments"), + (re.compile(r"\b(mission|vision|purpose)\b", re.I), "mission_vision_split"), ] diff --git a/python/designmd_pptx/recipes.py b/python/designmd_pptx/recipes.py index fe2115b..e6aedd3 100644 --- a/python/designmd_pptx/recipes.py +++ b/python/designmd_pptx/recipes.py @@ -5431,6 +5431,930 @@ def recipe_device_frame(tokens: dict, content: dict | None = None) -> list[dict] return ops +# --------------------------------------------------------------------------- +# Wave 4 — Infograpify long-tail structural roles (filename + geometry analysis) +# Mind maps, journey, PESTLE, RACI, scorecard, hex, puzzle, pillars, stairs, +# checklist, empathy map, risk matrix, circle segments, mission/vision. +# Original geometry only — never vendor bytes. +# --------------------------------------------------------------------------- + +def recipe_mindmap_branches(tokens: dict, content: dict | None = None) -> list[dict]: + """Mind map (Wave 4) — center hub + 4–8 radial branches.""" + b = _base_props(tokens) + c, t = b["c"], b["t"] + content = content or {} + title = content.get("title", "Mind map") + hub = str(content.get("hub") or content.get("center") or content.get("topic") or "Core idea") + branches = _norm_steps( + content.get("branches") or content.get("nodes") or content.get("items") + or content.get("steps"), + min_n=4, max_n=8, + defaults=[{"label": s} for s in ("Theme A", "Theme B", "Theme C", "Theme D", "Theme E", "Theme F")], + ) + n = len(branches) + cx, cy, r = 16.9, 11.2, 6.4 + ops: list[dict] = [_slide_op(tokens), _title_op(tokens, "MindTitle", title)] + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": "MindHub", "preset": "ellipse", + "fill": c["accent"], "line": "none", "text": hub, + "x": _cm(cx - 3.0), "y": _cm(cy - 1.8), + "width": "6.0cm", "height": "3.6cm", + "font": t["heading_font"], "size": str(max(16, t["section_pt"] - 2)), + "bold": "true", "color": c["on_accent"], + "align": "center", "valign": "middle", + }, + }) + nw, nh = 5.6, 2.2 + for i, br in enumerate(branches): + ang = -math.pi / 2 + (2 * math.pi * i / n) + x = cx + r * math.cos(ang) - nw / 2 + y = cy + r * math.sin(ang) * 0.68 - nh / 2 + x = max(b["margin"], min(33.87 - b["margin"] - nw, x)) + y = max(3.2, min(17.6 - nh, y)) + label = br["label"] + if br.get("detail"): + label = f"{label}\n{br['detail']}" + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"MindBranch{i + 1}", "preset": b["preset"], + "fill": c["surface"], "line": "none", "text": label, + "x": _cm(x), "y": _cm(y), "width": _cm(nw), "height": _cm(nh), + "font": t["body_font"], "size": str(max(14, min(18, t["body_pt"]))), + "bold": "true", "color": c["text_on_surface"], + "align": "center", "valign": "middle", + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "notes", + "props": {"text": content.get( + "notes", "One branch = one discussion thread; prune to ≤8.")}, + }) + return ops + + +def recipe_journey_stages(tokens: dict, content: dict | None = None) -> list[dict]: + """Customer / user journey (Wave 4) — staged swim of moments + emotion.""" + b = _base_props(tokens) + c, t = b["c"], b["t"] + content = content or {} + title = content.get("title", "Journey") + stages = _norm_steps( + content.get("stages") or content.get("steps") or content.get("moments"), + min_n=3, max_n=6, + defaults=[ + {"label": "Aware", "detail": "Discover"}, + {"label": "Consider", "detail": "Compare"}, + {"label": "Decide", "detail": "Choose"}, + {"label": "Use", "detail": "Adopt"}, + {"label": "Advocate", "detail": "Share"}, + ], + ) + n = len(stages) + col_w, xs = _grid_n(n, b["margin"], 0.3, max_n=6) + ops: list[dict] = [_slide_op(tokens), _title_op(tokens, "JourneyTitle", title)] + # Lane labels + for lane_i, lane in enumerate(("Action", "Emotion")): + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"JourneyLane{lane_i}", "text": lane, + "x": _cm(b["margin"]), "y": _cm(4.0 + lane_i * 6.2), + "width": "2.8cm", "height": "1.0cm", + "font": t["body_font"], "size": str(_micro_pt(t) + 2), + "bold": "true", "color": c["muted"], "fill": "none", + }, + }) + for i, st in enumerate(stages): + emo = st.get("emotion") or st.get("feeling") or ("🙂" if i < n - 1 else "🤩") + # Stage header + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"JourneyHead{i + 1}", "preset": b["preset"], + "fill": c["accent"] if i == 0 else c["surface"], "line": "none", + "text": st["label"], + "x": _cm(xs[i]), "y": "3.6cm", + "width": _cm(col_w), "height": "2.0cm", + "font": t["heading_font"], "size": str(max(14, t["body_pt"])), + "bold": "true", + "color": c["on_accent"] if i == 0 else c["text_on_surface"], + "align": "center", "valign": "middle", + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"JourneyAct{i + 1}", "preset": b["preset"], + "fill": c["surface"], "line": "none", + "text": st.get("detail") or st.get("action") or "—", + "x": _cm(xs[i]), "y": "6.0cm", + "width": _cm(col_w), "height": "5.2cm", + "font": t["body_font"], "size": str(max(14, t["body_pt"] - 2)), + "color": c["text_on_surface"], "align": "center", "valign": "middle", + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"JourneyEmo{i + 1}", "preset": b["preset"], + "fill": c["content_background"], "line": "none", + "text": str(emo), + "x": _cm(xs[i]), "y": "11.6cm", + "width": _cm(col_w), "height": "4.5cm", + "font": t["body_font"], "size": "28", + "color": c["text_on_content"], "align": "center", "valign": "middle", + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "notes", + "props": {"text": content.get( + "notes", "Call the biggest emotion drop — that is the intervention.")}, + }) + return ops + + +def recipe_pestle_grid(tokens: dict, content: dict | None = None) -> list[dict]: + """PESTLE / PESTEL 6-cell strategy scan (Wave 4).""" + b = _base_props(tokens) + c, t = b["c"], b["t"] + content = content or {} + title = content.get("title", "PESTLE scan") + labels = ["Political", "Economic", "Social", "Technological", "Legal", "Environmental"] + keys = ["political", "economic", "social", "technological", "legal", "environmental"] + cells: list[tuple[str, str]] = [] + raw = content.get("cells") or content.get("factors") + if isinstance(raw, list) and raw: + for i, item in enumerate(raw[:6]): + if isinstance(item, dict): + cells.append(( + str(item.get("label") or labels[i]), + str(item.get("body") or item.get("text") or ""), + )) + else: + cells.append((labels[i], str(item))) + else: + for i, k in enumerate(keys): + val = content.get(k) or content.get(k[:4]) + body = "" + if isinstance(val, list): + body = "\n".join(f"• {x}" for x in val[:4]) + elif isinstance(val, dict): + body = str(val.get("body") or val.get("text") or "") + elif val: + body = str(val) + cells.append((labels[i], body or "• …")) + while len(cells) < 6: + cells.append((labels[len(cells)], "• …")) + m = b["margin"] + gap = 0.35 + usable_w = 33.87 - 2 * m + usable_h = 14.5 + cw, ch = (usable_w - 2 * gap) / 3, (usable_h - gap) / 2 + ops: list[dict] = [_slide_op(tokens), _title_op(tokens, "PestleTitle", title)] + for i, (lab, body) in enumerate(cells[:6]): + col, row = i % 3, i // 3 + x = m + col * (cw + gap) + y = 3.5 + row * (ch + gap) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"Pestle{i + 1}", "preset": b["preset"], + "fill": c["surface"], "line": "none", + "x": _cm(x), "y": _cm(y), "width": _cm(cw), "height": _cm(ch), + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"PestleLab{i + 1}", "text": lab, + "x": _cm(x + 0.35), "y": _cm(y + 0.3), + "width": _cm(cw - 0.7), "height": "1.1cm", + "font": t["heading_font"], "size": str(max(16, t["body_pt"])), + "bold": "true", "color": c["accent"], "fill": "none", + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"PestleBody{i + 1}", "text": body, + "x": _cm(x + 0.35), "y": _cm(y + 1.5), + "width": _cm(cw - 0.7), "height": _cm(ch - 2.0), + "font": t["body_font"], "size": str(max(14, t["body_pt"] - 2)), + "color": c["text_on_surface"], "fill": "none", + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "notes", + "props": {"text": content.get( + "notes", "Mark the two factors that change the plan this quarter.")}, + }) + return ops + + +def recipe_raci_matrix(tokens: dict, content: dict | None = None) -> list[dict]: + """RACI responsibility matrix (Wave 4) — activities × roles.""" + b = _base_props(tokens) + c, t = b["c"], b["t"] + content = content or {} + title = content.get("title", "RACI") + roles = content.get("roles") or ["Exec", "PM", "Eng", "Design", "Ops"] + if not isinstance(roles, list): + roles = [str(roles)] + roles = [str(r) for r in roles[:6]] + activities = content.get("activities") or content.get("rows") or [ + {"name": "Scope", "raci": ["A", "R", "C", "C", "I"]}, + {"name": "Build", "raci": ["I", "A", "R", "C", "C"]}, + {"name": "Launch", "raci": ["A", "R", "C", "C", "R"]}, + ] + m = b["margin"] + n_roles = len(roles) + name_w = 6.5 + usable = 33.87 - 2 * m - name_w + col_w = usable / max(1, n_roles) + row_h = 2.0 + start_y = 4.0 + ops: list[dict] = [_slide_op(tokens), _title_op(tokens, "RaciTitle", title)] + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": "RaciCorner", "preset": b["preset"], + "fill": c["accent"], "line": "none", "text": "Activity", + "x": _cm(m), "y": _cm(start_y), "width": _cm(name_w), "height": _cm(row_h), + "font": t["body_font"], "size": str(max(12, t["body_pt"] - 4)), + "bold": "true", "color": c["on_accent"], + "align": "center", "valign": "middle", + }, + }) + for j, role in enumerate(roles): + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"RaciRole{j + 1}", "preset": b["preset"], + "fill": c["accent"], "line": "none", "text": role, + "x": _cm(m + name_w + j * col_w), "y": _cm(start_y), + "width": _cm(col_w - 0.1), "height": _cm(row_h), + "font": t["body_font"], "size": str(max(12, t["body_pt"] - 4)), + "bold": "true", "color": c["on_accent"], + "align": "center", "valign": "middle", + }, + }) + # Cap rows so header + rows stay within 16:9 canvas under contract load. + max_rows = 5 + act_list = list(activities)[:max_rows] + row_h = min(2.0, (14.5) / (len(act_list) + 1)) + for i, act in enumerate(act_list): + if isinstance(act, dict): + name = str(act.get("name") or act.get("label") or f"Task {i + 1}") + cells = act.get("raci") or act.get("cells") or [] + elif isinstance(act, list): + name, cells = str(act[0]), act[1:] + else: + name, cells = str(act), [] + y = start_y + (i + 1) * row_h + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"RaciAct{i + 1}", "preset": b["preset"], + "fill": c["surface"], "line": "none", "text": name, + "x": _cm(m), "y": _cm(y), "width": _cm(name_w), "height": _cm(row_h), + "font": t["body_font"], "size": str(max(12, t["body_pt"] - 4)), + "bold": "true", "color": c["text_on_surface"], + "align": "center", "valign": "middle", + }, + }) + for j in range(n_roles): + letter = str(cells[j] if j < len(cells) else "—")[:2] + fill = c["accent"] if letter.upper() in ("R", "A") else c["surface"] + tc = c["on_accent"] if letter.upper() in ("R", "A") else c["text_on_surface"] + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"RaciCell{i + 1}_{j + 1}", "preset": b["preset"], + "fill": fill, "line": "none", "text": letter.upper(), + "x": _cm(m + name_w + j * col_w), "y": _cm(y), + "width": _cm(col_w - 0.1), "height": _cm(row_h), + "font": t["heading_font"], "size": str(max(14, t["body_pt"] - 2)), + "bold": "true", "color": tc, + "align": "center", "valign": "middle", + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "notes", + "props": {"text": content.get( + "notes", "Exactly one A per row; R does the work.")}, + }) + return ops + + +def recipe_scorecard_balanced(tokens: dict, content: dict | None = None) -> list[dict]: + """Balanced scorecard (Wave 4) — 4 perspective cards with metrics.""" + b = _base_props(tokens) + c, t = b["c"], b["t"] + content = content or {} + title = content.get("title", "Balanced scorecard") + defaults = [ + ("Financial", "Revenue, margin, cash"), + ("Customer", "NPS, retention, win rate"), + ("Internal", "Cycle time, quality"), + ("Learning", "Skills, experiments"), + ] + perspectives = content.get("perspectives") or content.get("cards") or content.get("quadrants") + cards: list[dict] = [] + if isinstance(perspectives, list) and perspectives: + for i, p in enumerate(perspectives[:4]): + if isinstance(p, dict): + cards.append({ + "label": str(p.get("label") or p.get("title") or defaults[i][0]), + "body": str(p.get("body") or p.get("metrics") or p.get("text") or defaults[i][1]), + }) + else: + cards.append({"label": defaults[i][0], "body": str(p)}) + else: + cards = [{"label": a, "body": b_} for a, b_ in defaults] + while len(cards) < 4: + cards.append({"label": defaults[len(cards)][0], "body": defaults[len(cards)][1]}) + m = b["margin"] + gap = 0.4 + cw = (33.87 - 2 * m - gap) / 2 + ch = 6.4 + ops: list[dict] = [_slide_op(tokens), _title_op(tokens, "BscTitle", title)] + for i, card in enumerate(cards[:4]): + col, row = i % 2, i // 2 + x = m + col * (cw + gap) + y = 3.6 + row * (ch + gap) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"BscCard{i + 1}", "preset": b["preset"], + "fill": c["surface"], "line": "none", + "x": _cm(x), "y": _cm(y), "width": _cm(cw), "height": _cm(ch), + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"BscLab{i + 1}", "text": card["label"], + "x": _cm(x + 0.5), "y": _cm(y + 0.4), + "width": _cm(cw - 1.0), "height": "1.2cm", + "font": t["heading_font"], "size": str(t["section_pt"]), + "bold": "true", "color": c["accent"], "fill": "none", + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"BscBody{i + 1}", "text": card["body"], + "x": _cm(x + 0.5), "y": _cm(y + 1.8), + "width": _cm(cw - 1.0), "height": _cm(ch - 2.4), + "font": t["body_font"], "size": str(t["body_pt"]), + "color": c["text_on_surface"], "fill": "none", + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "notes", + "props": {"text": content.get( + "notes", "Tie each metric to one owner and one review cadence.")}, + }) + return ops + + +def recipe_hex_cluster(tokens: dict, content: dict | None = None) -> list[dict]: + """Honeycomb / hex cluster (Wave 4) — 5–7 tiles around a center.""" + b = _base_props(tokens) + c, t = b["c"], b["t"] + content = content or {} + title = content.get("title", "Cluster") + items = _norm_steps( + content.get("items") or content.get("tiles") or content.get("cells"), + min_n=5, max_n=7, + defaults=[{"label": s} for s in ("Core", "A", "B", "C", "D", "E", "F")], + ) + # Approximate hex with roundRect tiles in honeycomb packing + ops: list[dict] = [_slide_op(tokens), _title_op(tokens, "HexTitle", title)] + # positions relative (cx, cy) for 7 hex: center + 6 ring + cx, cy = 16.9, 11.0 + s = 3.4 # tile half-span + coords = [(0.0, 0.0)] + for i in range(6): + ang = math.pi / 6 + i * math.pi / 3 + coords.append((math.cos(ang) * 2.1 * s, math.sin(ang) * 1.55 * s)) + for i, (item, (dx, dy)) in enumerate(zip(items, coords)): + fill = c["accent"] if i == 0 else c["surface"] + tc = c["on_accent"] if i == 0 else c["text_on_surface"] + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"Hex{i + 1}", "preset": b["preset"], + "fill": fill, "line": "none", "text": item["label"], + "x": _cm(cx + dx - s), "y": _cm(cy + dy - s * 0.7), + "width": _cm(2 * s), "height": _cm(1.5 * s), + "font": t["body_font"], + "size": str(max(14, t["body_pt"] if i else t["section_pt"] - 4)), + "bold": "true", "color": tc, + "align": "center", "valign": "middle", + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "notes", + "props": {"text": content.get( + "notes", "Center is the organising idea; ring tiles are facets.")}, + }) + return ops + + +def recipe_puzzle_pieces(tokens: dict, content: dict | None = None) -> list[dict]: + """Puzzle / interlocking pieces (Wave 4) — 3–6 complementary parts.""" + b = _base_props(tokens) + c, t = b["c"], b["t"] + content = content or {} + title = content.get("title", "Fit") + pieces = _norm_steps( + content.get("pieces") or content.get("parts") or content.get("items"), + min_n=3, max_n=6, + defaults=[{"label": s} for s in ("Product", "GTM", "Ops", "People")], + ) + n = len(pieces) + col_w, xs = _grid_n(n, b["margin"], 0.35, max_n=6) + ops: list[dict] = [_slide_op(tokens), _title_op(tokens, "PuzzleTitle", title)] + for i, pc in enumerate(pieces): + text = pc["label"] + if pc.get("detail"): + text = f"{text}\n{pc['detail']}" + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"Puzzle{i + 1}", "preset": b["preset"], + "fill": c["accent"] if i % 2 == 0 else c["surface"], + "line": "none", "text": text, + "x": _cm(xs[i]), "y": "5.0cm", + "width": _cm(col_w), "height": "10.5cm", + "font": t["heading_font"], + "size": str(max(16, min(24, t["section_pt"]))), + "bold": "true", + "color": c["on_accent"] if i % 2 == 0 else c["text_on_surface"], + "align": "center", "valign": "middle", + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "notes", + "props": {"text": content.get( + "notes", "Name what breaks if any single piece is missing.")}, + }) + return ops + + +def recipe_pillar_columns(tokens: dict, content: dict | None = None) -> list[dict]: + """Pillars / foundational columns (Wave 4) — 3–5 vertical pillars.""" + b = _base_props(tokens) + c, t = b["c"], b["t"] + content = content or {} + title = content.get("title", "Pillars") + pillars = _norm_steps( + content.get("pillars") or content.get("columns") or content.get("items"), + min_n=3, max_n=5, + defaults=[{"label": s, "detail": "Foundation"} for s in ("Trust", "Speed", "Craft")], + ) + n = len(pillars) + col_w, xs = _grid_n(n, b["margin"], 0.5, max_n=5) + ops: list[dict] = [_slide_op(tokens), _title_op(tokens, "PillarTitle", title)] + base_y, max_h = 16.5, 11.5 + for i, p in enumerate(pillars): + h = max_h - i * 0.4 # slight variation + y = base_y - h + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"Pillar{i + 1}", "preset": b["preset"], + "fill": c["accent"] if i == 0 else c["surface"], + "line": "none", + "text": f"{p['label']}\n\n{p.get('detail') or ''}", + "x": _cm(xs[i]), "y": _cm(y), + "width": _cm(col_w), "height": _cm(h), + "font": t["heading_font"], + "size": str(max(16, t["section_pt"] - 4)), + "bold": "true", + "color": c["on_accent"] if i == 0 else c["text_on_surface"], + "align": "center", "valign": "middle", + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "notes", + "props": {"text": content.get( + "notes", "Pillars are non-negotiable investments, not initiatives.")}, + }) + return ops + + +def recipe_stairs_ascent(tokens: dict, content: dict | None = None) -> list[dict]: + """Ascending stairs / maturity steps (Wave 4).""" + b = _base_props(tokens) + c, t = b["c"], b["t"] + content = content or {} + title = content.get("title", "Ascent") + steps = _norm_steps( + content.get("steps") or content.get("levels") or content.get("stages"), + min_n=3, max_n=6, + defaults=[{"label": s} for s in ("Aware", "Repeatable", "Defined", "Managed", "Optimising")], + ) + n = len(steps) + m = b["margin"] + usable = 33.87 - 2 * m + step_w = usable / n + ops: list[dict] = [_slide_op(tokens), _title_op(tokens, "StairsTitle", title)] + base_y = 16.8 + for i, st in enumerate(steps): + h = 3.5 + i * (9.5 / max(1, n - 1)) + y = base_y - h + x = m + i * step_w + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"Stair{i + 1}", "preset": b["preset"], + "fill": c["accent"] if i == n - 1 else c["surface"], + "line": "none", + "text": f"{i + 1}. {st['label']}", + "x": _cm(x + 0.1), "y": _cm(y), + "width": _cm(step_w - 0.2), "height": _cm(h), + "font": t["body_font"], + "size": str(max(14, min(20, t["body_pt"]))), + "bold": "true", + "color": c["on_accent"] if i == n - 1 else c["text_on_surface"], + "align": "center", "valign": "top", + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "notes", + "props": {"text": content.get( + "notes", "You cannot skip a stair — name the exit criteria per step.")}, + }) + return ops + + +def recipe_checklist_board(tokens: dict, content: dict | None = None) -> list[dict]: + """Checklist / launch readiness board (Wave 4).""" + b = _base_props(tokens) + c, t = b["c"], b["t"] + content = content or {} + title = content.get("title", "Checklist") + items = content.get("items") or content.get("checks") or content.get("bullets") or [ + {"label": "Scope locked", "done": True}, + {"label": "Legal review", "done": True}, + {"label": "Support ready", "done": False}, + {"label": "Comms sent", "done": False}, + {"label": "Metrics live", "done": False}, + ] + if not isinstance(items, list): + items = [{"label": str(items)}] + norm: list[dict] = [] + for it in items[:10]: + if isinstance(it, dict): + norm.append({ + "label": str(it.get("label") or it.get("text") or it.get("title") or "Item"), + "done": bool(it.get("done") or it.get("checked") or it.get("status") in ("done", "ok", "yes")), + }) + else: + s = str(it) + done = s.startswith("[x]") or s.startswith("✓") + norm.append({"label": s.lstrip("[x]✓ ").strip(), "done": done}) + m = b["margin"] + ops: list[dict] = [_slide_op(tokens), _title_op(tokens, "CheckTitle", title)] + row_h = min(1.9, 14.0 / max(1, len(norm))) + for i, it in enumerate(norm): + y = 3.6 + i * row_h + mark = "✓" if it["done"] else "○" + fill = c["surface"] if it["done"] else c["content_background"] + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"CheckRow{i + 1}", "preset": b["preset"], + "fill": fill, "line": "none", + "text": f"{mark} {it['label']}", + "x": _cm(m), "y": _cm(y), + "width": _cm(33.87 - 2 * m), "height": _cm(row_h - 0.15), + "font": t["body_font"], "size": str(t["body_pt"]), + "bold": "true" if not it["done"] else "false", + "color": c["text_on_surface"], "valign": "middle", + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "notes", + "props": {"text": content.get( + "notes", "Open items need an owner before the meeting ends.")}, + }) + return ops + + +def recipe_empathy_map_quad(tokens: dict, content: dict | None = None) -> list[dict]: + """Empathy map (Wave 4) — Says / Thinks / Does / Feels around a user.""" + b = _base_props(tokens) + c, t = b["c"], b["t"] + content = content or {} + title = content.get("title", "Empathy map") + user = str(content.get("user") or content.get("persona") or content.get("name") or "User") + quads = [ + ("Says", content.get("says") or content.get("say") or "• Quotes from interviews"), + ("Thinks", content.get("thinks") or content.get("think") or "• Private worries / hopes"), + ("Does", content.get("does") or content.get("do") or "• Observable behaviours"), + ("Feels", content.get("feels") or content.get("feel") or "• Emotional state"), + ] + # Allow quads list override + if isinstance(content.get("quadrants"), list) and len(content["quadrants"]) >= 4: + q = content["quadrants"] + quads = [] + for i, lab in enumerate(("Says", "Thinks", "Does", "Feels")): + item = q[i] + if isinstance(item, dict): + quads.append((str(item.get("label") or lab), + str(item.get("body") or item.get("text") or ""))) + else: + quads.append((lab, str(item))) + m = b["margin"] + gap = 0.35 + cw = (33.87 - 2 * m - gap) / 2 + ch = 6.2 + ops: list[dict] = [_slide_op(tokens), _title_op(tokens, "EmpTitle", title)] + # Center user chip + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": "EmpUser", "preset": "ellipse", + "fill": c["accent"], "line": "none", "text": user, + "x": "14.2cm", "y": "9.0cm", "width": "5.5cm", "height": "3.2cm", + "font": t["heading_font"], "size": str(max(16, t["body_pt"])), + "bold": "true", "color": c["on_accent"], + "align": "center", "valign": "middle", + }, + }) + positions = [ + (m, 3.5), (m + cw + gap, 3.5), + (m, 3.5 + ch + gap), (m + cw + gap, 3.5 + ch + gap), + ] + for i, ((lab, body), (x, y)) in enumerate(zip(quads, positions)): + if isinstance(body, list): + body = "\n".join(f"• {x}" for x in body[:5]) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"EmpQ{i + 1}", "preset": b["preset"], + "fill": c["surface"], "line": "none", + "x": _cm(x), "y": _cm(y), "width": _cm(cw), "height": _cm(ch), + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"EmpLab{i + 1}", "text": lab, + "x": _cm(x + 0.4), "y": _cm(y + 0.3), + "width": _cm(cw - 0.8), "height": "1.0cm", + "font": t["heading_font"], "size": str(t["section_pt"] - 4), + "bold": "true", "color": c["accent"], "fill": "none", + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"EmpBody{i + 1}", "text": str(body), + "x": _cm(x + 0.4), "y": _cm(y + 1.4), + "width": _cm(cw - 0.8), "height": _cm(ch - 1.9), + "font": t["body_font"], "size": str(max(14, t["body_pt"] - 2)), + "color": c["text_on_surface"], "fill": "none", + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "notes", + "props": {"text": content.get( + "notes", "Prefer evidence over stereotypes in every quadrant.")}, + }) + return ops + + +def recipe_risk_heat_matrix(tokens: dict, content: dict | None = None) -> list[dict]: + """Risk heat matrix (Wave 4) — likelihood × impact grid with items.""" + b = _base_props(tokens) + c, t = b["c"], b["t"] + content = content or {} + title = content.get("title", "Risk matrix") + # 3×3 cells; items place by likelihood/impact 1–3 + items = content.get("risks") or content.get("items") or [ + {"label": "Vendor delay", "likelihood": 2, "impact": 3}, + {"label": "Scope creep", "likelihood": 3, "impact": 2}, + {"label": "Key person", "likelihood": 1, "impact": 3}, + {"label": "Reg change", "likelihood": 1, "impact": 2}, + ] + m = b["margin"] + # Left labels + bottom labels + grid_x, grid_y = m + 3.2, 4.0 + grid_w, grid_h = 22.0, 12.5 + cell_w, cell_h = grid_w / 3, grid_h / 3 + ops: list[dict] = [_slide_op(tokens), _title_op(tokens, "RiskTitle", title)] + # axis labels + for i, lab in enumerate(("Low", "Med", "High")): + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"RiskX{i}", "text": lab, + "x": _cm(grid_x + i * cell_w), "y": _cm(grid_y + grid_h + 0.15), + "width": _cm(cell_w), "height": "0.9cm", + "font": t["body_font"], "size": str(_micro_pt(t) + 2), + "color": c["muted"], "align": "center", "fill": "none", + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"RiskY{i}", "text": lab, + "x": _cm(m), "y": _cm(grid_y + (2 - i) * cell_h + cell_h / 2 - 0.4), + "width": "3.0cm", "height": "0.9cm", + "font": t["body_font"], "size": str(_micro_pt(t) + 2), + "color": c["muted"], "align": "right", "fill": "none", + }, + }) + # heat cells (background) + heat = [ + [c["surface"], c["surface"], c.get("chart_series2") or c["muted"]], + [c["surface"], c.get("chart_series2") or c["muted"], c["accent"]], + [c.get("chart_series2") or c["muted"], c["accent"], c["accent"]], + ] + for iy in range(3): + for ix in range(3): + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"RiskCell{iy}{ix}", "preset": "rect", + "fill": heat[iy][ix], "line": "none", + "x": _cm(grid_x + ix * cell_w + 0.05), + "y": _cm(grid_y + (2 - iy) * cell_h + 0.05), + "width": _cm(cell_w - 0.1), "height": _cm(cell_h - 0.1), + }, + }) + # place risk labels (clamp 1–3) + buckets: dict[tuple[int, int], list[str]] = {} + for it in items[:12]: + if not isinstance(it, dict): + continue + L = max(1, min(3, int(it.get("likelihood") or it.get("x") or 2))) + I = max(1, min(3, int(it.get("impact") or it.get("y") or 2))) + buckets.setdefault((L, I), []).append(str(it.get("label") or it.get("name") or "Risk")) + for (L, I), labels in buckets.items(): + text = "\n".join(labels[:3]) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"RiskItem{L}{I}", "text": text, + "x": _cm(grid_x + (L - 1) * cell_w + 0.25), + "y": _cm(grid_y + (3 - I) * cell_h + 0.3), + "width": _cm(cell_w - 0.5), "height": _cm(cell_h - 0.5), + "font": t["body_font"], "size": str(max(12, t["body_pt"] - 4)), + "bold": "true", "color": c["text"], "fill": "none", + "align": "center", "valign": "middle", + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": "RiskAxisX", "text": "Likelihood →", + "x": _cm(grid_x), "y": "18.0cm", "width": _cm(grid_w), "height": "0.7cm", + "font": t["body_font"], "size": str(_micro_pt(t)), + "color": c["muted"], "align": "center", "fill": "none", + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "notes", + "props": {"text": content.get( + "notes", "Top-right cells need owners this week.")}, + }) + return ops + + +def recipe_circle_segments(tokens: dict, content: dict | None = None) -> list[dict]: + """Segmented circle / ring legend (Wave 4) — 3–6 segments as legend cards.""" + b = _base_props(tokens) + c, t = b["c"], b["t"] + content = content or {} + title = content.get("title", "Segments") + segs = _norm_steps( + content.get("segments") or content.get("parts") or content.get("items"), + min_n=3, max_n=6, + defaults=[ + {"label": "Acquire", "value": "32%"}, + {"label": "Activate", "value": "28%"}, + {"label": "Retain", "value": "24%"}, + {"label": "Refer", "value": "16%"}, + ], + ) + n = len(segs) + ops: list[dict] = [_slide_op(tokens), _title_op(tokens, "CircTitle", title)] + # Center disc (placeholder for donut — officecli has no pie slice API in shapes) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": "CircDisc", "preset": "ellipse", + "fill": c["surface"], "line": "none", + "text": str(content.get("center") or content.get("hub") or "100%"), + "x": "3.5cm", "y": "5.0cm", "width": "12.0cm", "height": "12.0cm", + "font": t["heading_font"], "size": str(t["title_pt"] - 8), + "bold": "true", "color": c["text_on_surface"], + "align": "center", "valign": "middle", + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": "CircHole", "preset": "ellipse", + "fill": c["content_background"], "line": "none", + "x": "6.5cm", "y": "8.0cm", "width": "6.0cm", "height": "6.0cm", + }, + }) + # Legend list on the right + row_h = min(2.4, 13.0 / n) + for i, sg in enumerate(segs): + y = 4.0 + i * row_h + text = sg["label"] + if sg.get("value"): + text = f"{sg['value']} {text}" + if sg.get("detail"): + text = f"{text}\n{sg['detail']}" + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"CircLeg{i + 1}", "preset": b["preset"], + "fill": c["accent"] if i == 0 else c["surface"], + "line": "none", "text": text, + "x": "18.0cm", "y": _cm(y), + "width": "13.5cm", "height": _cm(row_h - 0.2), + "font": t["body_font"], "size": str(t["body_pt"]), + "bold": "true", + "color": c["on_accent"] if i == 0 else c["text_on_surface"], + "valign": "middle", + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "notes", + "props": {"text": content.get( + "notes", "Legend carries the insight; the disc is orientation only.")}, + }) + return ops + + +def recipe_mission_vision_split(tokens: dict, content: dict | None = None) -> list[dict]: + """Mission / vision two-panel (Wave 4) — narrative chrome.""" + b = _base_props(tokens) + c, t = b["c"], b["t"] + content = content or {} + title = content.get("title", "Direction") + mission = str(content.get("mission") or content.get("left") + or "Why we exist — the problem we refuse to ignore.") + vision = str(content.get("vision") or content.get("right") + or "Where we are going — the future state in concrete terms.") + m = b["margin"] + gap = 0.5 + cw = (33.87 - 2 * m - gap) / 2 + ops: list[dict] = [_slide_op(tokens), _title_op(tokens, "MVTitle", title)] + for i, (lab, body, fill) in enumerate(( + ("Mission", mission, c["surface"]), + ("Vision", vision, c["accent"]), + )): + x = m + i * (cw + gap) + tc = c["text_on_surface"] if i == 0 else c["on_accent"] + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"MVPanel{i + 1}", "preset": b["preset"], + "fill": fill, "line": "none", + "x": _cm(x), "y": "3.8cm", "width": _cm(cw), "height": "13.5cm", + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"MVLab{i + 1}", "text": lab, + "x": _cm(x + 0.8), "y": "4.6cm", + "width": _cm(cw - 1.6), "height": "1.2cm", + "font": t["heading_font"], "size": str(t["section_pt"]), + "bold": "true", "color": tc, "fill": "none", + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "shape", + "props": { + "name": f"MVBody{i + 1}", "text": body, + "x": _cm(x + 0.8), "y": "6.4cm", + "width": _cm(cw - 1.6), "height": "10.0cm", + "font": t["body_font"], "size": str(t["body_pt"] + 2), + "color": tc, "fill": "none", + }, + }) + ops.append({ + "command": "add", "parent": "/slide[last()]", "type": "notes", + "props": {"text": content.get( + "notes", "Mission is timeless; vision is dated and measurable.")}, + }) + return ops + + def _call_builder(builder, tokens, content, slide_index: int | None = None): """Call recipe builder with optional slide_index if supported.""" try: @@ -5510,6 +6434,21 @@ def recipe_freeform(tokens: dict, content: dict | None = None) -> list[dict]: "pipeline_stages": recipe_pipeline_stages, "geo_callout": recipe_geo_callout, "device_frame": recipe_device_frame, + # Wave 4 Infograpify long-tail roles + "mindmap_branches": recipe_mindmap_branches, + "journey_stages": recipe_journey_stages, + "pestle_grid": recipe_pestle_grid, + "raci_matrix": recipe_raci_matrix, + "scorecard_balanced": recipe_scorecard_balanced, + "hex_cluster": recipe_hex_cluster, + "puzzle_pieces": recipe_puzzle_pieces, + "pillar_columns": recipe_pillar_columns, + "stairs_ascent": recipe_stairs_ascent, + "checklist_board": recipe_checklist_board, + "empathy_map_quad": recipe_empathy_map_quad, + "risk_heat_matrix": recipe_risk_heat_matrix, + "circle_segments": recipe_circle_segments, + "mission_vision_split": recipe_mission_vision_split, "freeform": recipe_freeform, } @@ -5564,6 +6503,11 @@ def recipe_freeform(tokens: dict, content: dict | None = None) -> list[dict]: # Wave 2 "hub_spoke", "calendar_heatmap", "finance_statement", "geo_callout", "device_frame", + # Wave 4 Infograpify long-tail + "mindmap_branches", "journey_stages", "pestle_grid", "raci_matrix", + "scorecard_balanced", "hex_cluster", "puzzle_pieces", "pillar_columns", + "stairs_ascent", "checklist_board", "empathy_map_quad", + "risk_heat_matrix", "circle_segments", "mission_vision_split", ), "fixed": ( "cover", "section_divider", "section_opener_numbered", @@ -5655,6 +6599,24 @@ def recipe_freeform(tokens: dict, content: dict | None = None) -> list[dict]: "device_frame", ] +# Wave 4 — Infograpify remaining structural roles (post W1–W3) +WAVE4_SEQUENCE = [ + "mindmap_branches", + "journey_stages", + "pestle_grid", + "raci_matrix", + "scorecard_balanced", + "hex_cluster", + "puzzle_pieces", + "pillar_columns", + "stairs_ascent", + "checklist_board", + "empathy_map_quad", + "risk_heat_matrix", + "circle_segments", + "mission_vision_split", +] + # Empty-deck / flat-overlay default: consulting core only (no medical tax). DEFAULT_SEQUENCE = list(CORE_SEQUENCE) @@ -5667,14 +6629,14 @@ def recipe_freeform(tokens: dict, content: dict | None = None) -> list[dict]: CATALOG_SEQUENCE = list(CORE_SEQUENCE) + [ n for n in ( PREMIUM_SEQUENCE + DOMAIN_SEQUENCE + WAVE1_SEQUENCE + WAVE2_SEQUENCE - + GENERATIVE_SEQUENCE + + WAVE4_SEQUENCE + GENERATIVE_SEQUENCE ) if n not in CORE_SEQUENCE ] def sequence_for(catalog: str = "core") -> list[str]: - """Return ordered recipe list: core | premium | domain | wave1 | wave2 | generative | all.""" + """Return ordered recipe list: core | premium | domain | wave1 | wave2 | wave4 | generative | all.""" key = (catalog or "core").strip().lower() if key in ("core", "default", ""): return list(CORE_SEQUENCE) @@ -5686,6 +6648,8 @@ def sequence_for(catalog: str = "core") -> list[str]: return list(CORE_SEQUENCE) + [n for n in WAVE1_SEQUENCE if n not in CORE_SEQUENCE] if key in ("wave2", "long-tail"): return list(CORE_SEQUENCE) + [n for n in WAVE2_SEQUENCE if n not in CORE_SEQUENCE] + if key in ("wave4", "infograpify", "strategy-extra"): + return list(CORE_SEQUENCE) + [n for n in WAVE4_SEQUENCE if n not in CORE_SEQUENCE] if key in ("generative", "freeform", "hybrid"): return list(CORE_SEQUENCE) + [n for n in GENERATIVE_SEQUENCE if n not in CORE_SEQUENCE] if key in ("all", "catalog", "full"): diff --git a/python/designmd_pptx/reference.py b/python/designmd_pptx/reference.py index ae8bcd8..2aaea7c 100644 --- a/python/designmd_pptx/reference.py +++ b/python/designmd_pptx/reference.py @@ -48,18 +48,29 @@ _FAMILY_HINTS: list[tuple[re.Pattern[str], str]] = [ (re.compile(r"kpi|dashboard", re.I), "kpi_dashboard"), (re.compile(r"timeline|roadmap|gantt", re.I), "timeline_roadmap"), + # Mind maps before geo_map (otherwise "mindmap" matches "map") + (re.compile(r"mind\s*map|mindmap", re.I), "other"), + (re.compile(r"journey", re.I), "process_flow"), + (re.compile(r"pestle|pestel|\bpest\b", re.I), "strategy_canvas"), + (re.compile(r"raci|scorecard|pillar|checklist|empathy", re.I), "other"), (re.compile( - r"process|flow|funnel|pipeline|value\s*chain|adkar|aida|fishbone|ishikawa", + r"process|flow|funnel|pipeline|value\s*chain|adkar|aida|fishbone|ishikawa|stair", re.I, ), "process_flow"), - (re.compile(r"pyramid|triangle|hierarchy|iceberg", re.I), "hierarchy"), + (re.compile(r"pyramid|triangle|hierarchy|iceberg|pillar", re.I), "hierarchy"), (re.compile(r"org(?:anizational)?|team|persona", re.I), "org_team"), (re.compile(r"pricing|table", re.I), "pricing_table"), - (re.compile(r"compar|vs\.?|versus|swot|matrix|2\s*x\s*2", re.I), "comparison_matrix"), + (re.compile(r"compar|vs\.?|versus|swot|matrix|2\s*x\s*2|risk\s*heat", re.I), + "comparison_matrix"), (re.compile(r"agenda|cover|intro|profile|mission|vision", re.I), "narrative_chrome"), - (re.compile(r"chart|pie|waterfall|venn|cycle|circle", re.I), "chart_story"), + (re.compile(r"chart|pie|waterfall|venn|cycle|circle|hexagon|honeycomb|puzzle", re.I), + "chart_story"), (re.compile(r"business\s*model|canvas|bmc", re.I), "strategy_canvas"), - (re.compile(r"map|geo|world|asia|europe|united\s*states|mind\s*map|mindmap", re.I), "geo_map"), + # Cartography — mindmap/empathy already matched above + (re.compile( + r"\b(geo|world|asia|europe|united\s*states|continent)\b|(? list[str]: ], "process_flow": [ "process", "funnel_stages", "chevron_process", "cycle_loop", - "fishbone_causes", "framework_row", "pipeline_stages", "feature_cards", + "fishbone_causes", "framework_row", "pipeline_stages", + "journey_stages", "stairs_ascent", "feature_cards", + ], + "hierarchy": [ + "pyramid_levels", "iceberg_levels", "pillar_columns", "feature_cards", ], - "hierarchy": ["pyramid_levels", "iceberg_levels", "feature_cards"], "org_team": ["team", "org_tree", "persona_card", "feature_cards"], "pricing_table": ["pricing", "table", "comparison_2col"], "comparison_matrix": [ "comparison_2col", "matrix_2x2", "quadrant_matrix_rich", - "vs_scorecard", "swot_2x2", + "vs_scorecard", "swot_2x2", "risk_heat_matrix", "raci_matrix", ], "narrative_chrome": [ "cover", "section_divider", "agenda_toc", "section_opener_numbered", + "mission_vision_split", ], "chart_story": [ "chart_insight", "chart_callout_panel", "waterfall_insight", - "venn_overlap", "big_number", + "venn_overlap", "circle_segments", "big_number", + ], + "strategy_canvas": [ + "business_canvas", "pestle_grid", "scorecard_balanced", + "feature_cards", "table", ], - "strategy_canvas": ["business_canvas", "feature_cards", "table"], - "geo_map": ["geo_callout", "image_full", "image_text_2col"], + "geo_map": ["geo_callout", "empathy_map_quad", "image_full", "image_text_2col"], "device_mockup": ["device_frame", "image_text_2col", "image_full"], "other": [ "feature_cards", "bullets", "icon_stat_row", "scale_rating", "hub_spoke", "before_after_slider", "calendar_heatmap", "case_study_band", "okrs_tree", "project_status_rag", "finance_statement", "pipeline_stages", + # Wave 4 + "mindmap_branches", "hex_cluster", "puzzle_pieces", + "checklist_board", "raci_matrix", "risk_heat_matrix", + "empathy_map_quad", "journey_stages", "pestle_grid", ], } - # Full-family coverage roadmap (Wave 1 / Wave 2) — keep names stable. - # Wave 1–2 IDs are shipped — remaining planned are Wave 3 optional / future. + # Full-family coverage roadmap — Wave 1–4 shipped; residual planned IDs rare. planned = { "kpi_dashboard": ["metric_sparkline_row"], "timeline_roadmap": [], @@ -533,12 +554,12 @@ def _suggest_recipes(hints: Counter[str], family: str) -> list[str]: "hierarchy": [], "org_team": [], "comparison_matrix": [], - "narrative_chrome": ["mission_vision_split"], + "narrative_chrome": [], "chart_story": ["cycle_stats"], "strategy_canvas": [], - "geo_map": [], # geo_callout shipped (user basemap only) - "device_mockup": [], # device_frame shipped (user screenshot only) - "other": [], # Wave 2 long-tail roles shipped + "geo_map": [], + "device_mockup": [], + "other": [], } sug = list(existing.get(family, existing["other"])) for p in planned.get(family, []): diff --git a/python/designmd_pptx/validate.py b/python/designmd_pptx/validate.py index 211ce02..ab51ca0 100644 --- a/python/designmd_pptx/validate.py +++ b/python/designmd_pptx/validate.py @@ -152,6 +152,43 @@ "device_frame": { "title", "body", "caption", "src", "alt", "device", "notes", }, + # Wave 4 Infograpify long-tail + "mindmap_branches": { + "title", "hub", "center", "topic", "branches", "nodes", "items", "steps", "notes", + }, + "journey_stages": { + "title", "stages", "steps", "moments", "notes", + }, + "pestle_grid": { + "title", "cells", "factors", "political", "economic", "social", + "technological", "legal", "environmental", "notes", + }, + "raci_matrix": { + "title", "roles", "activities", "rows", "notes", + }, + "scorecard_balanced": { + "title", "perspectives", "cards", "quadrants", "notes", + }, + "hex_cluster": {"title", "items", "tiles", "cells", "notes"}, + "puzzle_pieces": {"title", "pieces", "parts", "items", "notes"}, + "pillar_columns": {"title", "pillars", "columns", "items", "notes"}, + "stairs_ascent": {"title", "steps", "levels", "stages", "notes"}, + "checklist_board": {"title", "items", "checks", "bullets", "notes"}, + "empathy_map_quad": { + "title", "user", "persona", "name", "says", "thinks", "does", "feels", + "say", "think", "do", "feel", "quadrants", "notes", + }, + "risk_heat_matrix": {"title", "risks", "items", "notes"}, + "circle_segments": { + "title", "segments", "parts", "items", "center", "hub", "notes", + }, + "mission_vision_split": { + "title", "mission", "vision", "left", "right", "notes", + }, + "freeform": { + "title", "body", "bullets", "placements", "style_directive", "style", + "style_profile", "notes", + }, } diff --git a/python/examples/content.infograpify-roles.deck.json b/python/examples/content.infograpify-roles.deck.json new file mode 100644 index 0000000..e8b3f1e --- /dev/null +++ b/python/examples/content.infograpify-roles.deck.json @@ -0,0 +1,129 @@ +{ + "version": "1.1", + "meta": { + "title": "Infograpify structural roles (Wave 4)", + "note": "Synthetic deck-spec only — original geometry, no vendor content" + }, + "slides": [ + { + "id": "cover", + "recipe": "cover", + "content": { + "title": "Strategy toolkit", + "subtitle": "Wave 4 roles from Infograpify analysis", + "meta": "designmd-pptx · original recipes" + } + }, + { + "id": "mind", + "recipe": "mindmap_branches", + "content": { + "title": "Problem space", + "hub": "Growth", + "branches": [ + {"label": "Product"}, + {"label": "GTM"}, + {"label": "Ops"}, + {"label": "People"}, + {"label": "Risk"}, + {"label": "Capital"} + ] + } + }, + { + "id": "journey", + "recipe": "journey_stages", + "content": { + "title": "Customer journey", + "stages": [ + {"label": "Aware", "detail": "Paid + organic", "emotion": "curious"}, + {"label": "Consider", "detail": "Trials", "emotion": "hopeful"}, + {"label": "Buy", "detail": "Checkout", "emotion": "anxious"}, + {"label": "Use", "detail": "Onboarding", "emotion": "focused"}, + {"label": "Advocate", "detail": "Referral", "emotion": "proud"} + ] + } + }, + { + "id": "pestle", + "recipe": "pestle_grid", + "content": { + "title": "External scan", + "political": ["Policy shift Q3"], + "economic": ["FX volatility"], + "social": ["Remote norms"], + "technological": ["Model cost drop"], + "legal": ["Data residency"], + "environmental": ["Scope 3 reporting"] + } + }, + { + "id": "raci", + "recipe": "raci_matrix", + "content": { + "title": "Launch RACI", + "roles": ["Exec", "PM", "Eng", "Design", "CS"], + "activities": [ + {"name": "Scope", "raci": ["A", "R", "C", "C", "I"]}, + {"name": "Build", "raci": ["I", "A", "R", "C", "C"]}, + {"name": "Launch", "raci": ["A", "R", "C", "C", "R"]} + ] + } + }, + { + "id": "bsc", + "recipe": "scorecard_balanced", + "content": { + "title": "Balanced scorecard", + "perspectives": [ + {"label": "Financial", "body": "ARR · NRR · Gross margin"}, + {"label": "Customer", "body": "NPS · Logo churn · Expansion"}, + {"label": "Internal", "body": "Cycle time · Escapes"}, + {"label": "Learning", "body": "Experiments / quarter"} + ] + } + }, + { + "id": "risk", + "recipe": "risk_heat_matrix", + "content": { + "title": "Risk heat map", + "risks": [ + {"label": "Vendor delay", "likelihood": 2, "impact": 3}, + {"label": "Scope creep", "likelihood": 3, "impact": 2}, + {"label": "Key person", "likelihood": 1, "impact": 3} + ] + } + }, + { + "id": "empathy", + "recipe": "empathy_map_quad", + "content": { + "title": "Buyer empathy", + "user": "Ops lead", + "says": "• Need audit trail", + "thinks": "• Will this break SOP?", + "does": "• Shadow IT spreadsheets", + "feels": "• Risk-averse, time-poor" + } + }, + { + "id": "mv", + "recipe": "mission_vision_split", + "content": { + "title": "North star", + "mission": "Make every brand deck as sharp as the product it sells.", + "vision": "By 2028 every team ships DESIGN.md-native slides without designers in the loop." + } + }, + { + "id": "close", + "recipe": "close", + "content": { + "title": "Next", + "body": "Scaffold with --content python/examples/content.infograpify-roles.deck.json", + "cta": "python -m designmd_pptx scaffold default -o out/roles --content …" + } + } + ] +} diff --git a/python/tests/test_domain_patterns.py b/python/tests/test_domain_patterns.py index 1942066..9cf3c86 100644 --- a/python/tests/test_domain_patterns.py +++ b/python/tests/test_domain_patterns.py @@ -106,14 +106,15 @@ def test_sequence_partitions(self) -> None: full, _ = normalize_deck_spec(None, catalog="all") full_r = [s["recipe"] for s in full["slides"]] self.assertEqual(len(full_r), len(CATALOG_SEQUENCE)) - from designmd_pptx.recipes import WAVE1_SEQUENCE, WAVE2_SEQUENCE - for name in DOMAIN_SEQUENCE + PREMIUM_SEQUENCE + WAVE1_SEQUENCE + WAVE2_SEQUENCE: + from designmd_pptx.recipes import WAVE1_SEQUENCE, WAVE2_SEQUENCE, WAVE4_SEQUENCE + for name in (DOMAIN_SEQUENCE + PREMIUM_SEQUENCE + WAVE1_SEQUENCE + + WAVE2_SEQUENCE + WAVE4_SEQUENCE): self.assertIn(name, full_r) # every builder is reachable via catalog partitions from designmd_pptx.recipes import GENERATIVE_SEQUENCE covered = (set(CORE_SEQUENCE) | set(PREMIUM_SEQUENCE) | set(DOMAIN_SEQUENCE) | set(WAVE1_SEQUENCE) | set(WAVE2_SEQUENCE) - | set(GENERATIVE_SEQUENCE)) + | set(WAVE4_SEQUENCE) | set(GENERATIVE_SEQUENCE)) self.assertEqual(set(RECIPE_BUILDERS), covered) self.assertEqual(set(CATALOG_SEQUENCE), covered) diff --git a/python/tests/test_wave2_recipes.py b/python/tests/test_wave2_recipes.py index e213ff3..3e2fa39 100644 --- a/python/tests/test_wave2_recipes.py +++ b/python/tests/test_wave2_recipes.py @@ -91,8 +91,8 @@ def test_pipeline_stage_count(self) -> None: self.assertEqual(len(stages), 3) def test_total_recipe_count_agent_usable(self) -> None: - # Roadmap: roughly ≤55–65, not hundreds (freeform #21 adds +1) - self.assertLessEqual(len(RECIPE_BUILDERS), 65) + # Structural roles, not hundreds of file clones (Wave 4 + freeform) + self.assertLessEqual(len(RECIPE_BUILDERS), 90) self.assertGreaterEqual(len(RECIPE_BUILDERS), 60) diff --git a/python/tests/test_wave4_infograpify.py b/python/tests/test_wave4_infograpify.py new file mode 100644 index 0000000..14ef03c --- /dev/null +++ b/python/tests/test_wave4_infograpify.py @@ -0,0 +1,103 @@ +"""Wave 4 — Infograpify long-tail structural recipes.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + +from designmd_pptx.compile import compile_design_md +from designmd_pptx.recipes import ( + PATTERN_LAYOUT, + RECIPE_BUILDERS, + WAVE4_SEQUENCE, + sequence_for, +) + +FIXTURES = Path(__file__).resolve().parent.parent / "fixtures" +PKG = Path(__file__).resolve().parent.parent / "designmd_pptx" + + +class Wave4Infograpify(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.tokens = compile_design_md(FIXTURES / "premium-consulting.DESIGN.md") + + def test_wave4_count_and_registry(self) -> None: + self.assertEqual(len(WAVE4_SEQUENCE), 14) + for name in WAVE4_SEQUENCE: + self.assertIn(name, RECIPE_BUILDERS, name) + + def test_pattern_layout_covers_wave4(self) -> None: + flat = {n for names in PATTERN_LAYOUT.values() for n in names} + for name in WAVE4_SEQUENCE: + self.assertIn(name, flat, name) + + def test_sequence_for_wave4(self) -> None: + seq = sequence_for("wave4") + for name in WAVE4_SEQUENCE: + self.assertIn(name, seq) + self.assertIn("mindmap_branches", sequence_for("infograpify")) + + def test_builders_emit_slide(self) -> None: + samples = { + "mindmap_branches": {"hub": "Core", "branches": [{"label": f"B{i}"} for i in range(6)]}, + "journey_stages": None, + "pestle_grid": {"political": ["Reg risk"], "economic": ["FX"]}, + "raci_matrix": None, + "scorecard_balanced": None, + "hex_cluster": None, + "puzzle_pieces": None, + "pillar_columns": None, + "stairs_ascent": None, + "checklist_board": { + "items": [{"label": "A", "done": True}, {"label": "B", "done": False}], + }, + "empathy_map_quad": {"user": "Alex", "says": "• quote"}, + "risk_heat_matrix": { + "risks": [{"label": "Delay", "likelihood": 3, "impact": 2}], + }, + "circle_segments": None, + "mission_vision_split": { + "mission": "Why we exist", "vision": "Where we go", + }, + } + for name in WAVE4_SEQUENCE: + ops = RECIPE_BUILDERS[name](self.tokens, samples.get(name)) + self.assertTrue(ops, name) + self.assertEqual(ops[0].get("type"), "slide", name) + types = {o.get("type") for o in ops} + self.assertTrue(types & {"shape", "notes"}, name) + + def test_raci_has_role_headers(self) -> None: + ops = RECIPE_BUILDERS["raci_matrix"](self.tokens, { + "roles": ["PM", "Eng"], + "activities": [{"name": "Ship", "raci": ["A", "R"]}], + }) + names = [str(o.get("props", {}).get("name", "")) for o in ops] + self.assertTrue(any(n.startswith("RaciRole") for n in names)) + self.assertTrue(any(n.startswith("RaciCell") for n in names)) + + def test_pestle_six_panels(self) -> None: + ops = RECIPE_BUILDERS["pestle_grid"](self.tokens, None) + panels = [ + o for o in ops + if str(o.get("props", {}).get("name", "")).startswith("Pestle") + and not str(o.get("props", {}).get("name", "")).startswith("PestleLab") + and not str(o.get("props", {}).get("name", "")).startswith("PestleBody") + and o.get("type") == "shape" + and o.get("props", {}).get("fill") not in (None, "none") + ] + # 6 background panels + self.assertGreaterEqual(len(panels), 6) + + def test_compose_title_hints_wave4(self) -> None: + from designmd_pptx.compose import _role_from_title + self.assertEqual(_role_from_title("Customer journey map"), "journey_stages") + self.assertEqual(_role_from_title("PESTLE external scan"), "pestle_grid") + self.assertEqual(_role_from_title("RACI for launch"), "raci_matrix") + self.assertEqual(_role_from_title("Empathy map workshop"), "empathy_map_quad") + self.assertEqual(_role_from_title("Mission and vision"), "mission_vision_split") + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/officecli-pptx-designmd/SKILL.md b/skills/officecli-pptx-designmd/SKILL.md index 141f543..df319eb 100644 --- a/skills/officecli-pptx-designmd/SKILL.md +++ b/skills/officecli-pptx-designmd/SKILL.md @@ -172,6 +172,13 @@ editorial). Prefer patterns first; freeform when NL or vision asks for re-layout **Wave 3 verticals:** `python/examples/content.{business,marketing,health,education,finance}.deck.json` — see `docs/recipe-coverage-roadmap.md` +**Wave 4 Infograpify roles:** **mindmap_branches** · **journey_stages** · +**pestle_grid** · **raci_matrix** · **scorecard_balanced** · **hex_cluster** · +**puzzle_pieces** · **pillar_columns** · **stairs_ascent** · **checklist_board** · +**empathy_map_quad** · **risk_heat_matrix** · **circle_segments** · +**mission_vision_split** — example: `python/examples/content.infograpify-roles.deck.json` +(analyze local packs with `reference` only; never commit vendor `.pptx`). + ### Animation (#40) Optional DESIGN.md frontmatter `animation: { enabled, entrance, transition, … }`.