Public-facing hub for NOAA/GSL fire weather social science needs and findings. React + TypeScript + Vite, built against the Firehouse design system and the July 2026 creative brief.
npm install
npm run dev # http://localhost:5173
npm run build # typecheck + production build to dist/
npm run preview # serve the production build| Route | Status |
|---|---|
/ |
Landing page — logo and hero with live stats, then the four topic areas' top-5 needs with mention counts, "where these needs come from", the GACC coverage map, and the submit CTA band. |
/about |
About page — what the hub is, the three audiences it serves, how a completed project becomes a published need, and the two people who maintain it. All copy lives in about.json; the biographies are rendered verbatim. |
/projects |
Project explorer — brief §5.2. Search, fire-phase / GACC / year / project-type filters, sort, detail modal. |
/topics/:topicKey |
Topic area page — the area's top five needs, each with its mention count and links to the projects it draws on, plus a "what this area covers" callout. One per TopicKey. Projects are not filed under topics: needs are synthesized across the whole collection, so the attribution is per need. |
* |
Custom 404 — echoes the requested address, and offers the explorer plus all four topic areas rather than dead-ending. Also rendered by /topics/:topicKey for a key that isn't one of the four, with wording tailored to that case. |
The landing page is a faithful implementation of Firehouse Landing Page.dc.html
from the Claude Design project. Every deviation is listed under
Deliberate deviations below.
src/
design-system/ The Firehouse design system, ported to React. Pages import
only from here — never raw token values.
styles/tokens/ colors / typography / spacing, ported byte-faithfully.
styles/ global.css, a11y-overrides.css
content/ CMS-agnostic content layer (see below)
pages/ LandingPage, ProjectsPage, NotFoundPage
components/ Layout, ThemeProvider
assets/ NOAA + GSL logos; hero imagery (see caveat)
public/ Served verbatim at the site root: icons, the link-preview card,
robots.txt, and the two host-level SPA fallbacks (see Hosting)
Thirteen components from
_ds/the-firehouse-design-system-a05f43c6-bfb5-47a4-b052-1f16fdeac42a/_ds_bundle.js
were ported to typed React: Button, Icon, TopicTag, StatCounter,
ProjectTile, ProjectDetailModal, TextField, TextArea, GovBanner,
SiteHeader, SiteFooter, plus the TOPICS map.
Three deliberate changes to how they're built, none to how they look:
- Inline styles → CSS files. The source used React state for hover and inline
style objects throughout. Those became real
:hover/:focus-visiblerules and media queries — which is what makes the responsive and keyboard behaviour below possible at all. Token values are unchanged. - Lucide from CDN →
lucide-react. The sourceIconinjected<i data-lucide>and called a global fromunpkg.com. Icons are now real React elements: no third-party script (which some agencies restrict outright), no layout shift, and an unknown icon name is a compile error rather than an empty box. Buttonis polymorphic. Renders<button>, a router<Link>, or an<a>. "Submit a Finding" goes off-site to the Qualtrics survey and has to be a real link.
Everything renderable comes from src/content, behind one interface:
interface ContentAdapter {
load(): Promise<SiteContent>;
}Three adapters ship: local (bundled JSON, the default), sanity (one GROQ query
over the HTTP API), and strapi (v5 REST). Selected by VITE_CMS — see
.env.example. Neither CMS adapter pulls in an SDK, so choosing between Sanity and
Strapi later doesn't change the dependency tree.
src/content/types.ts is the contract every adapter meets. normalize.ts runs on
all of them: it rejects unknown topic keys and duplicate slugs loudly, and resolves
the "empty href inherits settings.submitFormUrl" convention so the submission survey
address lives in exactly one field even though five controls point at it.
Live stats are computed, not stored. landing.json never contains the number 9.
Each stat declares a source (publishedProjectCount / topicCount / static) and
derive.ts counts live content. Editors control the wording; they can't put the
count out of step with what's published. Publishing a tenth project moves the
counter on its own — brief §5.1.
Structural things stay in code on purpose: the four topic keys (observe,
forecast, warning, governance) are a TopicKey union, because adding a fifth
topic is a schema and pipeline change, not a CMS edit. Their display names are
editable.
Everything below is an addition or correction, with the reason. Nothing was dropped.
Required by the brief (§9.2), absent from the design:
- Mobile navigation. The source header is one flex row that overflows below ~900px. Added a disclosure drawer under 1024px; the identity lockup and theme toggle stay visible. The site has to work on a phone at AMS.
- Responsive footer. The 4-column grid was fixed; it now collapses to 2 and 1.
- Real dialog semantics on
ProjectDetailModal—aria-modal, focus moved in and restored, Escape to close, focus loop. The source was a presentational panel. - Focus management on route change. Client-side routing swaps the DOM without
moving focus.
RouteFocusManagermoves it to<main>— but only on an actual navigation, never on the initial page load. A cold load already starts focus at the top of the document, and pulling it into<main>there would leave the skip link, the gov banner toggle and the entire header behind the focus position: the first Tab would land inside the content and the skip link could never be reached at all. - Skip link,
aria-currenton the active nav item,<label htmlFor>+aria-describedby+aria-invalidwiring on the form fields,prefers-reduced-motion.
Two AA contrast failures corrected — see src/styles/a11y-overrides.css for
measurements. Both are design system bugs and should be fixed upstream; the
overrides are isolated so a re-sync doesn't lose them.
--color-text-tertiary(--slate-500) was 4.07:1 on the page background, carrying 12px caption text. Darkened to#616A7A(5.13:1).- In dark mode, white on
--ember-400was 2.84:1 — that's the label on the primary "Submit a Finding" CTA. The label flips to navy-950 (6.71:1); the ember fill is untouched.
Judgment calls, easy to reverse:
- "Last reviewed " on each topic card. Not in the design. The card copy
promises the area "updates automatically", and a reader has no way to tell how
fresh it is. It only renders when a summary has
updatedAt— delete that field intopicSummaries.jsonand the line disappears. - "About" back in the nav. It was in the design pointing at nothing, so it was
cut; the page now exists (
/about) and the link is restored insettings.json, alongside one in the footer's "Explore" group. - Project explorer built. The landing page's secondary CTA points at it and the
design system ships
ProjectTile/ProjectDetailModalfor exactly this screen, so shipping the landing page alone would have left a dead button. - Topic area pages built (
/topics/:topicKey), and the landing cards now link to them. The design left the four cards inert; they read as the entry point to an area but went nowhere. Each page carries the introduction the card has no room for, the full needs list rather than the top two, and every project in that area. The route key is theTopicKeyitself, not a CMS slug — the keys are structural, so a published URL can't be broken by renaming a topic in the CMS. - Two new topic fields,
intro(paragraphs) andcovers(a "what this area covers" list), intopics.jsonandtypes.ts. Both optional: a topic without them still renders, just shorter. Arrays of plain strings rather than rich text, so nothing has to render editor-supplied HTML.
- Replace
submitFormUrlinsrc/content/data/settings.json— now the FireHouse Qualtrics survey. Every submit CTA reads from it. - Replace
projects.jsonwholesale from the FireHouse 1.0 report. The nine records there are placeholders — seesrc/content/data/README.md. Their takeaways and recommendations say "Placeholder" on purpose, andpapersis empty everywhere because no citation was invented. - Add the hero image.
src/assets/imagery/fire-weather-hero.pngis missing — it's a binary asset in the design project and couldn't come across the design-sync API, which returns text. Drop the file in that directory and it's picked up automatically; until then the hero renders a gradient stand-in. Seesrc/assets/imagery/README.md. - Review the draft "top needs" synthesis in
topicSummaries.json. It was drafted with Claude from the four published projects, with every need traced to the survey entries it counts innotebooklm/draft-synthesis-2026-09-17.md. Nothing is reviewed yet, so the site labels it a draft on every surface that shows it — run the NotebookLM pass innotebooklm/README.md, edit the wording, then setreviewedByto clear the label. - Review the topic-area
coverscopy intopics.json— the bullet list in the callout, and now the only area copy on/topics/…. The longerintroparagraphs are no longer rendered (September 2026 review) but are kept in the data. Stephanie or Emily should own the wording. - Set
VITE_SITE_URLonce hosting is settled, socanonical,og:urlandog:imageare emitted. Until then shared links preview without the image card. - Confirm the SPA fallback with Matt — which of
.htaccess/_redirects/404.htmlactually applies on GSL's host, and drop the two that don't. - Add a
Sitemap:line topublic/robots.txtand generatesitemap.xml; both need the final URL. If the build goes on a staging URL before launch, fliprobots.txttoDisallow: /so the preview isn't indexed. - Decide on self-hosted fonts. Archivo and Public Sans load from Google
Fonts. Some agencies prohibit third-party CDN calls; swapping the
@importintokens/typography.cssfor local@font-faceis the only change needed. - Run an audit with a real screen reader, and check the remaining federal requirements the brief flags as unconfirmed (USWDS conventions, privacy notice, .gov branding).
Named so it's clear these are gaps, not oversights:
- CMS. The content layer is ready and two adapters are written, but no Sanity or Strapi instance exists and neither adapter has been run against a live backend. A matching schema still has to be authored on whichever is chosen.
- Submission pipeline. The Qualtrics survey is an outbound link. Nothing yet moves a
processed submission into
topicSummaries.json— brief §9.1 allows this to be manual at first, and the brief's own risk list recommends a human review step. - The rest of explorer search. Search and filters are in (
src/content/search.ts); still open fromPLAN.md§1.2–1.3 are highlighting matched words in results and multi-select facets (each facet is currently single-select). - Build-time link previews for project pages. Each project has its own URL and
sets its own
<title>and description at runtime, so deep links work and index correctly — but a shared link still previews with the site-level card. Fixing it means emitting a per-project HTML shell at build time (PLAN.md§2.2). - Tests. None. Worth adding around
normalize.tsandderive.tsfirst — they're pure functions and they're what stands between a bad CMS edit and a broken page.
The landing-page hero carries a map of where research comes from, built to
FirehouseFormAdditions.pdf.
- Regions are the survey's recode values —
ONCC, notNorthern California (ONCC). Display text gets reworded over time; codes must not, or the map breaks quietly. Defined insrc/design-system/taxonomy.ts. - Fourteen values, not ten. The ten GACCs have boundaries.
PACIFIC,NATIONAL,INTLandUNKNOWNdo not, and are listed beside the map rather than dropped — a total that excludes them under-reports what was submitted.PACIFICexists specifically because the NIFC boundary file folds the Pacific islands into Northern California, so Hawaii research would otherwise be filed under ONCC. - Boundaries are committed, not fetched.
src/content/data/gaccBoundaries.jsonis NIFC'sNational_GACC_Boundaries, simplified to ~33 kB. The map draws on a hostile network; only the basemap tiles are live. - Selection lives in
?region=, so a filtered view of the map is a link someone can paste into an email.
The one runtime third-party dependency on the whole site is the basemap tiles
(server.arcgisonline.com, Esri — attribution is rendered under the map and is
required by their terms). If a federal review objects to the external call, the map
degrades to boundaries on a blank canvas rather than failing, and the BASEMAPS
constant in src/design-system/RegionMap.tsx is the only thing to change.
Accessibility: the region list beside the map is not a fallback. It is the operable
surface — real buttons, real counts, driving the same state the polygons do — and
the Leaflet canvas is aria-hidden so assistive technology is sent there rather
than into unlabelled SVG paths. Colour is never the only channel; every region
carries its count as a number.
scripts/import-survey.mjs turns a Qualtrics CSV export into
src/content/data/projects.json:
node scripts/import-survey.mjs ~/Downloads/firehouse-survey.csv --dry--dry parses and reports without writing — use it first. The script is the seam
where survey data becomes site content, and it is plain dependency-free Node so it
can be read and re-hosted by whoever takes it over: a live integration replaces the
CSV read with an API call and writes the same file. Nothing downstream changes.
Two rules it enforces that the UI cannot:
- Q10 (how results were communicated) never enters the JSON. It is internal-use only, and a field that is merely not rendered still ships in the bundle.
- Rows without IRB approval are reported, not silently dropped — a silent drop
looks exactly like a parsing bug.
normalize.tsfilters them again at runtime as a backstop.
Imported records stage as published: false, so running the script can never put
unreviewed research on a live site as a side effect.
QUESTION_MAP at the top of the script is keyed to guessed Qualtrics column
names, pending the mapped question list from Emily and Steph. It is the only thing
that should need editing when that arrives.
WCAG 2.1 AA / Section 508 is a hard requirement (creative brief §9.2), and the translucent surface treatment is exactly where that usually goes wrong. The rule the design holds to is glass provides depth, never contrast: the page backdrop is deliberately bounded, and every panel alpha is chosen so text clears AA against the worst-case composite rather than against the panel colour. Blur is decoration on top of a surface that already passes.
Three fallbacks drop to fully opaque surfaces — prefers-reduced-transparency,
prefers-contrast: more, and @supports not (backdrop-filter).
Two audits, complementary:
node scripts/check-contrast.mjs # the token system, incl. UI contrast (1.4.11)// scripts/audit-contrast-live.js — paste into the browser console
fhAudit(); // current theme
await fhAuditBothThemes(); // light and darkThe static script proves the palette is sound and runs without a browser. The live walk reads computed styles off real DOM nodes and catches what a token audit cannot — cascade bugs, hardcoded component colours, unaccounted translucent ancestors. Run both; they have caught different things.
Current state: 0 AA text-contrast failures across all routes in both themes, and in both fallback states.
Placement is still open (brief §8). The build is fully static and needs no server.
If it lands on a sub-path, set VITE_BASE_PATH=/firehouse/ — Vite's base and the
router's basename are already wired to it, and the icon links in index.html use
%BASE_URL% so they resolve there too.
Client routing needs the host to serve the app shell for unknown paths. /projects
and /topics/observe have no file behind them, so without this a direct hit, a refresh,
or a link shared off a conference slide returns 404. Three options ship, so whichever
host GSL lands on is already covered:
| File | Host |
|---|---|
public/.htaccess |
Apache. Needs AllowOverride FileInfo; also sets cache headers. Uncomment RewriteBase for a sub-path. |
public/_redirects |
Netlify / Cloudflare Pages. Rewrites with a 200, so the status code stays correct. |
dist/404.html |
Bucket-style hosts with no rewrite lever at all — S3, GitHub Pages, Azure Static Web Apps. Emitted at build time by the firehouse-spa-fallback plugin in vite.config.ts as a byte copy of index.html. Returns a 404 status, so prefer one of the above where possible. |
nginx has no drop-in file; ask for try_files $uri $uri/ /index.html; in the location
block.
index.html carries the Open Graph and Twitter tags that don't depend on the domain.
The three that must be absolute URLs — canonical, og:url, og:image — are injected
at build time by the firehouse-site-meta plugin from VITE_SITE_URL, and are
omitted entirely when it isn't set. That's deliberate: a shared link still previews with
its title and description, whereas a guessed domain would point at a URL that may never
exist. Set it once Matt confirms placement and the card at public/og-image.png starts
appearing in Slack, email and social unfurls.
The card (1200×630, the FireHouse wordmark on white) and the icons (favicon.ico,
favicon-32.png, icon-192/512.png, apple-touch-icon.png) are generated from the
brand files in src/assets/logos/source/. Icons use the isolated house-and-flame mark
on a white tile, so its slate outline still reads on a dark browser tab. The UI copies
in src/assets/logos/ include -on-dark variants with the slate recoloured light for
the dark header and hero. There is no vector source yet — if one arrives, regenerate
from it.