diff --git a/.agents/skills/infographic-agent/.npmignore b/.agents/skills/infographic-agent/.npmignore
new file mode 100644
index 0000000..c2df22c
--- /dev/null
+++ b/.agents/skills/infographic-agent/.npmignore
@@ -0,0 +1,4 @@
+# Exclude everything not needed in the published package
+node_modules/
+*.log
+.DS_Store
diff --git a/.agents/skills/infographic-agent/README.md b/.agents/skills/infographic-agent/README.md
new file mode 100644
index 0000000..2c1167c
--- /dev/null
+++ b/.agents/skills/infographic-agent/README.md
@@ -0,0 +1,62 @@
+# Infographic Agent Skill
+
+A standalone, agent-agnostic **skill** package for generating professional infographics, visual summaries, and data visualizations directly from text or topic descriptions with Gemini.
+
+This skill is designed to run in both local terminal environments and within AI coding agents (such as Claude Code, Cursor, Copilot, etc.) that support the Vercel agent skills ecosystem.
+
+## π Installation & Setup
+
+You can install this skill directly into your AI coding agent environment:
+
+```bash
+npx skills add ryanbaumann/infographic-agent
+```
+
+Alternatively, you can run the CLI tool directly without installation:
+
+```bash
+# First run: installs the small Python dependencies (google-genai and pillow)
+npx infographic-agent --install
+
+# Generate an infographic
+npx infographic-agent "Top 5 programming languages in 2026"
+```
+
+## π Prerequisites
+
+- **Python 3.8+**
+- **Gemini API Key**: Get a free key at [Google AI Studio](https://aistudio.google.com/apikey). Set it as:
+ ```bash
+ export GEMINI_API_KEY="your-key"
+ ```
+ *(If no key is configured in the environment, the CLI onboarding will guide you through getting one and saving it locally).*
+- **Vertex AI (Optional)**: If you prefer enterprise Vertex AI, configure Google Cloud Project details:
+ ```bash
+ export GOOGLE_CLOUD_PROJECT="your-project-id"
+ ```
+
+## βοΈ How It Works
+
+The skill runs a fast, browser-less two-agent pipeline powered by Gemini:
+
+1. **Research & Plan (`gemini-3.5-flash`)**: Grounds the topic with Google Search and returns the same Prepare contract as the web app: analysis metadata, exact text strings, and a precise renderer prompt.
+2. **Eval**: Runs deterministic checks for schema, explicit image prompt prefix, quoted text strings, source attribution, accessibility guidance, and prompt length before rendering.
+3. **Render (`gemini-3.1-flash-lite-image` by default)**: Renders the validated prompt directly into a high-quality, text-accurate infographic PNG. The portable skill can opt into `gemini-3.1-flash-image` via `--image-model` for quality-focused runs.
+
+## π οΈ CLI Flags & Options
+
+All flags are fully compatible between the Python script and the `npx` execution:
+
+| Flag / Option | Short | Description | Default / Choices |
+| :--- | :--- | :--- | :--- |
+| `topic` | *None* | The topic, prompt, or content you want to visualize. | *None* |
+| `--output` | `-o` | File path where the output PNG will be saved. | `infographic.png` |
+| `--mode` | `-m` | Preset layout and style theme. | `data-story` Choices: `classroom`, `custom`, `data-story`, `executive-summary`, `quick-slide`, `technical-deep-dive` |
+| `--aspect` | `-a` | Aspect ratio of the generated infographic image. | `9:16` Choices: `1:1`, `1:4`, `3:4`, `4:3`, `9:16`, `16:9` |
+| `--instructions` | `-i` | Custom layout, design, or brand color rules. | `""` |
+| `--image-model` | *None* | Image model for the portable skill. | `gemini-3.1-flash-lite-image` Choices: `gemini-3.1-flash-lite-image`, `gemini-3.1-flash-image` |
+| `--no-research` | *None* | Skip the research agent and generate directly (faster, no web search). | *Flag* |
+| `--yes` | `-y` | Non-interactive execution (no refine loop, best for CI/agents). | *Flag* |
+| `--setup` | *None* | Launch the interactive key onboarding walkthrough, then exit. | *Flag* |
+
+For full documentation and examples, visit the main repository at [github.com/ryanbaumann/infographic-agent](https://github.com/ryanbaumann/infographic-agent).
diff --git a/.agents/skills/infographic-agent/SKILL.md b/.agents/skills/infographic-agent/SKILL.md
new file mode 100644
index 0000000..a6051fb
--- /dev/null
+++ b/.agents/skills/infographic-agent/SKILL.md
@@ -0,0 +1,120 @@
+---
+name: infographic-agent
+description: >
+ Generate professional infographics, visual summaries, charts, and data visualizations directly with Gemini.
+ A research agent (gemini-3.5-flash) grounds the topic with Google Search and engineers a precise prompt,
+ then gemini-3.1-flash-lite-image renders it into a PNG. No browser, Playwright, or Chromium dependencies β
+ the only requirement is Google's GenAI SDK. Fully portable to any agent CLI environment.
+metadata:
+ version: "3.2.0"
+ author: "Infographic Agent contributors"
+---
+
+# Infographic Agent Skill (Portable)
+
+
+You are an expert AI Infographic Designer and Coordinator. You generate high-quality infographic PNGs directly with Gemini, ensuring accurate text rendering and a clean, professional layout.
+
+
+
+This skill mirrors the repo's web demo as a two-agent pipeline, both powered by Gemini:
+
+1. **Research orchestrator (`gemini-3.5-flash`):** reads the user's topic/content, optionally grounds it with Google Search, and returns the same `PrepareResult` contract as the web app: analysis metadata, exact text strings, and a precise image-generation prompt.
+2. **Image generator (`gemini-3.1-flash-lite-image` by default):** renders that prompt directly into a polished infographic PNG. The portable skill may use `gemini-3.1-flash-image` via `--image-model` when the caller explicitly chooses quality over latency. The web app remains locked to `gemini-3.1-flash-lite-image`.
+
+Before rendering, `portable_infographic.py` runs the same deterministic Prepare eval gate as the web app: schema, explicit image-prompt prefix, quoted text strings, source attribution, accessibility guidance, and prompt length. Blocking failures stop before Render; warnings are printed but do not block the artifact.
+
+After the first draft, an interactive refine loop lets the user iterate ("make the header bolder", "use teal accents") β each turn re-invokes the image model with the previous image plus the edit, saving a new revision in seconds.
+
+The entire workflow lives in `portable_infographic.py`. There are **no browser dependencies** β install is a single `pip install google-genai pillow` (google-genai runs the pipeline; pillow transcodes the output to lossless PNG for crisp text).
+
+**Security posture:** the Gemini API key is user-provided. If not set via `GEMINI_API_KEY`, the CLI walks the user through getting a free key from Google AI Studio and stores it locally at `~/.config/infographic-agent/config.json` with `0600` permissions. Errors are scrubbed of anything that looks like a credential before printing. If this skill is invoked autonomously, treat `--output` as trusted input β the path is resolved but will write wherever the invoker points it.
+
+
+
+Run this skill as a bounded human-in-the-loop agent loop:
+
+1. **Intake:** collect topic/content, mode, aspect ratio, output path, and any brand/style constraints.
+2. **Research:** use the research orchestrator unless `--no-research` is set; never invent data points.
+3. **Plan:** produce the exact text strings, layout, palette, and image prompt before rendering.
+4. **Eval:** run deterministic Prepare checks and stop before Render on contract failures.
+5. **Render:** call the image model once for the current plan and save the artifact.
+6. **Review:** stop for human review unless `--yes` was passed.
+7. **Refine:** apply one focused edit per turn, preserving the previous image as state, then return to Review.
+
+Portable state is held locally by the CLI as the current image path plus turn history. Agents that support Gemini Enterprise Agent Platform can replace that local state with the Gemini Interactions API by storing each returned `interaction.id` and passing it as `previousInteractionId` on the next review/refine turn.
+
+Interactions API note: use the unified Google Gen AI SDK (`@google/genai >= 2.0.0` or `google-genai >= 2.0.0`). Legacy packages such as `@google/generative-ai`, `google-generativeai`, `@google-cloud/vertexai`, and `google-cloud-aiplatform` are unsupported for Interactions, and legacy models such as `gemini-2.0-*` and `gemini-1.5-*` are deprecated and unsupported. Turn-scoped settings such as tools, system instructions, and generation config must be sent on every interaction request.
+
+
+
+1. **Identify Request:** Confirm the user wants to generate an infographic.
+2. **Install Skill:** The easiest way to install this skill into any AI coding agent:
+ ```bash
+ # Via the Vercel agent skills ecosystem (installs into Claude, Cursor, Copilot, etc.)
+ npx skills add ryanbaumann/infographic-agent
+
+ # Or run directly without installing via npm:
+ npx infographic-agent --install # first-time: pip install google-genai pillow
+ ```
+3. **Set up the API key (free, ~20 seconds):** Either export it, or let the CLI onboard you:
+ ```bash
+ # Option A β set it yourself (get a free key at https://aistudio.google.com/apikey):
+ export GEMINI_API_KEY="your-key"
+
+ # Option B β one-click onboarding: just run the tool. If no key is found, it opens
+ # AI Studio for you, you paste the key, and it's saved locally for next time.
+ npx infographic-agent --setup
+
+ # (Enterprise) Vertex AI works too:
+ export GOOGLE_CLOUD_PROJECT="your-project" # optional: GOOGLE_CLOUD_LOCATION (default us-central1)
+ ```
+4. **Execute:** Run the script to generate the PNG. A non-zero exit code means generation failed β check the printed error.
+5. **Deliver Output:** Output the path to the resulting `.png` file (and any `-v2`, `-v3` refinement revisions).
+
+
+
+When the user asks you to create an infographic, run `portable_infographic.py`.
+
+**Example usage:**
+```bash
+python3 skill/infographic-agent/portable_infographic.py "Top 5 programming languages in 2026"
+```
+
+**With options:**
+```bash
+python3 skill/infographic-agent/portable_infographic.py \
+ --text "Q2 sales highlights" \
+ --output sales.png \
+ --mode executive-summary \
+ --aspect 16:9
+```
+
+**Key flags:**
+- `--mode` β `data-story` (default), `executive-summary`, `technical-deep-dive`, `classroom`, `quick-slide`, `brandkit`, `blog-post`, `portfolio-showcase`, `custom`
+- `--aspect` β `1:1`, `9:16` (default), `16:9`, `3:4`, `4:3`, `1:4`, `16:10`, `21:9`
+- `--resolution` β `0.5K`, `1K` (default), `2K` (image size to request from the API)
+- `--instructions` β extra styling/content guidance
+- `--image-model` β `gemini-3.1-flash-lite-image` (default) or skill-only `gemini-3.1-flash-image`
+- `--no-research` β skip the research agent and generate directly from the text (faster, no web grounding)
+- `--yes` β non-interactive: generate once and exit (use this when running autonomously or in CI)
+- `--no-open` β do not auto-open the result in an image viewer
+- `--setup` β (re)configure the Gemini API key
+
+When invoking this skill autonomously (no human at the terminal), always pass `--yes` so it does not block on the interactive refine loop, and ensure `GEMINI_API_KEY` is set so it does not block on onboarding.
+
+### Alternative: orchestrate directly with subagents
+If you prefer to orchestrate without the script:
+1. Ask a research/LLM subagent (e.g. `gemini-3.5-flash`) to produce the web-compatible `PrepareResult` JSON from the user's content.
+2. Run local checks before rendering: required schema, prompt starts with `"Generate a professional infographic image"`, final text strings exist, quoted strings are present in the prompt, source attribution exists, accessibility guidance exists, and prompt length is bounded.
+3. Send the validated prompt to an image model (`gemini-3.1-flash-lite-image` by default; `gemini-3.1-flash-image` for skill-only quality runs) with `responseModalities: ['TEXT', 'IMAGE']` and save the returned PNG.
+4. Provide the link to the user, and offer refinement turns by re-sending the previous image plus the edit instruction.
+
+
+
+### The 3 Hard Rules of Infographics
+
+1. **Text Accuracy First:** Quote every text string exactly in the image prompt so the model renders it verbatim. Never let the model invent or misspell labels.
+2. **Data Accuracy Rule:** Never hallucinate data points. Ground with Google Search and give the exact numbers requested.
+3. **Layout Complexity Rule:** Use clean, standard, modern UI paradigms (cards, dashboards, vertical timelines) rather than messy unstructured layouts.
+
diff --git a/.agents/skills/infographic-agent/bin/infographic-agent.js b/.agents/skills/infographic-agent/bin/infographic-agent.js
new file mode 100755
index 0000000..95bf0ce
--- /dev/null
+++ b/.agents/skills/infographic-agent/bin/infographic-agent.js
@@ -0,0 +1,151 @@
+#!/usr/bin/env node
+/**
+ * infographic-agent CLI
+ *
+ * Thin Node.js shim that delegates to `portable_infographic.py`, which is
+ * bundled in this npm package. Node is only used here because npm/npx needs a
+ * JS entry-point; all the heavy lifting (the two Gemini agents that research
+ * and render the infographic) lives in the Python script.
+ *
+ * The ONLY runtime dependency is Google's GenAI SDK β there is no browser,
+ * Playwright, or Chromium download.
+ *
+ * Usage (via npx):
+ * npx infographic-agent "Top 5 programming languages in 2026"
+ *
+ * First-time setup:
+ * npx infographic-agent --install # pip install google-genai pillow
+ * # then just run it β the CLI walks you through getting a free API key.
+ */
+
+import { spawnSync } from "node:child_process";
+import { existsSync, readFileSync } from "node:fs";
+import { resolve, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const scriptPath = resolve(__dirname, "../portable_infographic.py");
+const pkg = JSON.parse(readFileSync(resolve(__dirname, "../package.json"), "utf8"));
+
+// βββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+function print(msg) {
+ process.stdout.write(msg + "\n");
+}
+
+function err(msg) {
+ process.stderr.write("[infographic-agent] " + msg + "\n");
+}
+
+function run(cmd, args) {
+ return spawnSync(cmd, args, { stdio: "inherit" });
+}
+
+// βββ Resolve Python interpreter βββββββββββββββββββββββββββββββββββββββββββββ
+
+function findPython() {
+ for (const candidate of ["python3", "python"]) {
+ const result = spawnSync(candidate, ["--version"], { encoding: "utf8" });
+ if (result.status === 0) {
+ const match = (result.stdout + result.stderr).match(/Python (\d+)\.(\d+)/);
+ if (match) {
+ const major = parseInt(match[1], 10);
+ const minor = parseInt(match[2], 10);
+ if (major >= 3 && minor >= 8) return candidate;
+ }
+ }
+ }
+ return null;
+}
+
+// βββ Pre-flight checks βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+if (!existsSync(scriptPath)) {
+ err(
+ `Could not find portable_infographic.py at: ${scriptPath}\n` +
+ " This is a packaging bug β please open an issue at\n" +
+ " https://github.com/ryanbaumann/infographic-agent/issues"
+ );
+ process.exit(1);
+}
+
+const python = findPython();
+
+// βββ CLI args ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+const args = process.argv.slice(2);
+const showHelp = args.includes("--help") || args.includes("-h");
+const doInstall = args.includes("--install");
+
+// βββ --help ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+if (showHelp) {
+ print(`
+infographic-agent v${pkg.version}
+
+Generate a professional infographic PNG directly with Gemini: a research
+agent (gemini-3.5-flash) prepares and validates the prompt, then the image
+model (gemini-3.1-flash-lite-image) renders it. No browser or Playwright needed.
+
+Usage:
+ npx infographic-agent "" [options]
+
+Options:
+ --text Content to visualize (alternative to the positional topic)
+ --output, -o Output PNG path (default: infographic.png)
+ --mode, -m data-story | executive-summary | technical-deep-dive |
+ classroom | quick-slide | custom (default: data-story)
+ --aspect, -a 1:1 | 9:16 | 16:9 | 3:4 | 4:3 | 1:4 (default: 9:16)
+ --instructions, -i Extra styling / content instructions
+ --no-research Skip the research agent; generate straight from your text
+ --no-open Do not auto-open the result
+ --yes, -y Non-interactive: generate once and exit (no refine loop)
+ --setup (Re)configure your free Gemini API key and exit
+ --install Install Python dependencies (google-genai, pillow)
+ --help, -h Show this help message
+
+Getting started (a free key takes ~20 seconds):
+ npx infographic-agent --install
+ npx infographic-agent "Top 5 programming languages in 2026"
+ # β the CLI walks you through grabbing a free key from Google AI Studio.
+
+Examples:
+ npx infographic-agent "Q2 sales highlights" -o sales.png -m executive-summary
+ npx infographic-agent --text "$(cat report.txt)" -a 16:9
+`.trim());
+ process.exit(0);
+}
+
+// βββ Require Python from here on βββββββββββββββββββββββββββββββββββββββββββββ
+
+if (!python) {
+ err(
+ "Python 3.8+ is required but was not found on your PATH.\n\n" +
+ " Install Python from https://www.python.org/downloads/ and make sure\n" +
+ " it is on your PATH, then re-run:\n\n" +
+ " npx infographic-agent ..."
+ );
+ process.exit(1);
+}
+
+// βββ --install: first-time dependency setup βββββββββββββββββββββββββββββββββ
+
+if (doInstall) {
+ print("Installing Python dependencies (google-genai, pillow)...");
+ const result = run(python, ["-m", "pip", "install", "--upgrade", "google-genai", "pillow"]);
+ if (result.status !== 0) process.exit(result.status);
+
+ print(`
+β Setup complete!
+
+ Generate your first infographic (the CLI will help you get a free API key):
+
+ npx infographic-agent "Top 5 programming languages in 2026"
+`.trim());
+ process.exit(0);
+}
+
+// βββ Delegate to Python script βββββββββββββββββββββββββββββββββββββββββββββββ
+
+const result = run(python, [scriptPath, ...args]);
+process.exit(typeof result.status === "number" ? result.status : 1);
diff --git a/.agents/skills/infographic-agent/package.json b/.agents/skills/infographic-agent/package.json
new file mode 100644
index 0000000..8a1fb50
--- /dev/null
+++ b/.agents/skills/infographic-agent/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "infographic-agent",
+ "version": "3.2.0",
+ "description": "Generate professional infographic PNGs from text with Gemini β a research agent (gemini-3.5-flash) prepares and validates the prompt, then gemini-3.1-flash-lite-image renders it. No browser/Playwright deps.",
+ "license": "MIT",
+ "author": "Ryan Baumann",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/ryanbaumann/infographic-agent.git",
+ "directory": "skill/infographic-agent"
+ },
+ "homepage": "https://github.com/ryanbaumann/infographic-agent#the-portable-skill",
+ "keywords": [
+ "infographic",
+ "gemini",
+ "gemini-3.1-flash-lite-image",
+ "ai",
+ "image-generation",
+ "cli",
+ "data-visualization",
+ "agent-skill"
+ ],
+ "type": "module",
+ "engines": {
+ "node": ">=18"
+ },
+ "bin": {
+ "infographic-agent": "bin/infographic-agent.js"
+ },
+ "files": [
+ "bin/",
+ "portable_infographic.py",
+ "requirements.txt",
+ "SKILL.md"
+ ],
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/.agents/skills/infographic-agent/portable_infographic.py b/.agents/skills/infographic-agent/portable_infographic.py
new file mode 100755
index 0000000..bc33b6f
--- /dev/null
+++ b/.agents/skills/infographic-agent/portable_infographic.py
@@ -0,0 +1,854 @@
+#!/usr/bin/env python3
+"""
+Infographic Agent β portable skill (direct Gemini image generation)
+
+Turns any topic, note, or file of text into a polished infographic PNG using the
+exact same two-agent pipeline as the web demo:
+
+ 1. Research orchestrator (gemini-3.5-flash) β grounds the topic with Google
+ Search, then engineers a precise, text-accurate image-generation prompt.
+ 2. Image generator (gemini-3.1-flash-lite-image) β renders the prompt
+ directly into a PNG.
+
+There are NO browser or Playwright dependencies. Install with:
+
+ pip install google-genai pillow
+
+(google-genai is required; pillow is used to transcode the model's output to
+lossless PNG for crisp text β the script still runs without it, saving the
+model's native format instead.)
+
+Quick start:
+
+ # First run walks you through getting a free key from Google AI Studio
+ python3 portable_infographic.py "Top 5 programming languages in 2026"
+
+ # ...or set it yourself and go
+ export GEMINI_API_KEY="your-key"
+ python3 portable_infographic.py "Q2 sales highlights" --output sales.png
+
+After the first draft the CLI drops into an interactive refine loop so you can
+iterate ("make the header bolder", "use teal accents") and watch each revision
+update in seconds.
+"""
+
+import argparse
+import json
+import os
+import re
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+genai = None
+types = None
+
+
+def ensure_genai() -> None:
+ """Import the Gemini SDK only when an API call is about to run."""
+ global genai, types
+ if genai is not None and types is not None:
+ return
+ try:
+ from google import genai as imported_genai
+ from google.genai import types as imported_types
+ except ImportError:
+ sys.stderr.write(
+ "[Error] The Google GenAI SDK is not installed.\n"
+ " Install it with: pip install google-genai\n"
+ )
+ sys.exit(1)
+ genai = imported_genai
+ types = imported_types
+
+# --------------------------------------------------------------------------- #
+# Constants β keep the default model IDs in lockstep with the web demo (src/types.ts)
+# --------------------------------------------------------------------------- #
+
+ORCHESTRATOR_MODEL = "gemini-3.5-flash" # research + prompt engineering
+IMAGE_MODEL = "gemini-3.1-flash-lite-image" # direct infographic rendering
+QUALITY_IMAGE_MODEL = "gemini-3.1-flash-image" # skill-only quality option
+SUPPORTED_IMAGE_MODELS = (IMAGE_MODEL, QUALITY_IMAGE_MODEL)
+IMAGE_PROMPT_PREFIX = "Generate a professional infographic image"
+
+AISTUDIO_KEY_URL = "https://aistudio.google.com/apikey"
+CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "infographic-agent"
+CONFIG_PATH = CONFIG_DIR / "config.json"
+
+# Soft limit: larger inputs still work but burn tokens and rarely fit one poster.
+MAX_TEXT_CHARS = 20000
+
+RETRYABLE = ("408", "429", "500", "502", "503", "504")
+
+MODES = {
+ "data-story": "Data-forward layout with charts, graphs, statistical callouts, trend lines, and percentage highlights.",
+ "executive-summary": "Clean and minimal. Large headline numbers, 3-5 key takeaways, strategic insights, board-ready aesthetics.",
+ "technical-deep-dive": "Dense and precise. Architecture diagrams, code snippets in monospace, system-flow arrows, technical terminology.",
+ "classroom": "Friendly and illustrative. Numbered steps, visual analogies, approachable language, warm colors.",
+ "quick-slide": "Single-slide format with minimal text, high visual impact, presentation-ready large typography.",
+ "brandkit": "Premium brand identity board with clean presentation grids, logo cover mark, color swatches, typography specimens, UI mockups (browser chrome, phone crop, or terminal frame), and an art-directed atmospheric image against a charcoal or warm ivory canvas.",
+ "blog-post": "Editorial thumbnail/hero layout. Combine a punchy bold tagline, large typography, a single dramatic atmospheric/campaign image, clean text strings, and generous negative space to capture user attention.",
+ "portfolio-showcase": "Minimalist case-study layout. Emphasize a clean alignment grid, clear gutters, project milestones or key results, elegant font choices, and clean visual details like browser chrome or small footer labels.",
+ "custom": "",
+}
+
+# Supported aspect ratios (extended with editorial layouts 16:10 and 21:9)
+SUPPORTED_ASPECTS = {"1:1", "9:16", "16:9", "3:4", "4:3", "1:4", "16:10", "21:9"}
+
+
+# --------------------------------------------------------------------------- #
+# System prompts β mirrors the web demo's two agents (src/services/geminiService.ts)
+# --------------------------------------------------------------------------- #
+
+RESEARCH_SYSTEM_PROMPT = """
+You are an expert infographic architect and visual data designer. You transform
+raw content into an optimized image-generation prompt that produces a
+professional, text-accurate infographic.
+
+
+
+1. NEVER fabricate data, statistics, or claims.
+2. Every data point MUST come from the user's content or grounded Google Search results.
+3. If information is missing, use Google Search to gather real data from credible sources.
+4. Quote ALL text strings exactly as they should appear in the infographic.
+
+
+
+The "prompt" field you output is sent directly to an image-generation model. It must:
+- Start with: "Generate a professional infographic image"
+- Use positive framing only β describe what TO include, never negations.
+- Give step-by-step spatial instructions: "At the top, place X. Below that, add Y..."
+- Quote ALL text strings exactly, wrapped in quotation marks.
+- Specify exact colors using #hex values and describe typography (weight, size, style).
+- Include accessibility notes: minimum contrast 4.5:1 for normal text, 3:1 for large text.
+- Stay under 800 words β dense and precise, not verbose.
+- End with composition notes: spacing, alignment, professional polish.
+
+
+
+- data-story: Data-forward layout with charts, graphs, statistical callouts, trend lines, and percentage highlights.
+- executive-summary: Clean and minimal. Large headline numbers, 3-5 key takeaways, strategic insights, board-ready aesthetics.
+- technical-deep-dive: Dense and precise. Architecture diagrams, code snippets in monospace, system-flow arrows, technical terminology.
+- classroom: Friendly and illustrative. Numbered steps, visual analogies, approachable language, warm colors.
+- quick-slide: Single-slide format with minimal text, high visual impact, presentation-ready large typography.
+- brandkit: Premium brand identity board with clean presentation grids, logo cover mark, color swatches, typography specimens, UI mockups (browser chrome, phone crop, or terminal frame), and an art-directed atmospheric image against a charcoal or warm ivory canvas.
+- blog-post: Editorial thumbnail/hero layout. Combine a punchy bold tagline, large typography, a single dramatic atmospheric/campaign image, clean text strings, and generous negative space to capture user attention.
+- portfolio-showcase: Minimalist case-study layout. Emphasize a clean alignment grid, clear gutters, project milestones or key results, elegant font choices, and clean visual details like browser chrome or small footer labels.
+
+
+
+Respond with valid JSON only. No markdown fences. No extra text. Schema:
+{
+ "analysis": {
+ "title": "string β compelling infographic title",
+ "subtitle": "string β supporting subtitle",
+ "sectionsCount": number,
+ "dataPointsCount": number,
+ "brandColors": ["#hex", "#hex", "..."],
+ "sourceAttribution": "string β source credits"
+ },
+ "prompt": "string β the complete image-generation prompt following ",
+ "allTextStrings": ["every", "text", "string", "in", "the", "infographic"]
+}
+"""
+
+IMAGE_SYSTEM_PROMPT = """
+You are a professional infographic image generator. Render a high-quality
+infographic from the provided prompt.
+
+
+1. Render ALL quoted text exactly as written β spelling, capitalization, and punctuation must match perfectly.
+2. Fill the entire canvas β use the full aspect ratio with no empty borders.
+3. Never fabricate text that was not explicitly provided in the prompt.
+
+
+- Legible, professional fonts; crisp, clearly readable text; consistent font families.
+- Contrast: minimum 4.5:1 for normal text, 3:1 for large text.
+- Clear visual hierarchy via size, weight, and spacing. Balanced composition with intentional whitespace.
+- Padding & Gutters: Standard padding 5-8% on all edges, consistent spacing between sections (3-5%).
+- Details: Include clean structural elements like thin rules, browser chrome borders, or small labels where appropriate.
+"""
+
+REFINE_SYSTEM_PROMPT = """
+You are an infographic refinement specialist. You receive the current
+infographic image and a user's edit request.
+
+
+1. ONLY modify what the user explicitly requested β treat this as a diff-style edit.
+2. Preserve all elements, text, colors, spacing, and styling not mentioned in the request.
+3. Render ALL text with perfect fidelity unless the user specifically changes it.
+4. Return the complete refined infographic with the same dimensions and aspect ratio.
+"""
+
+
+# --------------------------------------------------------------------------- #
+# Small utilities
+# --------------------------------------------------------------------------- #
+
+def info(msg: str) -> None:
+ print(msg, flush=True)
+
+
+def warn(msg: str) -> None:
+ sys.stderr.write(f"[Warn] {msg}\n")
+
+
+def error(msg: str) -> None:
+ sys.stderr.write(f"[Error] {msg}\n")
+
+
+def _scrub(message: str) -> str:
+ """Never echo raw SDK errors verbatim: strip anything that looks like a credential."""
+ return re.sub(
+ r'((?:key=|x-goog-api-key[=:]\s*|Authorization[=:]\s*)"?)[^\s"&]+',
+ r"\1[REDACTED]",
+ str(message),
+ )
+
+
+def _is_transient(err: Exception) -> bool:
+ return any(code in str(err) for code in RETRYABLE)
+
+
+def _friendly_api_error(err: Exception) -> str:
+ s = str(err)
+ if "401" in s or "403" in s or "API_KEY_INVALID" in s or "PERMISSION_DENIED" in s:
+ return (
+ "Your Gemini API key is invalid or lacks permission.\n"
+ f" Get a fresh free key at {AISTUDIO_KEY_URL} and re-run with --setup."
+ )
+ if "429" in s or "RESOURCE_EXHAUSTED" in s:
+ return "Rate limit / quota exceeded. Wait a moment and try again."
+ return _scrub(s)
+
+
+def _word_count(text: str) -> int:
+ return len([w for w in text.strip().split() if w])
+
+
+def _as_string_list(value) -> list:
+ return [str(v).strip() for v in value if str(v).strip()] if isinstance(value, list) else []
+
+
+def _quoted_in_prompt(prompt: str, text: str) -> bool:
+ return f'"{text}"' in prompt
+
+
+def evaluate_prepare_result(plan: dict) -> list:
+ """Deterministic handoff checks matching the web app's Prepare eval gate."""
+ analysis = plan.get("analysis") if isinstance(plan.get("analysis"), dict) else {}
+ prompt = (plan.get("prompt") or "").strip()
+ all_text = _as_string_list(plan.get("allTextStrings"))
+ brand_colors = _as_string_list(analysis.get("brandColors"))
+
+ schema_issues = []
+ if not str(analysis.get("title") or "").strip():
+ schema_issues.append("missing title")
+ if not isinstance(analysis.get("subtitle"), str):
+ schema_issues.append("missing subtitle")
+ if not isinstance(analysis.get("sectionsCount"), (int, float)):
+ schema_issues.append("missing sectionsCount")
+ if not isinstance(analysis.get("dataPointsCount"), (int, float)):
+ schema_issues.append("missing dataPointsCount")
+ if not isinstance(analysis.get("brandColors"), list):
+ schema_issues.append("missing brandColors")
+ if not isinstance(analysis.get("sourceAttribution"), str):
+ schema_issues.append("missing sourceAttribution")
+ if not prompt:
+ schema_issues.append("missing prompt")
+ if not isinstance(plan.get("allTextStrings"), list):
+ schema_issues.append("missing allTextStrings")
+
+ invalid_colors = [c for c in brand_colors if not re.match(r"^#[0-9a-fA-F]{6}$", c)]
+ if invalid_colors:
+ schema_issues.append(f"invalid brand color {invalid_colors[0]}")
+
+ missing_quoted = [text for text in all_text if not _quoted_in_prompt(prompt, text)]
+ prompt_words = _word_count(prompt)
+
+ return [
+ {
+ "id": "schema",
+ "label": "Structured schema",
+ "status": "pass" if not schema_issues else "fail",
+ "detail": "Prepare output includes the required analysis fields, prompt, and text list."
+ if not schema_issues else "; ".join(schema_issues),
+ },
+ {
+ "id": "image-prompt",
+ "label": "Explicit image prompt",
+ "status": "pass" if prompt.startswith(IMAGE_PROMPT_PREFIX) else "fail",
+ "detail": "Prompt starts with the required image-generation request."
+ if prompt.startswith(IMAGE_PROMPT_PREFIX) else f'Prompt must start with "{IMAGE_PROMPT_PREFIX}".',
+ },
+ {
+ "id": "text-fidelity",
+ "label": "Text fidelity",
+ "status": "fail" if not all_text else "pass" if not missing_quoted else "warn",
+ "detail": "No final text strings were returned for the renderer."
+ if not all_text else "All final text strings are quoted in the renderer prompt."
+ if not missing_quoted else f"{len(missing_quoted)} text string(s) are not quoted exactly in the renderer prompt.",
+ },
+ {
+ "id": "grounding",
+ "label": "Grounding",
+ "status": "pass" if str(analysis.get("sourceAttribution") or "").strip() else "warn",
+ "detail": "Source attribution is present."
+ if str(analysis.get("sourceAttribution") or "").strip() else "Source attribution is empty; generated facts may be harder to audit.",
+ },
+ {
+ "id": "accessibility",
+ "label": "Accessibility",
+ "status": "pass" if re.search(r"\b(WCAG|contrast|accessib)", prompt, re.I) else "warn",
+ "detail": "Prompt includes contrast or accessibility instructions."
+ if re.search(r"\b(WCAG|contrast|accessib)", prompt, re.I) else "Prompt does not explicitly mention contrast or accessibility.",
+ },
+ {
+ "id": "prompt-length",
+ "label": "Prompt length",
+ "status": "pass" if prompt_words <= 800 else "warn" if prompt_words <= 1200 else "fail",
+ "detail": f"Prompt is {prompt_words} words, within the 800-word target."
+ if prompt_words <= 800 else f"Prompt is {prompt_words} words; target is 800 words for renderer reliability."
+ if prompt_words <= 1200 else f"Prompt is {prompt_words} words; shorten before rendering.",
+ },
+ ]
+
+
+def validate_prepare_result(plan: dict) -> dict:
+ checks = evaluate_prepare_result(plan)
+ failures = [c for c in checks if c["status"] == "fail"]
+ if failures:
+ details = " ".join(f'{c["label"]}: {c["detail"]}' for c in failures)
+ raise ValueError(f"Prepare result failed quality gates: {details}")
+ plan["qualityChecks"] = checks
+ return plan
+
+
+def infer_title(content: str) -> str:
+ for line in content.splitlines():
+ cleaned = re.sub(r"^[#*\-\s]+", "", line).strip()
+ if cleaned:
+ return cleaned[:80]
+ return "Infographic"
+
+
+def normalize_prepare_plan(plan: dict, content: str, mode: str, extra: str) -> dict:
+ """Accept the current web schema and repair older two-field plans when possible."""
+ if not isinstance(plan, dict):
+ plan = {}
+
+ prompt = (plan.get("prompt") or "").strip()
+ title = ""
+ if isinstance(plan.get("analysis"), dict):
+ title = str(plan["analysis"].get("title") or "").strip()
+ title = title or str(plan.get("title") or "").strip() or infer_title(content)
+
+ analysis = plan.get("analysis") if isinstance(plan.get("analysis"), dict) else {}
+ normalized = {
+ "analysis": {
+ "title": title,
+ "subtitle": str(analysis.get("subtitle") or MODES.get(mode, "") or "Visual summary").strip(),
+ "sectionsCount": analysis.get("sectionsCount") if isinstance(analysis.get("sectionsCount"), (int, float)) else 3,
+ "dataPointsCount": analysis.get("dataPointsCount") if isinstance(analysis.get("dataPointsCount"), (int, float)) else len(re.findall(r"\d+(?:\.\d+)?%?", content)),
+ "brandColors": analysis.get("brandColors") if isinstance(analysis.get("brandColors"), list) else ["#4285F4", "#34A853", "#FBBC04"],
+ "sourceAttribution": str(analysis.get("sourceAttribution") or "User-provided content").strip(),
+ },
+ "prompt": prompt,
+ "allTextStrings": plan.get("allTextStrings") if isinstance(plan.get("allTextStrings"), list) else [title],
+ }
+
+ if extra and extra not in normalized["prompt"]:
+ normalized["prompt"] = f'{normalized["prompt"]}\n\nAdditional instructions: {extra}'
+
+ return normalized
+
+
+def direct_prepare_plan(content: str, mode: str, extra: str) -> dict:
+ """Build a renderer-ready PrepareResult without the research agent."""
+ title = infer_title(content)
+ mode_hint = MODES.get(mode, "")
+ prompt = (
+ f'Generate a professional infographic image. At the top, place "{title}" as the main title. '
+ "Clearly and accurately visualize the provided content using a clean modern layout with strong visual hierarchy, "
+ "legible typography, accessible color contrast (minimum 4.5:1), and polished spacing. "
+ "Use #4285F4 as the primary color, #34A853 as the secondary color, and #FBBC04 as an accent. "
+ "Render all quoted text exactly as written. "
+ + (f"Style: {mode_hint}. " if mode_hint else "")
+ + (f"Additional instructions: {extra}. " if extra else "")
+ + f"Source content to summarize visually: {content}"
+ )
+ return validate_prepare_result({
+ "analysis": {
+ "title": title,
+ "subtitle": mode_hint or "Visual summary",
+ "sectionsCount": 3,
+ "dataPointsCount": len(re.findall(r"\d+(?:\.\d+)?%?", content)),
+ "brandColors": ["#4285F4", "#34A853", "#FBBC04"],
+ "sourceAttribution": "User-provided content",
+ },
+ "prompt": prompt,
+ "allTextStrings": [title],
+ })
+
+
+def print_prepare_eval(plan: dict) -> None:
+ checks = plan.get("qualityChecks") or []
+ if not checks:
+ return
+ passed = sum(1 for c in checks if c.get("status") == "pass")
+ warnings = [c for c in checks if c.get("status") == "warn"]
+ info(f"β Prepare evals: {passed}/{len(checks)} passed" + (f", {len(warnings)} warning(s)" if warnings else ""))
+ for check in warnings:
+ warn(f'{check["label"]}: {check["detail"]}')
+
+
+# --------------------------------------------------------------------------- #
+# API key onboarding β the "one-click, free from AI Studio" flow
+# --------------------------------------------------------------------------- #
+
+def _load_saved_key() -> str:
+ try:
+ if CONFIG_PATH.exists():
+ data = json.loads(CONFIG_PATH.read_text())
+ return (data.get("gemini_api_key") or "").strip()
+ except Exception:
+ pass
+ return ""
+
+
+def _save_key(key: str) -> None:
+ try:
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
+ CONFIG_PATH.write_text(json.dumps({"gemini_api_key": key}, indent=2))
+ # Lock the file down β it holds a secret.
+ os.chmod(CONFIG_PATH, 0o600)
+ info(f"β Saved your key to {CONFIG_PATH} (readable only by you).")
+ except Exception as e:
+ warn(f"Could not save key to {CONFIG_PATH}: {e}. It will work for this run only.")
+
+
+def _try_open_url(url: str) -> bool:
+ """Open a URL in the default browser. Returns True if a launcher was invoked."""
+ try:
+ if sys.platform == "darwin":
+ subprocess.Popen(["open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ elif os.name == "nt":
+ os.startfile(url) # type: ignore[attr-defined]
+ else:
+ subprocess.Popen(["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ return True
+ except Exception:
+ return False
+
+
+def interactive_setup() -> str:
+ """Walk the user through getting a free Gemini API key and save it."""
+ info(
+ "\nββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ\n"
+ " Let's get you a FREE Gemini API key (takes ~20 seconds)\n"
+ "ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ\n"
+ f" 1. Open {AISTUDIO_KEY_URL}\n"
+ " 2. Sign in with any Google account (free β no billing required)\n"
+ ' 3. Click "Create API key", then copy it\n'
+ )
+
+ if sys.stdin.isatty():
+ try:
+ answer = input(" Open that page in your browser now? [Y/n] ").strip().lower()
+ except (EOFError, KeyboardInterrupt):
+ answer = "n"
+ if answer in ("", "y", "yes"):
+ if _try_open_url(AISTUDIO_KEY_URL):
+ info(" β Opened your browser.")
+ else:
+ info(f" β Could not auto-open a browser. Visit {AISTUDIO_KEY_URL} manually.")
+
+ if not sys.stdin.isatty():
+ error(
+ "No Gemini API key found and no interactive terminal to prompt in.\n"
+ f" Set one with: export GEMINI_API_KEY=\"your-key\" (get it free at {AISTUDIO_KEY_URL})"
+ )
+ sys.exit(1)
+
+ try:
+ import getpass
+ key = getpass.getpass(" Paste your API key here (hidden), then press Enter: ").strip()
+ except (EOFError, KeyboardInterrupt):
+ info("")
+ sys.exit(1)
+
+ if not key:
+ error("No key entered. Re-run and paste your key, or set GEMINI_API_KEY.")
+ sys.exit(1)
+
+ _save_key(key)
+ return key
+
+
+def resolve_api_key(force_setup: bool = False) -> str:
+ """Resolve the Gemini API key from env β saved config β interactive setup."""
+ if force_setup:
+ return interactive_setup()
+
+ key = (os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY") or "").strip()
+ if key:
+ return key
+
+ key = _load_saved_key()
+ if key:
+ return key
+
+ # Vertex AI is still supported for enterprise users who prefer it.
+ if os.environ.get("GOOGLE_CLOUD_PROJECT"):
+ return "" # signal: use Vertex path
+
+ return interactive_setup()
+
+
+def build_client(api_key: str) -> "genai.Client":
+ ensure_genai()
+ if api_key:
+ return genai.Client(api_key=api_key)
+ # Vertex AI fallback (GOOGLE_CLOUD_PROJECT is set).
+ project = os.environ["GOOGLE_CLOUD_PROJECT"]
+ location = os.environ.get("GOOGLE_CLOUD_LOCATION", "us-central1")
+ if location == "global":
+ location = "us-central1"
+ return genai.Client(vertexai=True, project=project, location=location)
+
+
+# --------------------------------------------------------------------------- #
+# Agent 1 β research orchestrator (gemini-3.5-flash + Google Search grounding)
+# --------------------------------------------------------------------------- #
+
+def research_prompt(client, content: str, mode: str, aspect: str, extra: str) -> dict:
+ """Ground the topic and engineer a precise image-generation prompt."""
+ mode_hint = MODES.get(mode, "")
+ user_prompt = "\n".join(
+ p for p in [
+ "Analyze the content below and produce the JSON described in your instructions. "
+ "Use Google Search to fill gaps or verify facts.",
+ f"Mode: {mode}{(' β ' + mode_hint) if mode_hint else ''}",
+ f"Aspect ratio: {aspect}",
+ f"Additional instructions: {extra}" if extra else None,
+ "",
+ "",
+ content,
+ "",
+ ] if p is not None
+ )
+
+ config = types.GenerateContentConfig(
+ system_instruction=RESEARCH_SYSTEM_PROMPT,
+ temperature=0.7,
+ tools=[types.Tool(google_search=types.GoogleSearch())],
+ http_options=types.HttpOptions(timeout=180_000),
+ )
+
+ info("π Researching and planning the infographic (gemini-3.5-flash)...")
+ last_error = None
+ for attempt in range(3):
+ try:
+ response = client.models.generate_content(
+ model=ORCHESTRATOR_MODEL, contents=user_prompt, config=config
+ )
+ plan = validate_prepare_result(normalize_prepare_plan(_parse_json(response.text or ""), content, mode, extra))
+ title = plan["analysis"]["title"]
+ if not plan["prompt"]:
+ raise ValueError("research agent returned no prompt")
+ if title:
+ info(f' β³ "{title}"')
+ print_prepare_eval(plan)
+ return plan
+ except Exception as e: # noqa: BLE001
+ last_error = e
+ if _is_transient(e) and attempt < 2:
+ wait = 2 ** (attempt + 1)
+ warn(f"Transient API error, retrying in {wait}s...")
+ time.sleep(wait)
+ else:
+ break
+
+ # Research is a best-effort enhancement: fall back to a direct prompt so the
+ # user still gets an image instead of a hard failure.
+ warn(f"Research step failed ({_friendly_api_error(last_error)}). Falling back to a direct prompt.")
+ plan = direct_prompt(content, mode, extra)
+ print_prepare_eval(plan)
+ return plan
+
+
+def direct_prompt(content: str, mode: str, extra: str) -> dict:
+ """Build an image prompt without the research agent (used by --no-research / fallback)."""
+ plan = direct_prepare_plan(content, mode, extra)
+ print_prepare_eval(plan)
+ return plan
+
+
+def _parse_json(text: str) -> dict:
+ cleaned = text.strip()
+ if cleaned.startswith("```"):
+ cleaned = re.sub(r"^```(?:json)?\s*\n?", "", cleaned)
+ cleaned = re.sub(r"\n?```\s*$", "", cleaned)
+ return json.loads(cleaned)
+
+
+# --------------------------------------------------------------------------- #
+# Agent 2 β image generator (gemini-3.1-flash-lite-image)
+# --------------------------------------------------------------------------- #
+
+def _extract_image(response):
+ """Return (image_bytes, mime_type) from a Gemini image response."""
+ parts = (response.candidates[0].content.parts if response.candidates else None) or []
+ for part in parts:
+ inline = getattr(part, "inline_data", None)
+ if inline and inline.data:
+ return inline.data, (inline.mime_type or "image/png")
+ raise RuntimeError("The model did not return an image. Try rephrasing your topic and generate again.")
+
+
+def _normalize_to_png(data: bytes, mime: str):
+ """Convert the model's output to lossless PNG so text stays crisp.
+
+ The Gemini Developer API returns JPEG for gemini-3.1-flash-lite-image and
+ does not allow forcing the output format, so we transcode here. Pillow keeps
+ the install tiny; if it is somehow missing we fall back to the native bytes
+ (with an honest extension) rather than hard-failing.
+ """
+ m = (mime or "").lower()
+ if m in ("image/png", ""):
+ return data, "image/png"
+ try:
+ import io
+ from PIL import Image
+
+ img = Image.open(io.BytesIO(data))
+ if img.mode not in ("RGB", "RGBA"):
+ img = img.convert("RGB")
+ buf = io.BytesIO()
+ img.save(buf, format="PNG")
+ return buf.getvalue(), "image/png"
+ except ImportError:
+ warn("Pillow not installed β saving the model's native format. "
+ "Run 'pip install pillow' for guaranteed lossless PNG output.")
+ return data, m
+ except Exception as e: # noqa: BLE001 β never let transcoding lose the image
+ warn(f"Could not transcode to PNG ({type(e).__name__}); saving native format.")
+ return data, m
+
+
+def _image_config(aspect: str, resolution: str):
+ """Use newer quality controls only when the installed SDK supports them."""
+ fields = getattr(types.ImageConfig, "model_fields", {})
+ kwargs = {"aspect_ratio": aspect}
+ if "image_size" in fields:
+ kwargs["image_size"] = resolution
+ else:
+ warn("Installed google-genai does not expose image_size; using the model's native resolution.")
+ return types.ImageConfig(**kwargs)
+
+
+def _thinking_config():
+ fields = getattr(types.ThinkingConfig, "model_fields", {})
+ if "thinking_level" in fields:
+ return types.ThinkingConfig(thinking_level="HIGH", include_thoughts=True)
+ return types.ThinkingConfig(thinking_budget=-1, include_thoughts=True)
+
+
+def generate_image(client, prompt: str, aspect: str, image_model: str, resolution: str):
+ config = types.GenerateContentConfig(
+ response_modalities=["TEXT", "IMAGE"],
+ image_config=_image_config(aspect, resolution),
+ thinking_config=_thinking_config(),
+ http_options=types.HttpOptions(timeout=180_000),
+ )
+ info(f"π¨ Generating the infographic ({image_model}) at {resolution}...")
+ return _call_image_model(
+ client,
+ contents=[f"{prompt}\n\n{IMAGE_SYSTEM_PROMPT}"],
+ config=config,
+ image_model=image_model,
+ )
+
+
+def refine_image(client, image_bytes: bytes, mime: str, instruction: str, aspect: str, image_model: str, resolution: str):
+ config = types.GenerateContentConfig(
+ response_modalities=["TEXT", "IMAGE"],
+ image_config=_image_config(aspect, resolution),
+ thinking_config=_thinking_config(),
+ http_options=types.HttpOptions(timeout=180_000),
+ )
+ contents = [
+ types.Part.from_bytes(data=image_bytes, mime_type=mime or "image/png"),
+ types.Part.from_text(
+ text=(
+ "The attached image is the current infographic.\n"
+ f"{instruction}\n\n{REFINE_SYSTEM_PROMPT}"
+ )
+ ),
+ ]
+ return _call_image_model(client, contents=contents, config=config, image_model=image_model)
+
+
+def _call_image_model(client, contents, config, image_model: str):
+ """Call the image model with retries; return (png_bytes, mime_type)."""
+ last_error = None
+ for attempt in range(3):
+ try:
+ response = client.models.generate_content(
+ model=image_model, contents=contents, config=config
+ )
+ data, mime = _extract_image(response)
+ return _normalize_to_png(data, mime)
+ except Exception as e: # noqa: BLE001
+ last_error = e
+ if _is_transient(e) and attempt < 2:
+ wait = 2 ** (attempt + 1)
+ warn(f"Transient API error, retrying in {wait}s...")
+ time.sleep(wait)
+ else:
+ break
+ raise RuntimeError(_friendly_api_error(last_error))
+
+
+# --------------------------------------------------------------------------- #
+# Output handling
+# --------------------------------------------------------------------------- #
+
+_EXT_BY_MIME = {"image/png": ".png", "image/jpeg": ".jpg", "image/jpg": ".jpg", "image/webp": ".webp"}
+_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ""}
+
+
+def save_image(image_bytes: bytes, mime: str, output_path: str) -> str:
+ """Write the image, ensuring the extension matches the actual format."""
+ correct_ext = _EXT_BY_MIME.get((mime or "").lower(), ".png")
+ path = os.path.realpath(output_path)
+ root, ext = os.path.splitext(path)
+ # Swap a mismatched image extension (or none) for the true one; otherwise append.
+ path = (root + correct_ext) if ext.lower() in _IMAGE_EXTS else (path + correct_ext)
+ parent = os.path.dirname(path)
+ if parent and not os.path.isdir(parent):
+ error(f"Output directory does not exist: {parent}")
+ sys.exit(1)
+ with open(path, "wb") as f:
+ f.write(image_bytes)
+ return path
+
+
+def open_output(path: str) -> None:
+ if _try_open_url(path):
+ info(" β³ Opened in your default image viewer.")
+
+
+# --------------------------------------------------------------------------- #
+# Interactive refine loop β fast, iterative preview
+# --------------------------------------------------------------------------- #
+
+def refine_loop(client, image_bytes: bytes, mime: str, output_path: str, aspect: str, auto_open: bool, image_model: str, resolution: str) -> None:
+ info(
+ "\n㪠Refine it, or press Enter to finish.\n"
+ ' e.g. "make the header bolder", "use teal accents", "add source citations"'
+ )
+ revision = 1
+ base, _ = os.path.splitext(output_path)
+ while True:
+ try:
+ instruction = input("\nRefine βΊ ").strip()
+ except (EOFError, KeyboardInterrupt):
+ info("")
+ break
+ if not instruction or instruction.lower() in ("q", "quit", "exit", "done"):
+ break
+ try:
+ image_bytes, mime = refine_image(client, image_bytes, mime, instruction, aspect, image_model, resolution)
+ except Exception as e: # noqa: BLE001
+ error(_scrub(str(e)))
+ continue
+ revision += 1
+ rev_path = save_image(image_bytes, mime, f"{base}-v{revision}")
+ info(f"β Saved revision {revision}: {rev_path}")
+ if auto_open:
+ open_output(rev_path)
+
+
+# --------------------------------------------------------------------------- #
+# CLI
+# --------------------------------------------------------------------------- #
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="infographic-agent",
+ description="Generate a professional infographic PNG directly with Gemini "
+ "(research orchestrator + image model). No browser dependencies.",
+ )
+ parser.add_argument("topic", nargs="?", help="Topic or content to visualize")
+ parser.add_argument("--text", help="Alternative to the positional topic argument")
+ parser.add_argument("--output", "-o", default="infographic.png", help="Output PNG path (default: infographic.png)")
+ parser.add_argument("--mode", "-m", default="data-story", choices=sorted(MODES.keys()),
+ help="Infographic style (default: data-story)")
+ parser.add_argument("--aspect", "-a", default="9:16", choices=sorted(SUPPORTED_ASPECTS),
+ help="Aspect ratio (default: 9:16)")
+ parser.add_argument("--resolution", "-r", default="1K", choices=["0.5K", "1K", "2K"],
+ help="Image resolution / size to request from the API (default: 1K)")
+ parser.add_argument("--instructions", "-i", default="", help="Extra styling / content instructions")
+ parser.add_argument("--image-model", default=IMAGE_MODEL, choices=SUPPORTED_IMAGE_MODELS,
+ help="Image model for the portable skill (default: gemini-3.1-flash-lite-image)")
+ parser.add_argument("--no-research", action="store_true",
+ help="Skip the research agent and generate directly from your text")
+ parser.add_argument("--no-open", action="store_true", help="Do not auto-open the result")
+ parser.add_argument("--yes", "-y", action="store_true", help="Non-interactive: generate once and exit (no refine loop)")
+ parser.add_argument("--setup", action="store_true", help="(Re)configure your Gemini API key and exit")
+ return parser
+
+
+def main() -> None:
+ parser = build_parser()
+ args = parser.parse_args()
+
+ if args.setup:
+ interactive_setup()
+ info("\nβ You're all set. Generate one with:\n"
+ ' infographic-agent "Top 5 programming languages in 2026"')
+ return
+
+ content = (args.text or args.topic or "").strip()
+ if not content:
+ parser.print_help()
+ sys.exit(1)
+
+ if len(content) > MAX_TEXT_CHARS:
+ warn(f"Input is {len(content)} chars (> {MAX_TEXT_CHARS}); large inputs rarely fit one poster and burn tokens.")
+
+ api_key = resolve_api_key()
+ try:
+ client = build_client(api_key)
+ except Exception as e: # noqa: BLE001
+ error(_scrub(str(e)))
+ sys.exit(1)
+
+ try:
+ if args.no_research:
+ plan = direct_prompt(content, args.mode, args.instructions)
+ else:
+ plan = research_prompt(client, content, args.mode, args.aspect, args.instructions)
+ image_bytes, mime = generate_image(client, plan["prompt"], args.aspect, args.image_model, args.resolution)
+ except Exception as e: # noqa: BLE001
+ error(_friendly_api_error(e))
+ sys.exit(1)
+
+ path = save_image(image_bytes, mime, args.output)
+ info(f"\nβ Saved infographic: {path}")
+
+ auto_open = not args.no_open
+ if auto_open:
+ open_output(path)
+
+ interactive = not args.yes and sys.stdin.isatty()
+ if interactive:
+ refine_loop(client, image_bytes, mime, path, args.aspect, auto_open, args.image_model, args.resolution)
+
+ info("\nDone. π")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.agents/skills/infographic-agent/requirements.txt b/.agents/skills/infographic-agent/requirements.txt
new file mode 100644
index 0000000..bb94f99
--- /dev/null
+++ b/.agents/skills/infographic-agent/requirements.txt
@@ -0,0 +1,4 @@
+# Runtime dependencies. Install with: pip install -r requirements.txt
+# (or: pip install google-genai pillow)
+google-genai>=1.0.0 # Gemini SDK β the two-agent research + image pipeline
+pillow>=10.0.0 # transcodes the model's output to lossless PNG (crisp text)
diff --git a/.agents/skills/portfolio-content/SKILL.md b/.agents/skills/portfolio-content/SKILL.md
index d6aa451..6570b35 100644
--- a/.agents/skills/portfolio-content/SKILL.md
+++ b/.agents/skills/portfolio-content/SKILL.md
@@ -21,6 +21,7 @@ string. Common keys:
| `featured` | work | `true` puts the entry on the home page |
| `org`, `role`, `period`, `tags`, `links` | work | Card + case-study header |
| `date`, `external` | writing | `external: ` = outbound link, no page |
+| `slug`, `aliases` | detail pages | Pin the canonical slug and list old root-relative paths that must permanently redirect to it |
| `draft`, `noindex`, `publishAt` | writing | Drafts stay private to `/writer/`; `publishAt` is an explicit UTC timestamp gate |
| `venue`, `type`, `links` | talks | Row metadata; `type` is free-form |
| `eyebrow` | pages | Label above the page title |
@@ -47,6 +48,16 @@ the build rebases them if the site is mounted under a subpath.
Markdown headings receive stable fragment IDs. Authors may pin one with `## Heading {#stable-id}`. Tables, fenced code language labels, blockquotes, images, lists, emphasis, and links are supported.
+When renaming a published detail page, set its new `slug`, add every previous path to `aliases`, and update `canonical`. The build writes `redirects.json`; the gateway turns each alias into an HTTP 308 and preserves the query string. Never leave the old page rendered as duplicate content.
+
+Every hosted essay needs three purposeful visual assets before publication:
+
+1. A dedicated 1200Γ675 header that explains the thesis without repeating the title.
+2. A distinct 1200Γ627 raster social card composed for thumbnail legibility.
+3. At least one dedicated 1200Γ675 inline image that shows a mechanism, artifact, or evidence from the argument.
+
+Write asset-specific alt text for each. Do not reuse a generic portfolio preview, title card, or the header as the inline evidence image.
+
## Adding a new content type
1. Add `{ name, label, listPage, detailPages }` to `COLLECTIONS` in `build.mjs`.
@@ -56,6 +67,8 @@ Markdown headings receive stable fragment IDs. Authors may pin one with `## Head
## Verify
+Run the `portfolio-review` skill before publication. Its claim, link, canonical, redirect, image, metadata, browser, and independent-review gates are required in addition to the build commands below.
+
```bash
node build.mjs # prints the page count
BASE_PATH=/writer/ PORTFOLIO_WRITER_MODE=true PORTFOLIO_DIST_DIR=writer-dist node build.mjs
diff --git a/.agents/skills/portfolio-design/SKILL.md b/.agents/skills/portfolio-design/SKILL.md
index fc77f9d..7139137 100644
--- a/.agents/skills/portfolio-design/SKILL.md
+++ b/.agents/skills/portfolio-design/SKILL.md
@@ -41,6 +41,10 @@ description: How Ryan designs. Use before changing style.css, page layouts in bu
- `prefers-reduced-motion` kills all transitions.
- Contrast: text tokens must hold β₯4.5:1 against `--bg` in both schemes: check both when touching tokens.
+## Review gate
+
+Run the `portfolio-review` skill for every public visual change. Verify file signatures and dimensions rather than trusting extensions, inspect desktop and mobile renders, and use an independent visual reviewer. For generated essay visuals, archive the final prompt and settings; the header, social preview, and inline evidence image must have distinct jobs.
+
## Lessons
A past UI pass added a Google Fonts import, 3D tilt card hovers, gradient hero text, and scroll-reveal animations. None of that matched this skill: system fonts, a simple border-and-lift hover, novelty spent on writing, not chrome. It got pulled back out. Read this skill before touching `style.css` or page layouts. If you want to deviate on purpose, update this skill in the same PR, don't just drift from it.
diff --git a/.agents/skills/portfolio-review/SKILL.md b/.agents/skills/portfolio-review/SKILL.md
new file mode 100644
index 0000000..d4db682
--- /dev/null
+++ b/.agents/skills/portfolio-review/SKILL.md
@@ -0,0 +1,68 @@
+---
+name: portfolio-review
+description: Audit publishable portfolio copy, content completeness, claims, links, canonicals, redirects, images, social metadata, accessibility, and rendered presentation. Use for every public content addition or meaningful edit before it is considered ready for review or publication.
+---
+
+# Portfolio review
+
+Treat review as a bounded evidence loop, not a vibe check. Read the portfolio writing, design, and content skills before reviewing their surfaces.
+
+## 1. Inventory the change
+
+- Run `git status --short` before work and inspect the focused diff.
+- List every changed public claim, link, canonical or alias, image, alt string, and metadata field.
+- Identify the intended reader action and the one idea each visual must communicate.
+- Use `docs/PORTFOLIO_EVIDENCE_LEDGER.md` for existing claims. Add durable new evidence there when a material claim will recur.
+
+## 2. Audit copy and claims
+
+- Lead with the outcome and concrete work. Cut throat-clearing, generic summaries, repeated conclusions, and unsupported superlatives.
+- Check every number, date range, attribution, role, causal statement, and current product fact against a primary source, checked-in artifact, or Ryan-approved internal evidence.
+- Distinguish observed correlation from causation. Put unrelated evidence in separately labeled groups.
+- Credit team work accurately. Remove or qualify anything the evidence does not support.
+- Verify that the opening states the thesis, each section advances it, and the ending tells the reader what to do.
+- Search the changed prose for banned voice patterns such as passive self-credit, hype adjectives, resume bullets, and em dashes; then perform a human-quality read because style cannot be proven by a regex.
+
+## 3. Audit links and URL ownership
+
+- Run the portfolio build to validate internal links, assets, duplicate slugs, canonical rules, and aliases.
+- Open material external links or fetch them with a real GET. Confirm the destination supports the adjacent claim; do not treat a successful status alone as evidence.
+- For a renamed page, verify the new canonical and social URLs in rendered HTML and test every old path, with and without a trailing slash, for an HTTP 308 to the clean root-relative target. Confirm query strings survive.
+- Keep one canonical owner. Do not publish duplicate local and external canonicals.
+
+## 4. Audit images and metadata
+
+- Require every hosted essay to have three distinct assets: a 1200x675 thesis header, a 1200x627 social preview, and at least one 1200x675 inline mechanism or evidence image.
+- Prefer real screenshots and artifacts. Generated visuals must show a mechanism or evidence, never invent metrics, UI, logos, or product behavior.
+- Iterate with the lower-cost image model; render the approved final prompt with the quality model. Save exact prompts, model IDs, aspect ratio, size, thinking setting, and post-processing in `docs/`.
+- Inspect file signatures with `file`; never trust an extension. Verify physical dimensions and generated HTML `width`/`height` attributes.
+- Write literal, asset-specific alt text. Do not reuse the header description for the social or inline image.
+- Inspect the rendered page at desktop and narrow mobile widths. Check legibility, crop, padding, hierarchy, dark/light behavior when relevant, and whether the image earns its space.
+
+## 5. Run the maker/checker loop
+
+For every publishable content change, use at least one independent read-only reviewer after the maker pass. For an essay or multi-surface page, split review into these lanes when agents are available:
+
+1. Copy and claim integrity.
+2. Links, canonical ownership, redirects, and metadata.
+3. Visual taste, image truthfulness, accessibility, desktop, and mobile rendering.
+
+Give reviewers the raw diff, rendered page, and assets. Do not give them the intended verdict. Ask for actionable blockers, not rewritten taste preferences.
+
+After each review round: classify findings, make one focused correction pass, rerun deterministic checks, and request a fresh read-only review. Stop after at most three rounds. Stop earlier when all lanes are clean. If a material claim, source, or design decision remains unresolved, return `NEEDS_HUMAN`; do not average reviewer opinions or declare victory.
+
+Use deterministic tools and lower-cost models for inventory and mechanical checks, a balanced model for edits, and the strongest justified model only for final cross-surface synthesis or unresolved ambiguity. The maker is never the only grader.
+
+## 6. Required verification
+
+Run the narrowest relevant checks, then the complete content path when practical:
+
+```bash
+cd portfolio && npm test && npm run build
+cd ../gateway && npm test
+cd .. && node scripts/build-local.mjs && node scripts/smoke.mjs
+```
+
+Also verify changed image signatures and dimensions, inspect desktop/mobile browser captures, and test affected redirects with HTTP requests. Report every command and result. Do not claim browser, link, or image review without captured evidence.
+
+Finish only when the diff is focused, the worktree contains no secrets or accidental generated files, documentation is updated, independent review is clean, and deterministic checks pass or their exact limitation is disclosed.
diff --git a/.agents/skills/portfolio-review/agents/openai.yaml b/.agents/skills/portfolio-review/agents/openai.yaml
new file mode 100644
index 0000000..1a7ed68
--- /dev/null
+++ b/.agents/skills/portfolio-review/agents/openai.yaml
@@ -0,0 +1,4 @@
+interface:
+ display_name: "Portfolio Review"
+ short_description: "Audit portfolio copy, claims, links, and visuals"
+ default_prompt: "Audit this portfolio content end to end with deterministic checks and a bounded independent-review loop."
diff --git a/.agents/skills/portfolio-writing/SKILL.md b/.agents/skills/portfolio-writing/SKILL.md
index 1465b5a..b3378d9 100644
--- a/.agents/skills/portfolio-writing/SKILL.md
+++ b/.agents/skills/portfolio-writing/SKILL.md
@@ -32,6 +32,10 @@ Three sections, in order, each 1β2 short paragraphs:
- Evidence from real work β link the artifact every time.
- End with what to do about it, not a summary.
+## Review gate
+
+Before calling public prose ready, run the `portfolio-review` skill. Inventory every material claim, verify it against `docs/PORTFOLIO_EVIDENCE_LEDGER.md` or a primary source, check attribution and causality, then use an independent copy/claims reviewer. Unsupported copy is removed or qualified, never polished into plausibility.
+
## Syndicating a post to Substack
Posts live here first. This site owns the canonical URL, has an RSS feed at
diff --git a/AGENTS.md b/AGENTS.md
index 80741ec..94ca430 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -204,6 +204,7 @@ Use these repo-local skills when the task matches their scope:
- `.agents/skills/google-maps-js-2d/SKILL.md` for focused Maps JavaScript API 2D Maps work: loader/imports, vector maps, Advanced Markers, overlays, deck.gl, WebGLOverlayView, Places widgets, and Mapbox-to-Google migration work.
- `.agents/skills/google-maps-environment-apis/SKILL.md` for Google Maps Platform Environment APIs: Air Quality, Pollen, Solar, Weather, environmental heatmap tiles, quota, caching, source labeling, and environmental-data migrations.
- `.agents/skills/frontend-responsive-design/SKILL.md` for responsive layout, accessibility, CSS architecture, Tailwind utility usage, and visual QA work.
+- `.agents/skills/portfolio-review/SKILL.md` for the mandatory pre-publication audit of copy, claims, links, canonicals, redirects, images, metadata, accessibility, and rendered desktop/mobile output.
## The agentic loop
@@ -213,6 +214,8 @@ Use these repo-local skills when the task matches their scope:
4. Before finishing, update `CHANGELOG.md` (every user-visible or behavioral change) and `LEARNINGS.md` (every surprise, root-caused bug, or environment gotcha, using the Context/Learning/Evidence/Use next time format).
5. If a learning is durable, fold it into the matching skill in the same PR. The changelog records what happened, the learning log records why, and skills encode what to do next time.
+Every publishable portfolio content change must also run the `portfolio-review` skill. Use a maker/checker loop with at least one independent read-only reviewer; essays and multi-surface pages should separate copy/claims, links/URL ownership, and visual/rendered QA when agents are available. Deterministic checks run before and after each correction pass. Stop after at most three review rounds, stop earlier when all lanes are clean, and escalate unresolved evidence or taste decisions to Ryan instead of self-approving.
+
## Pull Request Expectations
- Summarize changed behavior and cite touched files.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c679c1e..b945ee1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,16 @@
# Changelog
+## 2026-07-15: DevX essay rewrite and durable URL redirects
+
+- Renamed the first essay route from `devex` to `devx`, updated its canonical metadata, and added an HTTP 308 alias so existing links keep working with query strings intact.
+- Rewrote the essay around owning developer friction, distributing improvements into builder workflows, and measuring outcomes with product evidence and agent evaluations.
+- Replaced the oversized text-heavy hero with a 16:9 growth-loop diagram, added a separate 1200Γ627 social card, and embedded a purpose-built agent-evaluation loop in the article.
+- Added reusable front-matter aliases for future detail-page renames and documented the workflow in the portfolio content skill.
+- Hardened aliases against slashless duplicates, canonical-page collisions, private-route disclosure, stale canonicals, external targets, and bodyless entries.
+- Installed Infographic Agent 3.2.0 and documented the required three-asset visual standard for every future hosted essay.
+- Archived the exact final image prompts and settings, including Gemini 3.1 Flash Image at 1K with high thinking, so all three visuals can be reproduced.
+- Added a mandatory portfolio review workflow for every publishable content change: deterministic copy, claim, link, URL, image, metadata, and browser checks plus a bounded independent maker/checker loop.
+
## 2026-07-14: DevEx is a Growth Discipline Essay Update
- Published the rewritten "DevEx is a Growth Discipline" essay, focusing on the "builder experience" and scaling AI agents context deliverables.
diff --git a/LEARNINGS.md b/LEARNINGS.md
index 8c09c43..d8a5194 100644
--- a/LEARNINGS.md
+++ b/LEARNINGS.md
@@ -1,5 +1,12 @@
# Learnings
+## 2026-07-15: Published content renames need server redirects, not duplicate pages
+
+Context: The first essay used `devex` in its public URL, while the preferred term and future title are `DevX`.
+Learning: Changing a slug and canonical tag is not enough. Published links need one declarative alias that the static build validates and the runtime turns into a permanent redirect. Rendering HTML at both paths creates duplicate content and does not guarantee an HTTP redirect.
+Evidence: The build now emits `redirects.json` from front-matter aliases, the gateway returns HTTP 308 while preserving the query string, and tests cover the runtime behavior.
+Use next time: When renaming a work, writing, or talk detail page, set the new `slug`, append the previous path to `aliases`, update `canonical`, and verify both the new 200 response and old 308 response.
+
## 2026-07-14: Light/Dark theme compatibility in SVG graphics using native CSS variables
Context: Redesigning artifact cards (thumbnails) to look consistent in both light and dark modes without maintaining multiple static assets.
@@ -208,3 +215,17 @@ Context: The `portable-infographic-architect` script generated a very tall, vert
Learning: Large language models default to vertical stacking (flex-direction: column) when generating HTML/CSS cards for infographics unless explicitly constrained otherwise. A long vertical image works on Pinterest but fails catastrophically as a hero image or embedded asset in an essay.
Evidence: The initial DevX essay thumbnail was unreadably tall. Passing "WIDESCREEN LANDSCAPE 16:9 ASPECT RATIO REQUIRED. Arrange items horizontally side-by-side, NOT vertically." to the script's `--text` parameter successfully forced the LLM to generate a horizontal layout that fit perfectly within the blog design without stretching.
Use next time: When using the `portable-infographic-architect` for blog thumbnails, hero images, or essay embeds, explicitly command the script to use a landscape/widescreen layout and horizontal item arrangement in the prompt. Do not leave the aspect ratio up to the model.
+
+## 2026-07-15: Image-generation settings can outpace an installed SDK
+
+Context: The DevX essay visual pass required Gemini 3.1 Flash Image at 1K with high thinking, but the locally installed Python SDK did not expose `image_size` or `thinking_level` in its typed configuration objects.
+Learning: Model support and client-library support can land at different times. Feature-detect optional SDK fields, and use the official REST request shape when a required generation setting is supported by the model but absent from the installed client.
+Evidence: Compatibility helpers keep the infographic skill runnable on the current SDK, while the successful final image requests used `imageConfig.imageSize: "1K"` and `thinkingConfig.thinkingLevel: "high"` through the Gemini API.
+Use next time: Confirm required image settings against the live client types before a render loop. Archive the exact model, request configuration, prompt, and uncropped source beside the finished asset.
+
+## 2026-07-15: Content quality needs separate deterministic and judgment gates
+
+Context: The DevX essay passed copy, redirect, and visual review, but an independent final check still found JPEG bytes stored behind a `.png` extension after the composition itself looked complete.
+Learning: A polished render cannot prove its file contract, and a green build cannot prove voice, causality, or taste. Public content needs deterministic checks for facts that tools can establish, plus independent reviewers for copy, claims, URL ownership, and visual judgment.
+Evidence: File-signature inspection caught the mislabeled social source; build and HTTP checks proved canonicals and 308 aliases; desktop/mobile captures exposed the actual reading experience; separate reviewers found issues the maker pass missed.
+Use next time: Run the `portfolio-review` maker/checker loop for every publishable change. Inventory claims and assets, verify mechanically first, split independent review by surface, correct one focused set of findings, and stop within three rounds or ask Ryan to decide.
diff --git a/docs/devx-essay-visual-prompts.json b/docs/devx-essay-visual-prompts.json
new file mode 100644
index 0000000..63f280b
--- /dev/null
+++ b/docs/devx-essay-visual-prompts.json
@@ -0,0 +1,34 @@
+{
+ "generator": "Gemini API generateContent",
+ "researchModel": "gemini-3.5-flash",
+ "iterationModel": "gemini-3.1-flash-lite-image",
+ "finalModel": "gemini-3.1-flash-image",
+ "finalConfig": {
+ "aspectRatio": "16:9",
+ "imageSize": "1K",
+ "thinkingLevel": "high",
+ "includeThoughts": true
+ },
+ "visuals": [
+ {
+ "role": "article header",
+ "output": "portfolio/static/assets/devx-growth-header.png",
+ "postProcess": "Removed the model's duplicated lower label row without altering the diagram, center-cropped to 16:9, and resized to 1200x675.",
+ "prompt": "Generate a professional infographic image for the header of an essay arguing that DevX is a growth discipline. Show one continuous operating system: observed developer friction becomes a shipped product improvement, that correct path is distributed inside editor and agent workflows, outcomes are measured, and the evidence feeds the next iteration. Human builders and AI coding agents both participate. The visual must reveal the system rather than repeat the essay title. Final production render in Ryan Baumann's presentation style: demo-first, one idea, visual evidence over words, clean system diagram. Edge-to-edge composition with little empty padding. Neutral warm paper background, charcoal ink, one restrained blue accent. No essay title. Use ONLY these text labels verbatim: 'Friction', 'Ship', 'Distribute', 'Measure'. Render each of those four labels exactly once, in one top row, directly above its corresponding stage. Never repeat a label below an icon. Four stages left to right with a feedback arrow. Friction shows an abstract failed-task signal using lines and a red error mark, with no readable code. Ship shows an abstract corrected product or code path using shapes and a blue check, with no readable code. Distribute shows abstract editor and coding-agent surfaces, with no interface text. Measure shows clearly rising unlabeled bars or an upward trend, with NO numeric value. Never invent a metric. No other words, pseudo-code, tiny text, gradients, neon, glassmorphism, stock art, decorative 3D, fake logos, or watermark.",
+ "refinementPrompt": "Edit the provided infographic image. Remove the duplicate bottom row of the labels 'Friction', 'Ship', 'Distribute', and 'Measure'. Keep only the single top row of those four labels. Preserve the icons, upward measurement chart, blue and red accents, outer feedback arrows, warm background, line weights, spacing, and every other part of the composition unchanged. Add no new text, symbols, or decoration."
+ },
+ {
+ "role": "social preview source",
+ "output": "portfolio/static/social/devx-growth-discipline-final-source.png",
+ "finalOutput": "portfolio/static/social/devx-growth-discipline.png",
+ "postProcess": "Center-cropped the uncropped source to the Open Graph ratio and resized to 1200x627; scripts/social-cards.mjs reproduces this crop.",
+ "prompt": "Generate a professional infographic image as the social preview for the essay 'DevX Is a Growth Discipline'. The visual hook is that DevX owns measurable business outcomes, not documentation output. Show a compact product-distribution-measurement system on the left and two separately grouped observed outcomes from Google Maps Platform open-source client libraries on the right. Do not imply that the operating-system diagram proves the strategy caused the metrics. Between March 2025 and March 2026, unique active users grew 300%, and API engagement grew approximately 200%. Final production social composition, distinct from the header, optimized for thumbnail legibility. Use exactly this title: 'DevX Is a Growth Discipline'. Use exactly this subtitle: 'Own outcomes, not documentation output'. Use exactly these metric labels: '300% unique active user growth' and '~200% API engagement growth'. Include exactly 'Observed Mar 2025βMar 2026'. The left group is a minimal three-node loop labeled only 'Product', 'Distribution', 'Measurement'. The right group is labeled 'Observed outcomes'. Separate the groups visually with no causal arrow between them. Warm neutral paper, charcoal, restrained blue. No other words, case-study claim, URL, paragraph, tiny footer, gradients, neon, glass, stock art, fake logos, or watermark."
+ },
+ {
+ "role": "inline eval mechanism",
+ "output": "portfolio/static/assets/devx-eval-loop.png",
+ "postProcess": "Center-cropped to 16:9 and resized to 1200x675.",
+ "prompt": "Generate a professional infographic image that explains the agent evaluation evidence loop in a DevX essay. A representative developer task is attempted by a coding agent; the trace reveals a stall or wrong path; a rubric scores the result against a no-context baseline; the team makes a ship-or-hold decision; product telemetry and direct research answer different questions; the next product iteration begins. This visual must teach the mechanism, not advertise it. Final production render in Ryan Baumann's demo-first visual style. One left-to-right mechanism with an explicit feedback arrow, recognizable abstract trace and score artifacts, minimal exact labels. Use ONLY these labels verbatim: 'Task', 'Agent run', 'Trace', 'Rubric vs. baseline', 'Ship or hold', 'Telemetry', 'Research', 'Repeat'. Use abstract lines, nodes, bars, and decision shapes. Do not render pseudo-code, table contents, numeric scores, user-study labels, or any other text. Warm neutral background, charcoal ink, restrained blue accent. No title wall, generic card grid, paragraph, tiny text, gradients, neon, glass, stock art, decorative 3D, fake logos, or watermark."
+ }
+ ]
+}
diff --git a/gateway/lib/apps.js b/gateway/lib/apps.js
index b30d1a9..43e550e 100644
--- a/gateway/lib/apps.js
+++ b/gateway/lib/apps.js
@@ -37,6 +37,27 @@ function hasIndexHtml(dir) {
return existsSync(dir) && existsSync(join(dir, 'index.html'));
}
+function loadRedirects(dir) {
+ if (!dir) return {};
+ const redirectsPath = join(dir, 'redirects.json');
+ if (!existsSync(redirectsPath)) return {};
+ let redirects;
+ try {
+ redirects = JSON.parse(readFileSync(redirectsPath, 'utf8'));
+ } catch (error) {
+ throw new Error(`Failed to parse redirects.json at ${redirectsPath}: ${error.message}`);
+ }
+ if (!redirects || typeof redirects !== 'object' || Array.isArray(redirects)) {
+ throw new Error(`Invalid redirects.json at ${redirectsPath}.`);
+ }
+ for (const [source, target] of Object.entries(redirects)) {
+ if (!/^\/[a-z0-9/-]+\/$/.test(source) || source.includes('//') || !/^\/[a-z0-9/-]+\/$/.test(target) || target.includes('//')) {
+ throw new Error(`Invalid redirect in ${redirectsPath}: ${source} -> ${target}`);
+ }
+ }
+ return redirects;
+}
+
/**
* @returns {{ apps: Array