Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/portfolio-design/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ description: How Ryan designs. Use before changing style.css, page layouts in bu
## Principles

1. **Content-first, bare bones.** The design's job is to make the work legible, then get out of the way. No hero animations, no carousels, no decorative imagery.
2. **Fast is a feature.** Every page is a single request: CSS is inlined at build time, there is **zero client-side JavaScript**, fonts are the system stack. Keep any page under ~30KB of HTML. If a change needs a client script or a webfont, it needs a very good reason.
2. **Fast is a feature.** Every page is a single request: CSS is inlined at build time, fonts are the system stack, and client JavaScript is avoided by default. A tiny inline script is acceptable only for explicit user-controlled color-scheme preference, because it prevents theme flash and preserves a real light/dark toggle. Keep any page lean. If any other change needs a client script or a webfont, it needs a very good reason.
3. **Boring is deliberate.** One accent color, one column, generous whitespace. Novelty budget is spent on the writing, not the chrome.
4. **Both color schemes, always.** Light and dark are first-class via `prefers-color-scheme` and the token block at the top of `style.css`. Never hardcode a color in a component: add or use a token.
5. **Show, don't tell.** Every page carries at least one real image. Real screenshots first (previews, product shots). When no honest screenshot exists, generate an SVG artifact card with `scripts/artifact-cards.mjs`; cards state only facts already in the entry (real commands, real published stats). Never mock a product UI or fabricate a screenshot.
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 2026-07-13: Portfolio feedback readiness pass

- Compressed the portfolio header into a single-line shell on mobile and desktop, added Resume and Contact navigation, and added a manual light/dark theme toggle.
- Added a resume-style page with tighter career sections, proof points, and links back to work and contact.
- Replaced public email links with a backend-managed contact form and a gateway `/api/contact` route that sends through Resend when `RESEND_API_KEY` and `CONTACT_TO_EMAIL` are configured.
- Updated About contact copy so the public site no longer renders Ryan's email address.
- Kept existing real screenshots where available and left generated artifact cards only where no honest screenshot exists.

## 2026-07-12: Deployed UI/UX polish pass

- Audited the live site text rendering and demo pages, then tightened the local portfolio UI before the next deploy.
Expand Down
7 changes: 7 additions & 0 deletions LEARNINGS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Learnings

## 2026-07-13: Contact pages should hide owner email addresses at the rendering layer

Context: The portfolio needed contact UX without exposing Ryan's personal email address in the HTML.
Learning: A static portfolio can still keep the owner's address private by posting to a same-origin backend route and reading the recipient from server-only environment variables. The public form should collect the visitor's reply address, while the server uses `CONTACT_TO_EMAIL` and provider credentials such as `RESEND_API_KEY`.
Evidence: Today's pass removed public `mailto:` links to Ryan's address, added `/contact/`, and routed submissions through `/api/contact`. The route returns a setup message when mail credentials are missing instead of leaking a fallback address.
Use next time: Never solve portfolio contact by adding a visible owner email. Add or reuse a backend form route, document the server-only env vars, and make missing-provider behavior explicit.

## 2026-07-12: AEO works best when the page and schema say the same thing

Context: A repo-wide copy pass needed to improve search and answer-engine clarity without adding vague keyword stuffing.
Expand Down
99 changes: 99 additions & 0 deletions gateway/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { handleIsochronesApi } from './lib/isochrones.js';

const PORT = Number(process.env.PORT || 8080);
const JSON_BODY_LIMIT_BYTES = 16 * 1024;
const FORM_BODY_LIMIT_BYTES = 32 * 1024;

const { apps } = loadApps(process.env);
// Only public-visibility apps appear in /api/apps and /healthz.
Expand Down Expand Up @@ -73,6 +74,99 @@ function sendRaw(response, statusCode, body, contentType) {
response.end(body);
}


