Project case study

Civic experience / 2026

Visit Knob Noster

Two skies, one town.

A responsive React visitor guide for Knob Noster, Missouri: things to do, the state park, downtown, sample itineraries and trip planning, with live local weather from Open-Meteo.

Explore live project
Project
Independent portfolio concept
Role
Design + Development
Built with
React 19 · React Router · Vite · Open-Meteo API
Code
View on GitHub ↗
Visit Knob Noster homepage with the Knob Noster, Missouri headline over a lake photo and population, founding year, state park size and highway stats
The live Visit Knob Noster homepage.

01 / The brief

Two skies, one town.

Knob Noster, Missouri sits between a 3,934-acre state park and Whiteman Air Force Base, home of the B-2. Visitors come for one and discover the other. The guide needed to help someone plan a day or a weekend around both, in a few minutes.

It also needed to be honest about what it is. This is an independent portfolio concept, not an official tourism site, so every listing points to the official source for hours, access rules and events. Visitor facts were checked against the Knob Noster Chamber of Commerce, Missouri State Parks, the U.S. Air Force, the Missouri Department of Conservation and the U.S. Census.

Two side-by-side panels for Knob Noster State Park and Whiteman Air Force Base under the heading Chase the trails, or chase the flyover
The two reasons people visit, given equal weight on the homepage.

02 / Official sources

Real photos, credited. Real links, kept in one place.

A town guide without real photos feels like a template, but a portfolio concept can’t borrow tourism photography. I tracked down public-domain images instead: Missouri State Parks photos of the lake and trails, and a U.S. Air Force photo of a B-2 Spirit at Whiteman. Each photo is stored with its credit and a link back to the original, and the page hero prints that credit on the image, so nothing appears uncredited.

The same rule applies to facts. Hours, trail closures, camping, base tours and base access change, so the guide links to the people who maintain them: Missouri State Parks, Whiteman Air Force Base and the Knob Noster Chamber of Commerce. Every one of those URLs lives in a single sources file. When an agency moves a page, one line changes and every link across the site follows.

src/data/photos.js ↗
b2Sunset: {
  url: "/images/whiteman-b2-spirit-optimized.jpg",
  credit: "U.S. Air Force / Airman 1st Class Hailey Farrell · Public domain",
  creditUrl: "https://www.dvidshub.net/image/8346253/spirit-vigilance-2024",
  alt: "B-2 Spirit aircraft taxiing at Whiteman Air Force Base in April 2024",
},
Printable Visitor Guide page with three planning steps, a know-before-you-go list and an Official Links panel for the Chamber, the state park map, camping, park events, Whiteman tours and driving directions
Visitor Guide: a printable starting point, with every official link in one panel, including driving directions.
Knob Noster State Park page with acreage, trail count and lakes above a shortlist of official trails
State Park: a credited public-domain photo, and a shortlist of trails that links to Missouri State Parks for current maps and closures.

03 / Live data

Weather and directions a visitor can act on.

The Plan a Trip page shows live current conditions and a four-day forecast in Fahrenheit from the Open-Meteo API. Open-Meteo needs no API key, so the browser calls it directly and the site stays fully static.

The request asks for Central time, so “Today” always means Knob Noster’s today, wherever the visitor is reading from. The response is checked before anything renders, and Open-Meteo’s numeric weather codes are mapped to plain labels and a small set of icons. If the service is down, the widget says so and tells the visitor to check a forecast before heading out, instead of showing a blank box.

Directions needed no API at all. “Open live directions” is a Google Maps link with Knob Noster as the destination, opened in a new tab. Google Maps fills in the visitor’s starting point and current traffic, so the guide never asks for their location, and the drive times on the page stay a rough guide rather than a promise.

The site’s Content Security Policy only allows network requests to itself and api.open-meteo.com.

src/components/WeatherWidget.jsx ↗
fetch(url)
  .then((res) => {
    if (!res.ok) throw new Error("Weather request failed");
    return res.json();
  })
  .then((data) => {
    // Render only a response that has the fields the widget needs.
    if (data?.error || typeof data?.current?.weather_code !== "number" || !Array.isArray(data?.daily?.time)) {
      throw new Error("Unexpected weather payload");
    }
    if (!cancelled) setState({ status: "ready", data });
  })
  .catch(() => {
    if (!cancelled) setState({ status: "error", data: null });
  });
Current conditions card showing 58°F and clear sky with a four-day forecast, above drive times from Kansas City, Columbia, Warrensburg and Sedalia
Live conditions and a four-day forecast, then drive times along U.S. Route 50 and a link to live directions.

04 / Content as data

A new trip plan is a data change, not a new page.

The project started as static HTML mockups. Rebuilding it in React turned repeated markup into shared components (page hero, section heads, a stat strip and a day-by-day timeline) and moved the content into data files.

Things to do, trails, downtown listings, drive times and the three sample itineraries all live in src/data. Adding a weekend plan or a new trail means editing data, and every page that uses it updates the same way.

Suggested Itineraries page with three trip plans, The Perfect Day, Weekend Getaway and Aviation and Town Route, above a morning-to-evening timeline for The Perfect Day
Itinerary: three trip plans from one data file. A fourth plan would be a new entry, not a new page.
Things to Do page with category filters for outdoors, only here, downtown and seasonal above listing cards
Things to Do: every listing is anchored to a verified destination or an official planning source.

05 / Access

Built for keyboards and small screens.

  • A skip link, visible focus states and a reduced-motion mode.
  • On every route change the page scrolls to the top, the document title and description update, and focus moves to the new heading.
  • The itinerary picker follows the ARIA tabs pattern, with arrow keys, Home and End.
  • The mobile menu reports its open and closed state to assistive technology and closes when the route changes.
  • Weather loading is announced through a live region.
  • Every page passes an automated WCAG 2.2 AA accessibility scan on desktop and phone.
Visit Knob Noster homepage on a phone
Phone: homepage
Visit Knob Noster mobile menu open on a phone
Phone: navigation

06 / Testing

Tests that check what the guide promises.

The guide promises official sources, credited photos and weather a visitor can trust, so the tests check those promises. 71 unit and data tests confirm that every official link points to an official host, that pages link out only through the sources file, that every photo ships with its credit, and that the weather widget handles loading, success and five kinds of failure. 89 browser tests on desktop and phone cover every page’s layout and keyboard support, the Google Maps hand-off, weather with a mocked Open-Meteo response and an outage, and an accessibility scan of every page.

Writing them found real problems. On phones the homepage headline sat against the edge of the screen, the gold section labels on dark backgrounds were rendering dark brown and were nearly invisible, and the rust text fell short of WCAG AA contrast. All three are fixed, and the tests keep them fixed.

GitHub Actions runs lint, the unit tests and the browser tests on every push and pull request.

tests/e2e/pages.spec.js ↗
// Regression test: the homepage hero once lost its side padding on phones.
const touching = await page.evaluate(() =>
  [...document.querySelectorAll("main :is(h1, h2, h3, p, li, a.btn)")]
    .filter((el) => {
      const box = el.getBoundingClientRect();
      return box.width > 0 && (box.left < 12 || box.right > window.innerWidth - 12);
    })
    .map((el) => `${el.tagName.toLowerCase()}: ${el.textContent.trim().slice(0, 40)}`)
);
expect(touching).toEqual([]);

Explore the work

See the experience
in motion.