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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Changelog

## 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.
- Removed a duplicate Demos hero chip and trimmed the homepage hero chips to the highest-intent actions.
- Added an explicit Launch demo affordance to demo cards so visitors can tell the whole card opens the live app.

## 2026-07-12: Mobile demo reachability pass

- Improved the Strava 3D Explorer mobile bottom sheet so the primary controls open at a useful half-height by default, respect dynamic viewport height and safe areas, and keep drag gestures from fighting page scroll.
- Tightened Air Quality and Isochrones mobile layouts with sticky map canvases, scrollable control panels, 44px touch targets, visible focus rings, and safer viewport units.
- Simplified Isochrones mobile scenario and stat rows so all controls and results remain readable and reachable on narrow screens.

## 2026-07-12: Public-readiness copy and header CTA pass

- Reworked the public README around the repository's actual job: a portfolio, demo lab, and runnable reference architecture in one Cloud Run container.
- Updated the portfolio positioning to cover solution architecture, forward-deployed incubation, and product growth leadership alongside developer experience, while keeping claims tied to shipped work and measured proof points.
- Added persistent header calls to action for demos and contact, and wired the portfolio app card to use the real checked-in portfolio preview image.
- Refined work and about copy so Google Maps Platform incubation work, architecture artifacts, agent-ready platform work, and growth distribution show up across the site without overstating individual claims.

All notable changes to this project will be documented in this file.

## 2026-07-12: Sticky nav, profile/social sync
Expand Down
14 changes: 14 additions & 0 deletions LEARNINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,17 @@ Context: After fixing the WIF attribute condition, the GitHub Actions token was
Learning: In Google Cloud Workload Identity Federation, authenticating as a WIF principal is only half the battle. That principal must also have the `roles/iam.workloadIdentityUser` role bound on the Service Account it needs to impersonate. If the repository is renamed, the mapped principal (e.g., `principalSet://.../attribute.repository/NEW_REPO`) changes, and the old IAM binding on the service account will no longer grant access.
Evidence: The service account `github-actions-deployer` only allowed impersonation from `attribute.repository/ryanbaumann/trails.ninja`. Adding a new IAM policy binding for `attribute.repository/ryanbaumann/Portfolio` via `gcloud iam service-accounts add-iam-policy-binding` restored the GitHub Action's ability to impersonate the service account.
Use next time: When renaming a repository that uses GCP WIF with Service Account Impersonation, you must update BOTH the WIF provider's attribute condition AND the Service Account's IAM policy bindings to allow the new mapped principal.

## 2026-07-12: External planning docs need public access or exported assets

Context: A public-readiness pass referenced a Google Doc for image and thumbnail source material.
Learning: The agent environment cannot use a private or account-gated Google Doc as an asset source, even when the URL is provided in the prompt. Public portfolio image passes should rely on checked-in assets, public direct image URLs, or exported files committed under `portfolio/static/`.
Evidence: Opening `https://docs.google.com/document/d/1pkIYPenH9mrjlCg3EoBkBL1tYUZBwrnnCmqeRUkGtRs/edit?tab=t.mkssxc4q84rl` from the browsing tool returned no readable document content.
Use next time: If a source doc contains real images, export or attach those assets before the implementation pass. Otherwise use existing real screenshots first and generated artifact cards only where no honest screenshot exists.

## 2026-07-12: Mobile map demos need dynamic viewport units and explicit touch targets

Context: A mobile-readiness pass found the demos used desktop-friendly `100vh` map shells and panels, plus mobile controls that were reachable but not reliably comfortable on browser UIs with collapsing address bars.
Learning: Map demos should pair `100svh` or `100dvh` with safe-area padding, 44px touch targets, visible focus rings, and a clear mobile panel model. Bottom sheets should default to a useful state and prevent drag gestures from scrolling the page underneath.
Evidence: Updating Strava Explorer's bottom sheet default to half-height, using `visualViewport`, and adding `touch-action`/safe-area CSS made the primary route and tour controls immediately reachable. Updating Air Quality and Isochrones mobile CSS gave the map a stable sticky viewport and made panel controls scroll independently below it.
Use next time: For every new map demo, test the narrow layout first: map remains usable, controls remain reachable by touch and keyboard, panels account for safe areas, and no essential action depends on desktop hover.
133 changes: 51 additions & 82 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,116 +1,85 @@
# Ryan Baumann Portfolio site and demo lab
# Ryan Baumann Portfolio: one site, one demo lab, real maps