function readTextBody(request, limitBytes = FORM_BODY_LIMIT_BYTES) {
return new Promise((resolve, reject) => {
let body = '';
let bytesRead = 0;
request.on('data', (chunk) => {
bytesRead += chunk.length;
if (bytesRead > limitBytes) {
reject(Object.assign(new Error('Payload too large'), { statusCode: 413 }));
request.destroy();
return;
}
body += chunk;
});
request.on('end', () => {
if (bytesRead <= limitBytes) resolve(body);
});
request.on('error', reject);
});
}

function sendHtml(response, statusCode, body) {
applySecurityHeaders(response);
response.writeHead(statusCode, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
});
response.end(body);
}

function contactResponsePage(title, message, statusCode = 200) {
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title><style>body{font-family:system-ui,sans-serif;max-width:42rem;margin:4rem auto;padding:0 1.25rem;line-height:1.6;color:#111827;background:#faf9f6}a{color:inherit}</style></head><body><p><a href="/contact/">← Contact</a></p><h1>${title}</h1><p>${message}</p></body></html>`;
}

async function handleContactRequest(request, response) {
if (request.method !== 'POST') {
sendJson(response, 405, { error: 'Method not allowed' });
return;
}

let rawBody;
try {
rawBody = await readTextBody(request);
} catch (err) {
sendHtml(response, err.statusCode || 400, contactResponsePage('Message not sent', err.message, err.statusCode || 400));
return;
}

const contentType = request.headers['content-type'] || '';
const params = contentType.includes('application/x-www-form-urlencoded')
? new URLSearchParams(rawBody)
: new URLSearchParams();
const name = String(params.get('name') || '').trim().slice(0, 120);
const email = String(params.get('email') || '').trim().slice(0, 200);
const message = String(params.get('message') || '').trim().slice(0, 5000);

if (!name || !email || !message || !email.includes('@') || message.length < 20) {
sendHtml(response, 400, contactResponsePage('Message not sent', 'Please include your name, a valid email, and a message with at least 20 characters.', 400));
return;
}

const resendApiKey = process.env.RESEND_API_KEY;
const toEmail = process.env.CONTACT_TO_EMAIL;
const fromEmail = process.env.CONTACT_FROM_EMAIL || 'Portfolio Contact <onboarding@resend.dev>';
if (!resendApiKey || !toEmail) {
sendHtml(response, 503, contactResponsePage('Contact form is not configured yet', 'The backend route is live, but RESEND_API_KEY and CONTACT_TO_EMAIL must be set before it can deliver messages.', 503));
return;
}

const upstream = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${resendApiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: fromEmail,
to: [toEmail],
reply_to: email,
subject: `Portfolio contact from ${name}`,
text: `Name: ${name}\nEmail: ${email}\n\n${message}`,
}),
signal: AbortSignal.timeout(10_000),
});

if (!upstream.ok) {
sendHtml(response, 502, contactResponsePage('Message not sent', 'The mail provider did not accept the message. Please try again later.', 502));
return;
}

sendHtml(response, 200, contactResponsePage('Message sent', 'Thanks. I have your note and enough context to reply.', 200));
}

