From 13e1c3a219bda7999311aed1bb2db76552b95525 Mon Sep 17 00:00:00 2001 From: Ryan Baumann Date: Mon, 13 Jul 2026 01:35:50 +0000 Subject: [PATCH] Polish portfolio navigation and contact flow --- .agents/skills/portfolio-design/SKILL.md | 2 +- CHANGELOG.md | 8 ++ LEARNINGS.md | 7 ++ gateway/server.js | 99 ++++++++++++++++++++ portfolio/build.mjs | 59 +++++++++--- portfolio/content/pages/about.md | 2 +- portfolio/content/pages/contact.md | 5 + portfolio/content/pages/resume.md | 5 + portfolio/content/site.json | 1 - portfolio/style.css | 114 +++++++++++++++++++++++ 10 files changed, 288 insertions(+), 14 deletions(-) create mode 100644 portfolio/content/pages/contact.md create mode 100644 portfolio/content/pages/resume.md diff --git a/.agents/skills/portfolio-design/SKILL.md b/.agents/skills/portfolio-design/SKILL.md index 77bf377..935e748 100644 --- a/.agents/skills/portfolio-design/SKILL.md +++ b/.agents/skills/portfolio-design/SKILL.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index c40709e..a86cbc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/LEARNINGS.md b/LEARNINGS.md index b3a8b05..5c4b00a 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -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. diff --git a/gateway/server.js b/gateway/server.js index 4b5a7d5..7821ecf 100644 --- a/gateway/server.js +++ b/gateway/server.js @@ -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. @@ -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 `${title}

← Contact

${title}

${message}

`; +} + +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 '; + 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 = ''; @@ -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; diff --git a/portfolio/build.mjs b/portfolio/build.mjs index 271c781..c231bb8 100644 --- a/portfolio/build.mjs +++ b/portfolio/build.mjs @@ -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) => `${item.label}`) .join(''); - const headerCtas = [ - { label: 'See demos', href: `${BASE}demos/` }, - { label: 'Get in touch', href: `mailto:${site.links.email}` } - ] - .map((item) => `${item.label}`) - .join(''); const resolvedCanonical = canonical || absoluteUrl('/'); const resolvedImage = absoluteUrl(ogImage || site.defaultShareImage); @@ -379,14 +375,15 @@ ${ogImageAltTag} ${twitterTags} ${articleTags ? articleTags + '\n' : ''} -${robotsTag ? robotsTag + '\n' : ''}${jsonLdTag ? jsonLdTag + '\n' : ''} +${robotsTag ? robotsTag + '\n' : ''}${jsonLdTag ? jsonLdTag + '\n' : ''} +
${content} @@ -398,7 +395,7 @@ ${content} LinkedIn ${site.links.x ? `X` : ''} ${site.links.substack ? `Substack` : ''} - Email + Contact