Ryan Baumann's personal site — work, writing, talks, and a lab of live demo
apps — served together behind one zero-dependency Node gateway as a single
Cloud Run container. The canonical public URL is
https://www.ryanbaumann-portfolio.com/. The site lives at the root; every demo
mounts under its own path; navigation works in every direction. See
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full design.
This repo is the public home for Ryan Baumann's portfolio and demo lab. It is part personal site, part runnable reference architecture, and part proof that developer experience work should ship as real artifacts.

## Apps
The live site is https://www.ryanbaumann-portfolio.com/.

* **[Site / Portfolio](portfolio/README.md)** (served at `/`): Ryan's home page — work, writing (the blog), talks, and the demo lab — built as a zero-dependency static site over a flat-file markdown CMS. Staged here for extraction into its own `portfolio` repo.
* **[Strava 3D Explorer](strava-explorer/README.md)**: A Vite-powered web application to visualize Strava routes, endpoints, and photos in Google Maps Platform Photorealistic 3D with follow-camera animations.
* **[Air Quality Map](aqi-map/README.md)**: A live air-quality heatmap and click-to-inspect conditions from the Google Maps Platform Air Quality API on a 2D Google map.
* **[Isochrones](isochrones/README.md)**: A Vite + Node Google Maps Platform demo for analyzing delivery, commute, and response reachability with live-regenerating isochrone bands.
## What is inside

## Adding things (paved paths)
* **Portfolio site** at `/`: a zero-dependency static site over a flat-file markdown CMS. It covers work, writing, talks, and demos with no client-side JavaScript.
* **Demo lab** under app paths: Strava 3D Explorer, Air Quality Map, and Isochrones, each built as a self-contained app.
* **Gateway** in `gateway/`: a zero-npm-dependency Node server that serves the site, mounts each demo, and keeps secret-bearing API calls behind same-origin `/api/*` routes.
* **Cloud Run container**: one deployable artifact for the site and every demo.

```bash
npm run new:demo -- my-demo --title "My Demo" # scaffold + wire a new demo app
npm run new:post -- "Post title" # scaffold a blog post
npm run previews # regenerate demo screenshots
```
The portfolio narrative is intentionally grounded: solution architecture, developer experience, forward-deployed incubation at Google Maps Platform, and product growth leadership. The codebase backs that up with live apps, public docs, shipped links, tests, smoke checks, and a changelog.

A new demo shows up on the homepage, in the nav, in the container build, and
in CI's smoke test purely from its `apps.json` entry. A new post is live at
`/writing/<slug>/` on the next build.
## Start here

## Quickstart: run the whole container locally
| Goal | Command |
| --- | --- |
| Configure local secrets | `npm run setup` |
| Build the whole container locally | `npm run build` |
| Run the production gateway locally | `npm start` |
| Smoke-test routes, assets, proxies, and secret leaks | `npm run smoke` |
| Add a demo app | `npm run new:demo -- my-demo --title "My Demo"` |
| Add a blog post | `npm run new:post -- "Post title"` |
| Regenerate demo screenshots | `npm run previews` |

## Apps

This builds every app and boots the same gateway that runs in production.
First, run the interactive setup script to configure your local `.env` file with the required API keys:
* **[Site / Portfolio](portfolio/README.md)**, served at `/`: Ryan's home page, work, writing, talks, and demo index.
* **[Strava 3D Explorer](strava-explorer/README.md)**: visualize Strava routes, endpoints, and photos in Google Maps Platform Photorealistic 3D.
* **[Air Quality Map](aqi-map/README.md)**: inspect live Air Quality API heatmap tiles and point conditions on a 2D Google map.
* **[Isochrones](isochrones/README.md)**: analyze delivery, commute, and response reachability with live-regenerating isochrone bands.

## Local development

```bash
npm run setup # interactively generates your .env file
node scripts/build-local.mjs # builds each app, stages apps/<name>/ like the Dockerfile does
node gateway/server.js # or: npm start
npm run setup
npm run build
npm start
```

Then open `http://localhost:8080/`. To exercise it end-to-end (route
liveness, asset resolution, OAuth URL shape, and a secret-leak scan over
every built file):
Open `http://localhost:8080/`. Then run:

```bash
node scripts/smoke.mjs # or: npm run smoke
npm run smoke
```

## Working on a single app

Each app is still self-contained for day-to-day development — navigate to
its directory and follow its README:
Each app can also run on its own dev server:

```bash
# To run the Strava 3D Explorer
cd strava-explorer
npm install
npm run dev

# To run the Air Quality Map
cd aqi-map
npm install
npm run dev

# To run the Isochrones Demo
cd isochrones
npm install
npm run dev

# To build and preview the Portfolio (zero dependencies)
cd portfolio
node build.mjs && node serve.mjs
cd strava-explorer && npm install && npm run dev
cd aqi-map && npm install && npm run dev
cd isochrones && npm install && npm run dev
cd portfolio && node build.mjs && node serve.mjs
```