function readJsonBody(request) {
return new Promise((resolve, reject) => {
let body = '';
Expand Down Expand Up @@ -110,6 +204,11 @@ async function handleApi(request, response, pathname, searchParams) {
return;
}

if (pathname === '/api/contact') {
await handleContactRequest(request, response);
return;
}

if (pathname === '/api/apps') {
sendJson(response, 200, { apps: publicApps });
return;
Expand Down
59 changes: 48 additions & 11 deletions portfolio/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -318,17 +318,13 @@ function layout({ title, description, content, active = '', canonical, ogImage,
{ href: `${BASE}writing/`, label: 'Writing', key: 'writing' },
{ href: `${BASE}talks/`, label: 'Talks', key: 'talks' },
...(demos.length ? [{ href: `${BASE}demos/`, label: 'Demos', key: 'demos' }] : []),
{ href: `${BASE}resume/`, label: 'Resume', key: 'resume' },
{ href: `${BASE}contact/`, label: 'Contact', key: 'contact' },
{ href: `${BASE}about/`, label: 'About', key: 'about' },
];
const nav = navItems
.map((item) => `<a href="${item.href}"${item.key === active ? ' aria-current="page"' : ''}>${item.label}</a>`)
.join('');
const headerCtas = [
{ label: 'See demos', href: `${BASE}demos/` },
{ label: 'Get in touch', href: `mailto:${site.links.email}` }
]
.map((item) => `<a class="header-cta" href="${item.href}">${item.label}</a>`)
.join('');

const resolvedCanonical = canonical || absoluteUrl('/');
const resolvedImage = absoluteUrl(ogImage || site.defaultShareImage);
Expand Down Expand Up @@ -379,14 +375,15 @@ ${ogImageAltTag}
${twitterTags}
${articleTags ? articleTags + '\n' : ''}<link rel="icon" href="${BASE}favicon.svg" type="image/svg+xml" />
<link rel="alternate" type="application/rss+xml" title="${escapeHtml(site.name)} Writing" href="${absoluteUrl('/feed.xml')}" />
${robotsTag ? robotsTag + '\n' : ''}${jsonLdTag ? jsonLdTag + '\n' : ''}<style>${CSS}</style>
${robotsTag ? robotsTag + '\n' : ''}${jsonLdTag ? jsonLdTag + '\n' : ''}<script>try{const t=localStorage.getItem('theme');if(t)document.documentElement.dataset.theme=t;}catch{}</script>
<style>${CSS}</style>
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<header class="site-header">
<a class="site-name" href="${BASE}">${escapeHtml(site.name)}</a>
<nav aria-label="Site">${nav}</nav>
<div class="header-actions" aria-label="Primary actions">${headerCtas}</div>
<button class="theme-toggle" type="button" aria-label="Toggle color theme" onclick="try{const e=document.documentElement;const n=e.dataset.theme==='dark'?'light':'dark';e.dataset.theme=n;localStorage.setItem('theme',n)}catch{}">Theme</button>
</header>
<main id="main">
${content}
Expand All @@ -398,7 +395,7 @@ ${content}
<a href="${site.links.linkedin}" rel="noopener">LinkedIn</a>
${site.links.x ? `<a href="${site.links.x}" rel="noopener">X</a>` : ''}
${site.links.substack ? `<a href="${site.links.substack}" rel="noopener">Substack</a>` : ''}
<a href="mailto:${site.links.email}">Email</a>
<a href="${BASE}contact/">Contact</a>
</p>
</footer>
</body>
Expand Down Expand Up @@ -710,7 +707,8 @@ function buildHome(collections) {
{ label: 'Work', href: `${BASE}work/` },
...(demos.length ? [{ label: 'Demos', href: `${BASE}demos/` }] : []),
{ label: 'Writing', href: `${BASE}writing/` },
{ label: 'Email', href: `mailto:${site.links.email}` },
{ label: 'Resume', href: `${BASE}resume/` },
{ label: 'Contact', href: `${BASE}contact/` },
{ label: 'GitHub', href: site.links.github, external: true },
{ label: 'LinkedIn', href: site.links.linkedin, external: true },
];
Expand Down Expand Up @@ -851,14 +849,53 @@ function buildCollectionIndex(collection, entries) {
}));
}


function resumePageContent() {
return `<section class="resume-shell">
<div class="resume-header">
<div>
<p class="eyebrow">Resume</p>
<h1>${escapeHtml(site.name)}</h1>
<p class="lede">${escapeHtml(site.positioning || site.role)}</p>
</div>
<p class="resume-meta">${escapeHtml(site.location)}<br /><a href="${site.links.linkedin}" rel="noopener">LinkedIn</a> · <a href="${site.links.github}" rel="noopener">GitHub</a></p>
</div>
<div class="resume-section"><h2>Focus</h2><p>${escapeHtml(site.answerEngineSummary || site.description)}</p></div>
<div class="resume-section"><h2>Experience</h2>
<article><h3>Google Maps Platform</h3><p class="resume-meta">Developer Experience, solution architecture, product incubation · 2022 – present</p><p>Lead developer experience and forward-deployed platform work across Code Assist, agent skills, evals, AI-native distribution, and the Geo Architecture Center.</p></article>
<article><h3>Google Cloud</h3><p class="resume-meta">0→1 industry solution product and engineering lead · 2021 – 2022</p><p>Led Intelligent Product Essentials from zero to launch with GE Appliances in nine months.</p></article>
<article><h3>Mapbox</h3><p class="resume-meta">Customer engineering, product, partnerships · 2015 – 2020</p><p>Grew customer engineering from 1 to 15 as Mapbox crossed $100M ARR. Took Boundaries and Atlas to their first $5M ARR and led OSS partnerships with Uber's visualization stack.</p></article>
<article><h3>Instabase and Caterpillar</h3><p class="resume-meta">Solution architecture and industrial IoT</p><p>Led solution architecture at Series-B Instabase. Earlier, built industrial IoT systems at Caterpillar with 3 US patents.</p></article>
</div>
<div class="resume-section"><h2>Selected proof</h2><ul>${(site.proofPoints || []).map((point) => `<li><strong>${escapeHtml(point.label)}</strong>: ${escapeHtml(point.text)}</li>`).join('')}</ul></div>
<p class="chips"><a class="chip" href="${BASE}work/">Work</a><a class="chip" href="${BASE}contact/">Contact</a></p>
</section>`;
}

function contactPageContent() {
return `<section class="contact-shell">
<p class="eyebrow">Contact</p>
<h1>Send a note</h1>
<p class="lede">Use this form instead of a public email address. It sends through the backend, keeps my address out of the HTML, and gives me enough context to reply.</p>
<form class="contact-form" action="${BASE}api/contact" method="post">
<label>Name <input name="name" autocomplete="name" required /></label>
<label>Email <input name="email" type="email" autocomplete="email" required /></label>
<label>What should I know? <textarea name="message" rows="7" required minlength="20"></textarea></label>
<button class="button" type="submit">Send note</button>
</form>
<p class="section-note">If the backend mail provider is not configured yet, the form returns a setup message instead of exposing an address.</p>
</section>`;
}

function buildStandalonePages() {
const dir = join(CONTENT_DIR, 'pages');
if (!existsSync(dir)) return;
for (const file of readdirSync(dir)) {
if (!file.endsWith('.md') || file.startsWith('_')) continue;
const slug = file.replace(/\.md$/, '');
const { meta, body } = parseFrontMatter(readFileSync(join(dir, file), 'utf8'));
const content = `<article class="prose">
const customContent = slug === 'resume' ? resumePageContent() : slug === 'contact' ? contactPageContent() : null;
const content = customContent || `<article class="prose">
<p class="eyebrow">${escapeHtml(meta.eyebrow || site.name)}</p>
<h1>${escapeHtml(meta.title)}</h1>
${(() => {
Expand Down
2 changes: 1 addition & 1 deletion portfolio/content/pages/about.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,4 @@ I joined as the first customer-facing engineer at Series B and grew the team fro

## Elsewhere

[GitHub](https://github.com/ryanbaumann) · [LinkedIn](https://www.linkedin.com/in/ryanbaumann/) · [Substack](https://ryanbaumann.substack.com/) · [Email](mailto:rsbaumann@gmail.com)
[GitHub](https://github.com/ryanbaumann) · [LinkedIn](https://www.linkedin.com/in/ryanbaumann/) · [Substack](https://ryanbaumann.substack.com/) · [Contact](/contact/)
5 changes: 5 additions & 0 deletions portfolio/content/pages/contact.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: Contact
summary: Contact Ryan Baumann through a backend-managed form without exposing a public email address.
eyebrow: Contact
---
5 changes: 5 additions & 0 deletions portfolio/content/pages/resume.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: Resume
summary: A resume-style overview of Ryan Baumann's solution architecture, developer experience, product growth, and platform work.
eyebrow: Resume
---
1 change: 0 additions & 1 deletion portfolio/content/site.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
"links": {
"github": "https://github.com/ryanbaumann",
"linkedin": "https://www.linkedin.com/in/ryanbaumann/",
"email": "rsbaumann@gmail.com",
"x": "https://x.com/RyanBaumann",
"substack": "https://ryanbaumann.substack.com/"
},
Expand Down
Loading
Loading