From ae4aa1e04c41ab5cf92b65420a80535c39c7fed4 Mon Sep 17 00:00:00 2001
From: Bryan Helmkamp
Date: Mon, 16 Mar 2026 20:55:31 -0400
Subject: [PATCH] Showcase: render workflow graphs as SVGs, add nav, fix prompt
- Render DOT workflow definitions as visual SVG diagrams at build time
using @viz-js/viz, replacing raw code blocks on show pages and
placeholder first-letter thumbnails on index cards
- Collapse models/skills/languages into a compact metadata strip on
show pages instead of separate boxed sections
- Fix prompt expand/collapse to use a single DOM element with max-height
animation instead of duplicating the text in two swapped containers
- Add prev/next navigation links at the bottom of show pages
- Extract duplicated langIcons data into shared src/lib/langIcons.ts
- Use varied reveal animation types (reveal-scale, reveal-left)
Co-Authored-By: Claude Opus 4.6 (1M context)
---
apps/marketing/package.json | 1 +
apps/marketing/src/lib/langIcons.ts | 18 +++
apps/marketing/src/lib/renderWorkflow.ts | 60 ++++++++
.../marketing/src/pages/showcase/[slug].astro | 145 ++++++++----------
apps/marketing/src/pages/showcase/index.astro | 49 +++---
bun.lock | 3 +
6 files changed, 165 insertions(+), 111 deletions(-)
create mode 100644 apps/marketing/src/lib/langIcons.ts
create mode 100644 apps/marketing/src/lib/renderWorkflow.ts
diff --git a/apps/marketing/package.json b/apps/marketing/package.json
index fba9efd45..ce8e57859 100644
--- a/apps/marketing/package.json
+++ b/apps/marketing/package.json
@@ -10,6 +10,7 @@
"dependencies": {
"@astrojs/react": "^4.2.1",
"@tailwindcss/vite": "^4.2.1",
+ "@viz-js/viz": "^3.25.0",
"astro": "^5.9.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
diff --git a/apps/marketing/src/lib/langIcons.ts b/apps/marketing/src/lib/langIcons.ts
new file mode 100644
index 000000000..bad47e297
--- /dev/null
+++ b/apps/marketing/src/lib/langIcons.ts
@@ -0,0 +1,18 @@
+export const langIcons: Record = {
+ python: {
+ label: "Python",
+ path: "M12 1.5c-5.1 0-4.8 2.2-4.8 2.2l.006 2.3H12.4v.7H4.8S1.5 6.3 1.5 11.5s2.9 5 2.9 5h1.7V14.1s-.1-2.9 2.8-2.9h4.9s2.8 0 2.8-2.7V3.8s.4-2.3-4.6-2.3zm-2.7 1.3a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8zM12 22.5c5.1 0 4.8-2.2 4.8-2.2l-.006-2.3H11.6v-.7h7.6s3.3.4 3.3-4.8-2.9-5-2.9-5h-1.7v2.4s.1 2.9-2.8 2.9h-4.9s-2.8 0-2.8 2.7v4.7s-.4 2.3 4.6 2.3zm2.7-1.3a.9.9 0 1 1 0-1.8.9.9 0 0 1 0 1.8z",
+ },
+ rust: {
+ label: "Rust",
+ path: "M23.8 14.1l-1.2-.7a10.5 10.5 0 0 0 0-2.8l1.2-.7a.3.3 0 0 0 .1-.4 12 12 0 0 0-2.6-4.5.3.3 0 0 0-.4 0l-1.2.7a10.2 10.2 0 0 0-2.4-1.4V3a.3.3 0 0 0-.2-.3 12 12 0 0 0-5.2 0 .3.3 0 0 0-.2.3v1.3a10.2 10.2 0 0 0-2.4 1.4l-1.2-.7a.3.3 0 0 0-.4 0A12 12 0 0 0 5.1 9.5a.3.3 0 0 0 .1.4l1.2.7a10.5 10.5 0 0 0 0 2.8l-1.2.7a.3.3 0 0 0-.1.4 12 12 0 0 0 2.6 4.5.3.3 0 0 0 .4 0l1.2-.7c.7.6 1.5 1 2.4 1.4V21a.3.3 0 0 0 .2.3 12 12 0 0 0 5.2 0 .3.3 0 0 0 .2-.3v-1.3c.9-.4 1.7-.8 2.4-1.4l1.2.7a.3.3 0 0 0 .4 0 12 12 0 0 0 2.6-4.5.3.3 0 0 0-.1-.4zM12 16.5a4.5 4.5 0 1 1 0-9 4.5 4.5 0 0 1 0 9z",
+ },
+ typescript: {
+ label: "TypeScript",
+ path: "M1.5 1.5h21v21h-21V1.5zm10.2 10.2v-1.8h6.6v1.8h-2.3v6.6h-2v-6.6h-2.3zm-4.1-1.8h2v4.5c0 .7.1 1.2.3 1.5.4.5 1 .8 1.8.8s1.4-.3 1.8-.8c.2-.3.3-.8.3-1.5V9.9h2v4.7c0 1-.2 1.8-.7 2.4-.7.9-1.8 1.3-3.4 1.3s-2.7-.4-3.4-1.3c-.5-.6-.7-1.4-.7-2.4V9.9z",
+ },
+ ruby: {
+ label: "Ruby",
+ path: "M20.6 18.3L21.7 5l-5.5 1.5-2.5-3.5-3 3.2L4.3 3 3 10.5l3.3 2-3 4.2 5.3.8 1.5 4 4.5-2.8 4 3.6 2-4z",
+ },
+};
diff --git a/apps/marketing/src/lib/renderWorkflow.ts b/apps/marketing/src/lib/renderWorkflow.ts
new file mode 100644
index 000000000..59d60d983
--- /dev/null
+++ b/apps/marketing/src/lib/renderWorkflow.ts
@@ -0,0 +1,60 @@
+import { instance } from "@viz-js/viz";
+
+let vizInstance: Awaited> | undefined;
+
+async function getViz() {
+ if (!vizInstance) {
+ vizInstance = await instance();
+ }
+ return vizInstance;
+}
+
+/**
+ * Renders a Graphviz DOT string to an SVG string, styled to match the site's
+ * teal-on-navy palette. The returned SVG has no fixed dimensions (uses viewBox)
+ * so it can be sized by its container.
+ */
+export async function renderWorkflow(dot: string): Promise {
+ const viz = await getViz();
+
+ const styledDot = injectGraphStyle(dot);
+ let svg = viz.renderString(styledDot, { format: "svg", engine: "dot" });
+
+ // Make SVG responsive: remove fixed width/height, keep viewBox
+ svg = svg.replace(/\s*width="[^"]*"/, "");
+ svg = svg.replace(/\s*height="[^"]*"/, "");
+
+ return svg;
+}
+
+/**
+ * Injects graph-level styling attributes into the DOT string so the rendered
+ * SVG uses the site's color palette without post-processing.
+ */
+function injectGraphStyle(dot: string): string {
+ const styleBlock = `
+ bgcolor="transparent"
+ node [
+ fontname="Space Grotesk"
+ fontsize=13
+ fontcolor="#e8edf3"
+ style="filled"
+ fillcolor="#141c2f"
+ color="#357f9e"
+ penwidth=1.5
+ shape=box
+ margin="0.15,0.1"
+ ]
+ edge [
+ color="#4b5768"
+ fontname="DM Sans"
+ fontsize=10
+ fontcolor="#a8b5c5"
+ arrowsize=0.7
+ penwidth=1.2
+ ]
+ `;
+
+ // Insert style after the opening brace of the digraph
+ return dot.replace(/\{/, `{\n${styleBlock}\n`);
+}
diff --git a/apps/marketing/src/pages/showcase/[slug].astro b/apps/marketing/src/pages/showcase/[slug].astro
index 00f2b4163..b556d1c07 100644
--- a/apps/marketing/src/pages/showcase/[slug].astro
+++ b/apps/marketing/src/pages/showcase/[slug].astro
@@ -4,36 +4,26 @@ import Nav from "../../components/Nav.astro";
import Footer from "../../components/Footer.astro";
import PageScripts from "../../components/PageScripts.astro";
import { getCollection, render } from "astro:content";
+import { langIcons } from "../../lib/langIcons";
+import { renderWorkflow } from "../../lib/renderWorkflow";
export async function getStaticPaths() {
- const entries = await getCollection("showcase");
- return entries.map((entry) => ({
+ const entries = (await getCollection("showcase")).sort(
+ (a, b) => a.data.sortOrder - b.data.sortOrder
+ );
+ return entries.map((entry, i) => ({
params: { slug: entry.id },
- props: { entry },
+ props: {
+ entry,
+ prev: entries[i - 1] ?? null,
+ next: entries[i + 1] ?? null,
+ },
}));
}
-const { entry } = Astro.props;
+const { entry, prev, next } = Astro.props;
const { Content } = await render(entry);
-
-const langIcons: Record = {
- python: {
- label: "Python",
- path: "M12 1.5c-5.1 0-4.8 2.2-4.8 2.2l.006 2.3H12.4v.7H4.8S1.5 6.3 1.5 11.5s2.9 5 2.9 5h1.7V14.1s-.1-2.9 2.8-2.9h4.9s2.8 0 2.8-2.7V3.8s.4-2.3-4.6-2.3zm-2.7 1.3a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8zM12 22.5c5.1 0 4.8-2.2 4.8-2.2l-.006-2.3H11.6v-.7h7.6s3.3.4 3.3-4.8-2.9-5-2.9-5h-1.7v2.4s.1 2.9-2.8 2.9h-4.9s-2.8 0-2.8 2.7v4.7s-.4 2.3 4.6 2.3zm2.7-1.3a.9.9 0 1 1 0-1.8.9.9 0 0 1 0 1.8z",
- },
- rust: {
- label: "Rust",
- path: "M23.8 14.1l-1.2-.7a10.5 10.5 0 0 0 0-2.8l1.2-.7a.3.3 0 0 0 .1-.4 12 12 0 0 0-2.6-4.5.3.3 0 0 0-.4 0l-1.2.7a10.2 10.2 0 0 0-2.4-1.4V3a.3.3 0 0 0-.2-.3 12 12 0 0 0-5.2 0 .3.3 0 0 0-.2.3v1.3a10.2 10.2 0 0 0-2.4 1.4l-1.2-.7a.3.3 0 0 0-.4 0A12 12 0 0 0 5.1 9.5a.3.3 0 0 0 .1.4l1.2.7a10.5 10.5 0 0 0 0 2.8l-1.2.7a.3.3 0 0 0-.1.4 12 12 0 0 0 2.6 4.5.3.3 0 0 0 .4 0l1.2-.7c.7.6 1.5 1 2.4 1.4V21a.3.3 0 0 0 .2.3 12 12 0 0 0 5.2 0 .3.3 0 0 0 .2-.3v-1.3c.9-.4 1.7-.8 2.4-1.4l1.2.7a.3.3 0 0 0 .4 0 12 12 0 0 0 2.6-4.5.3.3 0 0 0-.1-.4zM12 16.5a4.5 4.5 0 1 1 0-9 4.5 4.5 0 0 1 0 9z",
- },
- typescript: {
- label: "TypeScript",
- path: "M1.5 1.5h21v21h-21V1.5zm10.2 10.2v-1.8h6.6v1.8h-2.3v6.6h-2v-6.6h-2.3zm-4.1-1.8h2v4.5c0 .7.1 1.2.3 1.5.4.5 1 .8 1.8.8s1.4-.3 1.8-.8c.2-.3.3-.8.3-1.5V9.9h2v4.7c0 1-.2 1.8-.7 2.4-.7.9-1.8 1.3-3.4 1.3s-2.7-.4-3.4-1.3c-.5-.6-.7-1.4-.7-2.4V9.9z",
- },
- ruby: {
- label: "Ruby",
- path: "M20.6 18.3L21.7 5l-5.5 1.5-2.5-3.5-3 3.2L4.3 3 3 10.5l3.3 2-3 4.2 5.3.8 1.5 4 4.5-2.8 4 3.6 2-4z",
- },
-};
+const workflowSvg = await renderWorkflow(entry.data.workflow);
---
@@ -50,8 +40,8 @@ const langIcons: Record = {
Showcase
-
-
+
+
{entry.data.title}
@@ -59,12 +49,12 @@ const langIcons: Record = {
{entry.data.description}
- {/* Language icons + tags */}
-
+ {/* Compact metadata strip */}
+
{entry.data.languages.map((lang) => {
const icon = langIcons[lang];
return icon ? (
-
+
@@ -73,15 +63,17 @@ const langIcons: Record = {
) : null;
})}
|
- {entry.data.tags.map((tag) => (
-
- {tag}
-
+ {entry.data.models.map((model) => (
+ {model}
+ ))}
+ |
+ {entry.data.skills.map((skill) => (
+ {skill}
))}
{/* GitHub button */}
-
+
-
-
+
+
Workflow
-
-
{entry.data.workflow}
-
+
-
-
-
-
-
Models
-
- {entry.data.models.map((model) => (
-
- {model}
-
- ))}
-
-
-
-
Skills
-
- {entry.data.skills.map((skill) => (
-
- {skill}
-
- ))}
-
-
-
-
-
-
+
Prompt
-
{entry.data.prompt}
@@ -179,19 +167,16 @@ const langIcons: Record = {