diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b31923..c40709e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/LEARNINGS.md b/LEARNINGS.md index 21c9206..b3a8b05 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -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. diff --git a/README.md b/README.md index d38356c..08a7405 100644 --- a/README.md +++ b/README.md @@ -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//` 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// 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//`, 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). diff --git a/apps.json b/apps.json index bc1af2d..15245ca 100644 --- a/apps.json +++ b/apps.json @@ -6,7 +6,7 @@ "path": "/", "dev_build_dir": "portfolio/dist", "tags": [], - "preview": null + "preview": "/previews/portfolio.jpg" }, { "name": "strava-explorer", diff --git a/aqi-map/src/styles.css b/aqi-map/src/styles.css index 57b7ffb..0e0f56a 100644 --- a/aqi-map/src/styles.css +++ b/aqi-map/src/styles.css @@ -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; @@ -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); @@ -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; } } diff --git a/isochrones/src/styles.css b/isochrones/src/styles.css index 0492760..3719e2a 100644 --- a/isochrones/src/styles.css +++ b/isochrones/src/styles.css @@ -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; @@ -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); @@ -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) { diff --git a/portfolio/build.mjs b/portfolio/build.mjs index 4f65808..271c781 100644 --- a/portfolio/build.mjs +++ b/portfolio/build.mjs @@ -323,6 +323,12 @@ function layout({ title, description, content, active = '', canonical, ogImage, 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); @@ -380,6 +386,7 @@ ${robotsTag ? robotsTag + '\n' : ''}${jsonLdTag ? jsonLdTag + '\n' : ''}