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}

-
+

{entry.data.prompt}

-

{entry.data.prompt}

-
+

About

+ + + {(prev || next) && ( +
+ )} @@ -179,19 +167,16 @@ const langIcons: Record = {