Each app's own dev server proxies or stands in for the gateway's `/api/*`
routes locally — see each app's README for details. `AGENTS.md` has a full
"Adding a new demo app" walkthrough for wiring a new app into the gateway.
## Architecture in one paragraph

## Security Best Practices
`portfolio/` builds static HTML into `portfolio/dist/`. Each demo builds its own static bundle. `scripts/build-local.mjs` stages those outputs under `apps/<name>/`, matching the Docker runtime layout. `gateway/server.js` serves the portfolio at the root, mounts demo apps from `apps.json` by most-specific path first, and proxies secret-bearing calls through server-side routes. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full design.

* **No Hardcoded Secrets**: Never commit API keys, client secrets, access tokens, or generated `.env.*` files. Use environment files locally (which are excluded via `.gitignore`).
* **Key Restrictions**: Always restrict Google Maps browser keys by referrer (e.g., `http://localhost:5173/*` and your production domain) and limit their API scope to only the services required (Maps JavaScript, Air Quality, Places).
* **Server-Side Proxy**: `gateway/` brokers every secret-bearing API call (Strava OAuth token exchange/refresh/deauthorize + photo proxy, Google Maps Isochrones) same-origin under `/api/*`, so unrestricted API secrets never reach the browser — only referrer-restricted public browser keys (Google Maps JS) ship in the client bundles, which is their designed use.
## Security posture

## Deploy
* Never commit API keys, OAuth secrets, access tokens, refresh tokens, or generated `.env.*` files.
* Browser-exposed Google Maps keys must use `VITE_` variables, API restrictions, and HTTP referrer restrictions.
* Server-side secrets stay in Node env vars: `STRAVA_CLIENT_ID`, `STRAVA_CLIENT_SECRET`, and `GMP_SERVER_API_KEY`.
* Gateway proxy endpoints return keyless `503` JSON responses instead of crashing, so smoke tests can run without secrets.

`.github/workflows/deploy.yml` builds the container image with Cloud Build
and deploys it to a single Cloud Run service (currently named `trails-ninja` for
legacy continuity) on pushes to
`main`, authenticating via Workload Identity Federation. It replaces the
project's previous two-part deploy (a GCS bucket for the static frontend
plus a separate Cloud Run OAuth broker) — see the workflow file's header
comment for the one-time `gcloud run services update ... --update-secrets`
command that sets runtime secrets on the service.
## Deploy

## Cost Note
`.github/workflows/deploy.yml` builds the container with Cloud Build and deploys it to Cloud Run on pushes to `main` using Workload Identity Federation.

> [!NOTE]
> Google Maps Platform usage may incur costs. Consider using the free Maps Demo Key for prototyping: https://mapsplatform.google.com/maps-demo-key.
## Cost and terms

## Terms of Service Compliance
Google Maps Platform usage may incur costs. For prototypes, consider the Maps Demo Key: https://mapsplatform.google.com/maps-demo-key.