@@ -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 }, ]; @@ -851,6 +849,44 @@ function buildCollectionIndex(collection, entries) { })); } + +function resumePageContent() { + return `
+
+
+

Resume

+

${escapeHtml(site.name)}

+

${escapeHtml(site.positioning || site.role)}

+
+

${escapeHtml(site.location)}
LinkedIn · GitHub

+
+

Focus

${escapeHtml(site.answerEngineSummary || site.description)}

+

Experience

+

Google Maps Platform

Developer Experience, solution architecture, product incubation · 2022 – present

Lead developer experience and forward-deployed platform work across Code Assist, agent skills, evals, AI-native distribution, and the Geo Architecture Center.

+

Google Cloud

0→1 industry solution product and engineering lead · 2021 – 2022

Led Intelligent Product Essentials from zero to launch with GE Appliances in nine months.

+

Mapbox

Customer engineering, product, partnerships · 2015 – 2020

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.

+

Instabase and Caterpillar

Solution architecture and industrial IoT

Led solution architecture at Series-B Instabase. Earlier, built industrial IoT systems at Caterpillar with 3 US patents.

+
+

Selected proof

    ${(site.proofPoints || []).map((point) => `
  • ${escapeHtml(point.label)}: ${escapeHtml(point.text)}
  • `).join('')}
+

WorkContact

+
`; +} + +function contactPageContent() { + return `
+

Contact

+

Send a note

+

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.

+
+ + + + +
+

If the backend mail provider is not configured yet, the form returns a setup message instead of exposing an address.

+
`; +} + function buildStandalonePages() { const dir = join(CONTENT_DIR, 'pages'); if (!existsSync(dir)) return; @@ -858,7 +894,8 @@ function buildStandalonePages() { if (!file.endsWith('.md') || file.startsWith('_')) continue; const slug = file.replace(/\.md$/, ''); const { meta, body } = parseFrontMatter(readFileSync(join(dir, file), 'utf8')); - const content = `
+ const customContent = slug === 'resume' ? resumePageContent() : slug === 'contact' ? contactPageContent() : null; + const content = customContent || `

${escapeHtml(meta.eyebrow || site.name)}

${escapeHtml(meta.title)}

${(() => { diff --git a/portfolio/content/pages/about.md b/portfolio/content/pages/about.md index 2faf5d7..8453f51 100644 --- a/portfolio/content/pages/about.md +++ b/portfolio/content/pages/about.md @@ -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/) diff --git a/portfolio/content/pages/contact.md b/portfolio/content/pages/contact.md new file mode 100644 index 0000000..03f004f --- /dev/null +++ b/portfolio/content/pages/contact.md @@ -0,0 +1,5 @@ +--- +title: Contact +summary: Contact Ryan Baumann through a backend-managed form without exposing a public email address. +eyebrow: Contact +--- diff --git a/portfolio/content/pages/resume.md b/portfolio/content/pages/resume.md new file mode 100644 index 0000000..aaa8a1f --- /dev/null +++ b/portfolio/content/pages/resume.md @@ -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 +--- diff --git a/portfolio/content/site.json b/portfolio/content/site.json index bf4d584..a2b1a0e 100644 --- a/portfolio/content/site.json +++ b/portfolio/content/site.json @@ -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/" }, diff --git a/portfolio/style.css b/portfolio/style.css index 4cee850..d700646 100644 --- a/portfolio/style.css +++ b/portfolio/style.css @@ -458,3 +458,117 @@ main > section { margin-bottom: clamp(3rem, 7vw, 4.5rem); } grid-template-columns: minmax(0, 1.15fr) minmax(18rem, .85fr); } } + +/* ---- compact header, explicit theme toggle ---- */ +html[data-theme="light"] { + --bg: #faf9f6; + --surface: #ffffff; + --ink: #111827; + --muted: #4b5563; + --faint: #9ca3af; + --line: #e5e7eb; + --accent: #3b82f6; + --accent-ink: #2563eb; +} +html[data-theme="dark"] { + --bg: #030712; + --surface: #111827; + --ink: #f9fafb; + --muted: #9ca3af; + --faint: #6b7280; + --line: #1f2937; + --accent: #60a5fa; + --accent-ink: #93c5fd; +} +.site-header { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + padding-block: 0.7rem; + flex-wrap: nowrap; +} +.site-name { white-space: nowrap; } +.site-header nav { + justify-content: end; + gap: clamp(0.55rem, 2vw, 1rem); +} +.site-header nav a { + min-height: 36px; + font-size: clamp(0.78rem, 2.5vw, 0.92rem); +} +.theme-toggle, +.button { + min-height: 36px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--surface); + color: var(--ink); + font: inherit; + font-size: 0.82rem; + font-weight: 650; + padding: 0.25rem 0.7rem; + cursor: pointer; +} +.theme-toggle:hover, +.button:hover { border-color: var(--accent); color: var(--accent-ink); } +@media (max-width: 640px) { + .site-header { + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 0.55rem; + padding-block: 0.5rem; + padding-inline: 0.8rem; + } + .site-name { max-width: 5.8rem; overflow: hidden; text-overflow: ellipsis; } + .site-header nav { width: auto; justify-content: start; } + .site-header nav a { min-height: 34px; } + .theme-toggle { padding-inline: 0.55rem; } +} + +/* ---- resume and contact ---- */ +.resume-shell, +.contact-shell { + max-width: var(--prose); +} +.resume-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: start; + border-bottom: 2px solid var(--ink); + padding-bottom: 1.1rem; + margin-bottom: 1.3rem; +} +.resume-section { + border-bottom: 1px solid var(--line); + padding: 1.1rem 0; +} +.resume-section article + article { margin-top: 1rem; } +.resume-section h2 { font-size: 0.95rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--accent-ink); } +.resume-section h3 { margin-bottom: 0.2rem; } +.resume-meta { color: var(--muted); font-size: 0.9rem; margin: 0 0 0.4rem; } +.contact-form { + display: grid; + gap: 1rem; + margin: 1.5rem 0; +} +.contact-form label { + display: grid; + gap: 0.35rem; + color: var(--muted); + font-weight: 650; +} +.contact-form input, +.contact-form textarea { + width: 100%; + border: 1px solid var(--line); + border-radius: 0.7rem; + background: var(--surface); + color: var(--ink); + font: inherit; + padding: 0.7rem 0.8rem; +} +.contact-form textarea { resize: vertical; } +.button { width: fit-content; padding-inline: 1rem; } +@media (max-width: 560px) { + .resume-header { display: block; } +}