These projects integrate third-party APIs. By using them, you agree to comply with their respective Terms of Service:
* **Google Maps Platform**: Subject to the [Google Maps Platform Terms of Service](https://cloud.google.com/maps-platform/terms) and [Acceptable Use Policy](https://cloud.google.com/maps-platform/terms/aup). Users of applications using these features are also bound by the [Google Maps End User Additional Terms of Service](https://maps.google.com/help/terms_maps.html) and [Google Privacy Policy](https://policies.google.com/privacy).
* **Strava**: Subject to the [Strava Developer Agreement](https://www.strava.com/legal/api).
These projects integrate Google Maps Platform and Strava APIs. Use them in compliance with the [Google Maps Platform Terms of Service](https://cloud.google.com/maps-platform/terms), [Google Maps End User Additional Terms](https://maps.google.com/help/terms_maps.html), [Google Privacy Policy](https://policies.google.com/privacy), and the [Strava Developer Agreement](https://www.strava.com/legal/api).

## Contributing

Contributions are welcome! Please follow these guidelines:
1. Fork the repository and create a feature branch.
2. Ensure changes are localized to the specific project directory you are modifying.
3. Keep changes simple, clean, and well-documented.
4. Open a pull request describing the changes and testing completed.
Small, focused PRs are welcome. Keep app-specific code, commands, and dependencies inside the app directory you are modifying. Use npm only. Include the validation commands you ran.

## License

This repository is licensed under the [MIT License](LICENSE).
MIT. See [LICENSE](LICENSE).
2 changes: 1 addition & 1 deletion apps.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"path": "/",
"dev_build_dir": "portfolio/dist",
"tags": [],
"preview": null
"preview": "/previews/portfolio.jpg"
},
{
"name": "strava-explorer",
Expand Down
16 changes: 12 additions & 4 deletions aqi-map/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; }
button, input, select { font: inherit; }
button, input, select, a { touch-action: manipulation; }
button, select, input:not([type="range"]):not([type="checkbox"]) { min-height: 44px; }
button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible { outline: 2px solid #34d399; outline-offset: 2px; }

#app {
min-height: 100vh;
min-height: 100svh;
display: grid;
grid-template-columns: minmax(320px, 400px) 1fr;
background: radial-gradient(circle at top left, rgba(52, 211, 153, 0.22), transparent 34rem), #050816;
Expand All @@ -24,6 +28,7 @@ button, input, select { font: inherit; }
padding: clamp(1rem, 3vw, 1.5rem);
overflow-y: auto;
max-height: 100vh;
max-height: 100svh;
border-right: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(15, 23, 42, 0.88);
backdrop-filter: blur(18px);
Expand Down Expand Up @@ -123,11 +128,14 @@ input[type="range"] { padding-inline: 0; accent-color: #34d399; }
.credits a { color: #94a3b8; }
.credits a:hover { color: #34d399; }

.map-shell { position: relative; min-height: 100vh; }
.map-shell { position: relative; min-height: 100vh; min-height: 100svh; }
#map { position: absolute; inset: 0; min-height: 100%; }

@media (max-width: 860px) {
#app { grid-template-columns: 1fr; }
.panel { max-height: none; border-right: 0; order: 2; }
.map-shell { min-height: 58vh; order: 1; }
body { overflow: auto; }
#app { grid-template-columns: 1fr; grid-template-rows: minmax(52svh, 58dvh) auto; }
.panel { max-height: none; border-right: 0; border-top: 1px solid rgba(148, 163, 184, 0.2); order: 2; padding-bottom: max(1rem, env(safe-area-inset-bottom)); }
.map-shell { min-height: 52svh; order: 1; position: sticky; top: 0; }
h1 { font-size: clamp(2rem, 13vw, 3rem); }
.conditions .aqi-headline { align-items: flex-start; }
}
22 changes: 18 additions & 4 deletions isochrones/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; }
button, input, select { font: inherit; }
button, input, select, a { touch-action: manipulation; }
button, select, input:not([type="range"]):not([type="checkbox"]) { min-height: 44px; }
button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible { outline: 2px solid #38bdf8; outline-offset: 2px; }

#app {
min-height: 100vh;
min-height: 100svh;
display: grid;
grid-template-columns: minmax(330px, 400px) 1fr;
background: radial-gradient(circle at top left, rgba(14, 165, 233, 0.24), transparent 34rem), #050816;
Expand All @@ -24,6 +28,7 @@ button, input, select { font: inherit; }
padding: clamp(1rem, 3vw, 1.5rem);
overflow-y: auto;
max-height: 100vh;
max-height: 100svh;
border-right: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(15, 23, 42, 0.88);
backdrop-filter: blur(18px);
Expand Down Expand Up @@ -151,13 +156,22 @@ output { color: #38bdf8; font-weight: 700; }
.credits a { color: #94a3b8; }
.credits a:hover { color: #38bdf8; }

.map-shell { position: relative; min-height: 100vh; }
.map-shell { position: relative; min-height: 100vh; min-height: 100svh; }
#map { position: absolute; inset: 0; min-height: 100%; }

@media (max-width: 860px) {
#app { grid-template-columns: 1fr; }
.panel { max-height: none; border-right: 0; order: 2; }
.map-shell { min-height: 55vh; order: 1; }
body { overflow: auto; }
#app { grid-template-columns: 1fr; grid-template-rows: minmax(52svh, 56dvh) auto; }
.panel { max-height: none; border-right: 0; border-top: 1px solid rgba(148, 163, 184, 0.2); order: 2; padding-bottom: max(1rem, env(safe-area-inset-bottom)); }
.map-shell { min-height: 52svh; order: 1; position: sticky; top: 0; }
h1 { font-size: clamp(2rem, 13vw, 3rem); }
.scenario-grid { grid-template-columns: 1fr; }
.scenario { grid-template-columns: auto 1fr; justify-items: start; text-align: left; align-items: center; min-height: 52px; }
.scenario span:last-child { grid-column: 2; }
.insights-header { flex-direction: column; align-items: flex-start; }
.origin-label { text-align: left; }
.stat { grid-template-columns: auto 1fr; gap: 0.35rem 0.65rem; }
.stat-area, .stat-delta { grid-column: 2; text-align: left; }
}

@media (prefers-reduced-motion: no-preference) {
Expand Down
Loading
Loading