Preview: native-product-pages integration (do not merge) - #1130
Draft
busbyk wants to merge 84 commits into
Draft
Conversation
Contributor
|
Preview deployment: https://native-product-pages.preview.avy-fx.org |
Contributor
Migration Safety CheckFound 1 potential issue: 20260807_190252_native_danger_map_flag.ts Warning (line 6): ALTER keyword detected - review for data loss sql`ALTER TABLE \`settings\` ADD \`native_products_danger_map\` integer DEFAULT false;`,Review these patterns and add backup/restore logic if needed. See |
busbyk
added a commit
that referenced
this pull request
Aug 25, 2026
Resolves the conflicts blocking the preview PR (#1130). Conflict: `src/app/api/[center]/og/route.tsx` - Both sides edited the fallow waiver header. Kept this branch's comment and the plural `dynamic-segment-name-conflicts` rule name, which is what the four sibling `/api/[center]/*` routes use — `forecast-freshness` names `og` as carrying the same waiver, so they have to agree. - Import of `getForecastZoneDanger` stays on this branch's path (`@/services/nac/dangerMap/mapLayer`); it no longer lives in `nac.ts` here. - Kept main's `isWebpMedia` import — the merged handler body calls it. - The `isOgDocType` guard (main) and the `fallow-ignore-next-line complexity` waiver (this branch) are independent additions; kept both, with the waiver immediately above the handler so it still applies. `src/payload-types.ts` and `drift.lock` auto-merged; regenerating types produced no diff, confirming the auto-merge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
busbyk
added a commit
that referenced
this pull request
Aug 31, 2026
`fallow` has been the only red CI job on the integration PR (#1130), and it is wired into the pre-commit hook, so it has been blocking every commit on this branch — 05a and 05b both went in with --no-verify. This is remediation of pre-existing debt across issues 01-05, not a fix for any single issue. Both gates now exit 0, and the audit reports no issues in 166 changed files. Tell the tool about deliberate public surfaces `model/forecast.ts` exists so the model is the single import surface for consumers, so its re-exports have no consumers of their own and read as dead, transitively taking their sources in `types/forecastSchemas.ts` with them. Both are declared in `ignoreExports`, the same treatment `components/ui/**` already gets. This clears 23 of 39 dead-code findings on its own. ADR 018 was cited by `model/forecast.ts` but had never actually been written; it is written here, since it is the justification for that config. Delete what was genuinely dead `getUseNativeForecasts.ts` (every call site had migrated to `getNativeProductFlag`), the unconsumed `getProductDataSource` re-export in `sources/index.ts`, and the `ProductListItem` alias. Suppress the z.nativeEnum false positives Members of `AvalancheProblemType`, `ProductStatus` and `ExternalMediaType` reached only through `z.nativeEnum` look unused because zod validates against member *values*. They are load-bearing — dropping `WindSlab` would make zod reject every forecast carrying a wind-slab problem — so they carry per-member suppressions with a docblock, matching `AvalancheCenterType` in schemas.ts. Waive the two dynamic-segment conflicts Same false positive three sibling routes already waive: route groups keep `(payload)/api/[...slug]` and `api/[center]` separate. Bring the 15 gated complexity findings to zero Mostly by extracting the pure decisions out of components into tested modules, which drops the parent's branch count and covers the logic that moved: `mediaItem.ts`, `datePickerNavigation.ts`, `dangerRatingLayout.ts`, `zoneCardDanger.ts`, plus `parseArchiveWindowQuery` in `archiveDates.ts`. The JSX-heavy components (DangerScale, AvalancheProblemCard, the date picker, NativeForecastView) are split into named sections. 63 new tests. `NativeForecastView` and `ZoneForecastCard` each had their own copy of the highest-danger-band lookup; both now use `zoneCardDanger.ts`. Collapse the 6 gated duplication groups `centerRoutePage.ts` holds the per-center route preamble the built-in pages shared verbatim — static params, metadata shape, and the platform guard. `ZoomPanSurface` holds the zoom/pan interaction the Gallery block and the forecast lightbox had duplicated. Not addressed: the 2 circular dependencies through the Payload config barrel. They are `warn`, gate nothing, and are down from 27 at the July baseline. The only fix that actually clears them is injecting `payload` across ~26 call sites — fallow counts a lazy `await import()` as a graph edge too, so that shortcut does not work. Left as-is deliberately. The baselines are untouched: nothing here was accepted as debt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
busbyk
force-pushed
the
native-product-pages
branch
from
August 31, 2026 21:25
f1793db to
daaa36e
Compare
busbyk
added a commit
that referenced
this pull request
Aug 31, 2026
`fallow` has been the only red CI job on the integration PR (#1130), and it is wired into the pre-commit hook, so it has been blocking every commit on this branch — 05a and 05b both went in with --no-verify. This is remediation of pre-existing debt across issues 01-05, not a fix for any single issue. Both gates now exit 0, and the audit reports no issues in 166 changed files. Tell the tool about deliberate public surfaces `model/forecast.ts` exists so the model is the single import surface for consumers, so its re-exports have no consumers of their own and read as dead, transitively taking their sources in `types/forecastSchemas.ts` with them. Both are declared in `ignoreExports`, the same treatment `components/ui/**` already gets. This clears 23 of 39 dead-code findings on its own. ADR 018 was cited by `model/forecast.ts` but had never actually been written; it is written here, since it is the justification for that config. Delete what was genuinely dead `getUseNativeForecasts.ts` (every call site had migrated to `getNativeProductFlag`), the unconsumed `getProductDataSource` re-export in `sources/index.ts`, and the `ProductListItem` alias. Suppress the z.nativeEnum false positives Members of `AvalancheProblemType`, `ProductStatus` and `ExternalMediaType` reached only through `z.nativeEnum` look unused because zod validates against member *values*. They are load-bearing — dropping `WindSlab` would make zod reject every forecast carrying a wind-slab problem — so they carry per-member suppressions with a docblock, matching `AvalancheCenterType` in schemas.ts. Waive the two dynamic-segment conflicts Same false positive three sibling routes already waive: route groups keep `(payload)/api/[...slug]` and `api/[center]` separate. Bring the 15 gated complexity findings to zero Mostly by extracting the pure decisions out of components into tested modules, which drops the parent's branch count and covers the logic that moved: `mediaItem.ts`, `datePickerNavigation.ts`, `dangerRatingLayout.ts`, `zoneCardDanger.ts`, plus `parseArchiveWindowQuery` in `archiveDates.ts`. The JSX-heavy components (DangerScale, AvalancheProblemCard, the date picker, NativeForecastView) are split into named sections. 63 new tests. `NativeForecastView` and `ZoneForecastCard` each had their own copy of the highest-danger-band lookup; both now use `zoneCardDanger.ts`. Collapse the 6 gated duplication groups `centerRoutePage.ts` holds the per-center route preamble the built-in pages shared verbatim — static params, metadata shape, and the platform guard. `ZoomPanSurface` holds the zoom/pan interaction the Gallery block and the forecast lightbox had duplicated. Not addressed: the 2 circular dependencies through the Payload config barrel. They are `warn`, gate nothing, and are down from 27 at the July baseline. The only fix that actually clears them is injecting `payload` across ~26 call sites — fallow counts a lazy `await import()` as a graph edge too, so that shortcut does not work. Left as-is deliberately. The baselines are untouched: nothing here was accepted as debt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
busbyk
force-pushed
the
native-product-pages
branch
from
August 31, 2026 22:51
e06bf7d to
381b525
Compare
Port forecast/warning Zod schemas from AvyApp to web package with real API fixtures from NWAC, SAC, and SNFAC. Covers forecasts, summaries (off-season), null warnings, string-typed size transforms, and all media type variants. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds fetchForecast(), fetchWarning(), and resolveZoneFromSlug() to the NAC service. All functions apply the DVAC->NWAC center alias and use 5-minute ISR revalidation. Includes unit tests for zone resolution. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Port dangerName, dangerColor, dangerTextColor, dangerIconUrl from avy app. Copy danger and problem icon PNGs to public/images/. Document cross-repo color discrepancies in docs/nac-data-display.md for future alignment. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a per-center checkbox under a new "Features" tab in Settings, defaulting to false. Includes a utility to read the flag by tenant slug, seed data, migration, and regenerated types. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The forecast sanitizer allowed `img` but not `iframe`, so a YouTube or Facebook video a forecaster embedded in a discussion was deleted with no trace, and the `data-video-id` that marks an embedded-video figure was stripped along with it. On a life-safety page, dropping a forecaster's video silently is the wrong failure. Allow `iframe` behind an explicit provider allowlist: YouTube and Vimeo through the existing `videoEmbed` helpers, plus Facebook's `/plugins/video.php` and `/plugins/post.php` over https. `allowedIframeHostnames` is not enough on its own — it deletes the `src` and leaves an empty frame — so the check lives in `transformTags`, which renames anything that fails to a tag outside the allowlist. Surviving frames get a fixed attribute set and are sized by aspect ratio rather than by the authored pixel height, which letterboxes once the column is narrower than the author's screen. An embed from a provider we won't frame becomes a link labelled with its hostname rather than disappearing. Also strip inline `height` from every forecast image. The declared width is honored up to the column width, and a surviving pixel height then stretches the image; the legacy widget forced `height: auto !important` on all of them for the same reason. `MediaSlide` now builds its YouTube URL with `getVideoEmbedUrl` too, so the same video frames the same way inline and in the lightbox. That moves the lightbox to youtube-nocookie.com with `rel=0`, matching the Gallery block. Fixtures in the tests are verbatim markup from the live v2 API. Refs #1214 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Photos a forecaster embeds in a discussion rendered as static images: no expand affordance, not zoomable, and a video figure — a poster image carrying a `data-video-id` — was indistinguishable from a photo with no way to watch it. The legacy widget put both into its lightbox. `collectEmbeddedMedia` finds those figures in the rendered DOM and builds the same `MediaItem` shape the widget built, so they can go through the existing `MediaLightbox`. Reading the DOM rather than splitting the HTML string is what the widget did, and the corpus says why it matters: of 2,261 real figures, 13 are nested inside a wrapper or another figure's `figcaption`, and one center wraps its whole discussion in a `div`, so nothing there is ever top-level. Querying the tree finds all 2,261. `DiscussionBody` still renders the discussion in one `dangerouslySetInnerHTML`, so server rendering and the no-JS rendering are unchanged, then marks up the figures after mount and portals in the legacy affordances — an expand chip, and a play button on videos. The rendered HTML sits behind `memo` because React compares the `dangerouslySetInnerHTML` wrapper by identity: re-rendering it, as opening the lightbox does, would rewrite the subtree and strand every overlay in a detached node. Only the small chip is a button. The rest of the overlay is `pointer-events: none`, as the legacy icons were, so an image keeps its own context menu; mouse users open the lightbox by clicking anywhere on the figure. Being a button is also what makes the figure keyboard-reachable — the legacy widget was click-only. Refs #1214 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`getVideoEmbedUrl` rebuilds the URL from the video id alone, which is fine for a plain video and wrong for a playlist: `/embed/videoseries?list=…` loses its list and, because `videoseries` is itself eleven characters, sails through the video-id check and asks YouTube for a video by that name — a broken player, with no sign anything went wrong. A start offset and a Vimeo private-video hash disappear the same way, more quietly. Carry `list`, `index`, `start`, `end`, `t` and `h` onto the rebuilt URL. The `si` share token still goes. Nothing in the corpus embeds a playlist today; this is about the next one. Refs #1214 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A browser never renders an `<iframe>`'s children — they are fallback for clients that can't frame — but htmlparser2 reads them as live markup. A blocked embed was being renamed to a real `<a>`, and `nonTextTags` only governs tags the sanitizer disallows, so those children survived: `<iframe src="https://evil/x"><figure class="afp-photoswipe">…` came out the other side as a figure, which `collectEmbeddedMedia` then queries for and puts in the lightbox. Content the legacy widget would never have shown, reaching a life-safety page. Keep the blocked embed on the dropped tag, which `nonTextTags` does govern, and carry the note as that transform's `text` — sanitize-html emits a discarded tag's text while discarding its children. The note is now inert rather than a link. The URL just failed the allowlist; the reader should know something was there without being one click from it. Refs #1214 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`problemPhoto` returned null for anything that wasn't an image or a photo, so a video attached to an avalanche problem rendered as nothing at all — no poster, no caption, no indication a forecaster had put one there. The legacy widget shows the poster frame with a play glyph over it and opens it in the lightbox. Across 4,545 sampled products, 1,836 problems carry media and 111 of those are videos, including recent CNFAIC, SNFAC and NWAC forecasts. All of them are YouTube: 107 as an object with a `video_id` and its own poster frame, and 4 — SNFAC, 2020 — where `url` is a bare string that is itself the YouTube id. Handle both, and route the media through the lightbox, so a problem's example photo is zoomable too rather than a static image. `getYouTubeVideoId` now reads the bare-string shape, matching the legacy widget's `image.url?.video_id ? … : "…/embed/" + image.url`. That shape reached `resolveMediaSlide` as "Unsupported media type" before, so this also fixes those four in the forecast media lightbox. A video with no poster of its own falls back to YouTube's; the legacy widget has a helper for that but never calls it, and renders a broken image instead. The expand/play affordance moves to a shared MediaOverlay, now that the discussion and the problem card both draw it. The jsdom stubs both of their tests need move to a shared client setup file rather than being copied per suite. Refs #1214 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`/embed/live_stream?channel=…` fails exactly like the playlist case: `live_stream` is eleven characters, so it passes for a video id and the rebuilt URL drops the channel it was meant to play. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ound (#1229) * fix: resolve forecast zone slugs containing an ampersand Next hands a dynamic path segment percent-encoded, so a zone whose name contains `&` arrived as `soldier-%26-wood-river-valley-mtns` and matched nothing in the zone list, which is built from the center's own zone URLs. Three of Sawtooth's four active zones are affected: the page rendered "Zone not found." — including when following the link the all-zones grid itself renders. Decoding the param means the slug is now a literal `&` where it is used to build a query string, so the OG image route and the archive fetch encode it explicitly rather than relying on it having arrived pre-encoded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(a11y): give forecast media thumbnails an accessible name The thumbnail buttons contain only a decorative `<img alt="">`, so they had no accessible name at all — a WCAG 4.1.2 failure. Names them by position, which is also what the media lightbox E2E coverage locates them by. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * build: keep Sentry out of the mocked E2E build The E2E suite for the native product pages runs against a production build, which would otherwise report every synthetic failure it provokes to the real Sentry project. More importantly, the SDK's OpenTelemetry module instrumentation is loaded even when reporting is disabled, and it races this app's async server chunks: with Sentry wired in, concurrent requests intermittently returned `ReferenceError: Cannot access 'h' before initialization` from the Payload API — 185 occurrences in one run, and zero once the build stopped carrying it. That instrumentation still ships to production; worth investigating on its own. Also gives the mocked build its own `distDir`, so a build made against the live AFP API can never be served as if it were mocked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: E2E coverage for the native AFP product pages Deterministic, offline end-to-end coverage for the native forecast pages, with MSW intercepting the AFP API at the Node network layer inside Next and Playwright driving the browser. Closes the E2E half of #1208. The seam is a `NODE_OPTIONS --import` preload, not `src/instrumentation.ts`: Next skips the instrumentation hook during a production build, so mocks started there could never shape `generateStaticParams` or any prerendered page — which is exactly the HTML a reader is served. The preload reaches the build workers, so the suite asserts against real prerendered output. An unmocked run is impossible rather than merely detected. The mocked build and server point `NAC_HOST`/`AFP_HOST` at the reserved `.invalid` TLD, which the mock handlers read too, so they agree by construction while a process where interception failed gets a DNS failure instead of real forecast data. The preload proves interception in its own process before Next renders anything; the build lands in its own `distDir`; and any unmapped NAC/AFP request answers 501, is recorded, and fails the run — necessary because these pages degrade to visible copy rather than crashing, so a missing fixture would otherwise read as a passing test of a degraded page. Fixtures are captured, never hand-authored: the AFP products-api golden corpus is vendored with a source commit and a sha256 per file, checked in CI and pre-commit so a "golden" cannot be quietly edited into saying what a test wants. Two responses the corpus does not cover yet are captured into a separate, clearly-marked staging area, each blocking a named test and carrying the upstream Case that retires it. Covers single-zone render, the danger block, expired and archived products, the all-zones grid, the media lightbox, off-season degradation, the native-vs-widget rollout flag, the DVAC→NWAC alias, and phone width. Six further specs are written and skip themselves with a reason until the upstream capture they need lands. Rollout state is fixed per tenant in the seed so no test mutates state another worker can observe. Documented in docs/afp-products/e2e-mocks.md, including the two defects the suite found: the ampersand slug bug fixed separately, and — carried as a `fixme` — that revalidating a forecast page's cache tag turns the page into a 404, so the freshness mechanism meant to deliver a correction removes it instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep a forecast page reachable after a correction is published The revalidate-on-view path calls `revalidateTag` the moment a forecast changes. That is a *hard* cache invalidation — the next read of any page carrying the tag misses outright rather than going stale, which is how it differs from the `revalidate` window beside it. The zone route was `dynamicParams = false`, so Next answered that miss by abandoning the route; `[center]/[...segments]` picked the request up, found no Payload page, and 404'd — and cached it. Measured at ~70 seconds, on the corrected zone and on every zone sharing its weather product. One weather product covers all ten NWAC zones. So the mechanism that exists to deliver a correction removed the forecast instead, during exactly the conditions that draw readers to it. Nothing reached production: the rollout flag is off for every center. Generates the route on demand instead, which is what the dated route beside it already did, and 404s an unknown zone from an explicit check rather than from routing — before the rollout flag is read, so a bad slug behaves the same way on native and on the widget. `freshness.e2e.spec.ts` asserted this and was carried as a `fixme`; it now runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(e2e): scope the Sentry note to the harness, and tighten the mock guards The claim that Sentry's instrumentation destabilises the production server was wrong, and the docs said so in three places. Re-tested as a matrix: a normal production build carries Sentry without the MSW preload and survived three cold starts x 36 concurrent requests with zero errors, where the mocked build with both produced 185 in one run. It is an interaction between two libraries that each hook module loading, and production never loads the preload. The mocked build still skips `withSentryConfig` — that keeps the suite stable and keeps its synthetic failures out of the real project. Also in this commit, from the same working tree: - The mock-role flag becomes `NEXT_PUBLIC_E2E_MOCK_ROLE`. `sentry-base-config.ts` is imported by the browser SDK too, and Next inlines only `NEXT_PUBLIC_` vars there — unprefixed, the client read it as undefined and kept reporting from E2E runs. - `globalSetup` now compares the build id the running server booted from, not just the two on-disk records. A rebuild rewrites both at once, so a server left running from the previous build agreed with them — and `reuseExistingServer` makes that the likely case, not the exotic one. - `--check` runs in CI, not pre-commit; the docblock said otherwise. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(e2e): stub every host a forecast video can reach frames YouTube at `youtube-nocookie.com` rather than `youtube.com`, a video poster now comes from `i.ytimg.com`, and the sanitizer lets a forecaster's own `iframe` through for Vimeo and Facebook as well. `stubExternalAssets` still routed only `www.youtube.com`. Nothing fails when a host is missing from that list — the suite just quietly stops being hermetic and starts loading the real provider, which is the failure the whole harness is built to rule out. The lightbox's YouTube assertion moved for the same reason: it matched `youtube.com/embed/`, which the nocookie host no longer contains. It is skipped pending a fixture, so it would have gone stale unnoticed until the capture landed and then failed for a reason unrelated to the capture. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(e2e): cover the media #1228 preserved in discussions and problems photo is zoomable, an embedded video plays, a pasted provider iframe frames inline, and an embed we will not frame leaves a note naming the provider. None of it is reachable from the corpus — no golden's authored HTML carries a `figure` or an `iframe` at all — so the specs are written and skip themselves with the Case they wait on, as the rest of the blocked suite does. That capture is a different kind of gap from the ones already listed, and the doc now says so: the others are wire-level, so any product of the right shape will do, while this one is about markup a forecaster typed, which no schema requires. It belongs with the shape-variant work in #1209. One test here is not about embedding. `DiscussionBody` delegates clicks from the whole discussion subtree, so a link inside it is the one thing that has to escape the handler rather than open the lightbox. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: say when a native product page change needs a Playwright spec CI already runs this suite on every PR, so a change that breaks an existing spec fails on its own. What CI cannot say is that a surface added yesterday has no spec at all — which is how #1228 shipped four new reader-facing behaviours, each well covered by unit tests, with nothing here. Two failure modes are worth naming because neither shows up red: a third-party host missing from `stubExternalAssets` loads for real and the suite still passes, and an assertion inside a skipped spec is still asserting whatever was true the day it was written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(e2e): name the mocked suite after the products, not the rendering mode `native` described the rendering mode (native pages vs the embedded NAC widget), but the suite covers both halves of that flag — flag-toggle.e2e.spec.ts asserts the widget branch too. It is defined by the upstream it mocks, not by which branch renders, and the surrounding vocabulary already says so: docs/afp-products/, and the afp-golden:sync/check scripts sitting beside it in package.json. pnpm test:e2e:native -> pnpm test:e2e:afp-products __tests__/e2e/frontend-native/ -> __tests__/e2e/afp-products/ playwright.mocked.config.ts -> playwright.afp-products.config.ts projects frontend-native{,-freshness} -> afp-products{,-freshness} CI job e2e-native -> e2e-afp-products `nativeProducts` in the seed keeps its name; that flag really is about the rendering mode. e2e:build/e2e:start also keep theirs — they name the mocked server, not the suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: surface the afp-golden commands where test commands are looked up `afp-golden:sync` and `afp-golden:check` were only documented in docs/afp-products/e2e-mocks.md, which you find only if you already know it exists. docs/testing.md is where someone goes looking for a test command, and `afp-golden:check` runs in CI on every PR — so a red build pointed at a script the testing doc never mentioned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * build(e2e): refuse to vendor goldens from a dirty or stale products-api checkout `sync` recorded whatever commit the local checkout happened to sit at, with no fetch and no dirty check. Two ways that goes wrong, and they differ in kind: - Uncommitted changes under the corpus make PROVENANCE.json's commit a lie about the bytes, which defeats the point of recording it. Always fatal. - A checkout behind its remote is honest but stale — you vendor last month's goldens and `--check` confirms them forever after. Fatal by default, waivable with --allow-stale when pinning deliberately. The fetch is best-effort, since being offline should not block a sync; it says so rather than implying freshness was verified. Both guards run before any file is written, so a refusal leaves the vendored corpus untouched. Also spells out in the doc why `--check` exists (a golden edited by hand to make a red test pass) and that it needs neither the corpus nor the network, which is what lets CI run it without a checkout of a Python repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(e2e): warm the mocked server without depending on *.localhost DNS The warm-up walk exists because a cold production server initialises its chunks lazily, and four Playwright workers arriving at once has been observed racing that into 5xx. It addressed each tenant as `slug.localhost`, which resolves only where /etc/hosts says so — macOS does it for free, the Playwright container CI runs this in does not. There every request failed DNS and was swallowed by the best-effort catch, so the guard was inert in the one environment it was written for. Address loopback directly and put the tenant in a `Host` header, which the middleware already resolves from. `fetch` cannot express that — undici overwrites a caller-supplied `host` with the URL's own authority — so this goes through `node:http.request`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * build: drop an inert clone suppression from the fallow config `dup:88eee158` is a pre-existing clone between the seed's `upsert('media', …)` calls in biographies.ts and index.ts — nothing to do with the E2E harness, and already carried by fallow-baselines/dupes.json. With it removed, `pnpm fallow:check` and `pnpm fallow:audit` report the same findings and still exit 0, so it was suppressing nothing. Unlike a baseline entry, `ignoredClones` is permanent and global: it would also have hidden that clone if it grew. ADR 018's fallow bullet describes exactly three entries this harness adds, and this was not one of them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(e2e): explain the prerender 404 guard by what it catches The guard's rationale was written against `dynamicParams = false`, which this branch inverts. With `true`, a zone missing from `generateStaticParams` is not prerendered at all, so the not-found shell it described can no longer be produced that way. The check still earns its place — the zone route now reaches `notFound()` on its own, for an unresolvable slug or a center that does not publish forecasts — so say that instead of pointing a reader at a mechanism that no longer applies. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Name the native-product-page vocabulary in DOMAIN_CONTEXT The architecture doc and ADR 018 already describe how the data layer works, but the words it uses — Product, View, Data source — were only defined in prose. Product in particular reads three ways depending on who is talking: the page, the feature, or the unit of data. Pin each one to a single meaning, and record the disambiguation that bites most often: "v3" is the AFP's platform generation, not a shared API version segment. Closes #1207 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Account for the map-layer adapter in the architecture doc The danger map's source adapter landed in #1183, after this doc was written, so the doc still described a three-product data layer. Most of the gap is cosmetic, but one part isn't: Control 2 listed the forecast and warning env vars and not NAC_MAP_LAYER_SOURCE, which reads as "the map layer isn't switchable" — the opposite of true, and it's the product closest to being ready to flip. Bind model/mapLayer.ts to the doc so the next map-layer change flags it, rather than going stale the same way twice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rebasing this branch onto main linearizes it, so git drops the merge commits — and with them the changes those merges made that were in neither parent. Three survived only there: - sanitize-html / @types/sanitize-html. src/components/forecast/sanitizeHtml.ts imports sanitize-html (DOMPurify needs jsdom, which does not survive Next's server bundle); the dependency entered in the reconciliation merge, so without this the branch does not build. - nac.ts's forecast/warning fetchers go back through main's normalizeCenterSlug helper instead of re-inlining the dvac -> NWAC mapping. - drift.lock: re-acknowledge the five bindings the linearized history re-flags. Verified the resulting tree is byte-identical (drift.lock aside) to merging origin/main into the pre-rebase branch tip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sanitize-html and htmlparser2's tokenizer were shipping to every reader of a
native forecast page, because MediaLightbox — a 'use client' component — called
sanitizeHtml on a caption at render time.
Deleting that call was not an option. Of the three paths into the lightbox only
the discussion's was covered upstream: the thumbnail grid takes media straight
off the NAC API, and a problem figure passed its raw item through while
sanitizing only its own figcaption. On those two the client-side call was the
one thing standing between forecaster-authored HTML and
dangerouslySetInnerHTML.
So the caption travels with the item instead. A LightboxMedia pairs a MediaItem
with its already-sanitized caption HTML, and the lightbox, the thumbnail grid
and the problem figure all take that rather than a bare MediaItem — a new call
site cannot hand the lightbox HTML nobody cleaned without a type error. The
item keeps its authored caption, which resolveMediaSlide still reads for a
slide's alt and title; those are text attributes React escapes, so a slide's
accessible name is unchanged.
The discussion path stays as it was, and needs nothing: collectEmbeddedMedia
reads a figcaption back out of DOM written from sanitizeHtml(hazard_discussion),
and the allowlist passes nothing — no svg, math, noscript or template — that
re-parses into anything but itself.
The obvious-looking fix, a .transform() on caption in imageMediaSchema, would
have put the library straight back: MediaType is a runtime enum in that same
file and client components import it as a value. sanitizeHtmlIsServerOnly walks
the import graph from every 'use client' module and fails if the sanitizer
becomes reachable again — including through a weaker `import { type X }` that a
bundler is not guaranteed to elide.
Measured against local production builds either side of the change: client JS
across the app 7,791,433 -> 7,549,561 bytes, with the 239,754-byte chunk
carrying sanitize-html and htmlparser2 gone outright and no trace of either
library left anywhere under .next/static.
Refs #1234
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Frame the forecast lightbox in the shared shell The forecast lightbox opened as a boxed `max-w-4xl` card while the Gallery block and the legacy NAC widget it replaces both hand the media the whole screen. #1232 extracted the Gallery block's chrome into `Lightbox`; this wires the forecast side through it, which is the follow-up that PR called for. What the shell now owns: the full-viewport black field, the slide counter, the edge arrow strips, the close button, and arrow-key navigation on a window listener — so the keys work from anywhere on the page rather than only once focus lands inside the carousel. `useCarouselIndex` and `LightboxFooter` go with it, along with the `onKeyDown` handler on `DialogContent`. What stays here is the forecast's own: slides built from remote AFP URLs rather than Payload `Media` documents, and captions the server already sanitized. The caption moves into `LightboxSlide` so it travels with its media instead of swapping under a still image mid-scroll. Two behaviours come along with the shell: - Zoom controls. They drive the active slide's surface through `zoomRef`, so `MediaLightbox` offers them only when that slide is a still photo — a video or an outbound link would otherwise advertise three buttons that do nothing. `ZoomablePhoto` takes the ref, and fills its parent instead of a hard-coded `70vh`, since the chrome decides how much room the media gets and only at layout time. - Off-screen slides no longer mount their video player or zoom surface. The carousel keeps every slide mounted, so a non-active video slide was loading an autoplaying iframe for media nobody was looking at; it shows a still now. The e2e spec moves from the carousel's `Next slide`/`Previous slide` buttons to the shell's `Next`/`Previous` edge arrows. The counter reads the same `2 / 4`, so those assertions are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Drop the now-unused closeClassName from DialogContent The forecast lightbox was its only caller, and it renders its own close button through the shared shell now, so the prop threads a value nothing passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Correct two test assumptions the isActive gating invalidated Only the active slide mounts its player now, which quietly changed what two tests were resting on. The E2E "embeds a YouTube video slide" case opened the first thumbnail and then looked for an iframe anywhere in the dialog. That held while every video slide rendered its own player; it does not anymore, so the assertion silently became a bet on where the capture happens to put the video. It is skipped until product_forecast_SNFAC_problems lands, so nothing is red today — the cost would have been paid by whoever un-skips it. Walk to the video instead, one slide per attempt, reusing the toPass shape clickUntil already uses. The MediaSlide unit test's rationale claimed an off-screen player would autoplay. The forecast embed does not: getVideoEmbedUrl is called without the autoplay argument, as the pinned ?rel=0 URL two tests above shows. That is the Gallery lightbox's behaviour, not this one's. The rest of the reason — a third-party player loading for media nobody is looking at — stands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two problems in the shared freshness component, fixed together because
fixing either alone makes the other worse.
Part A — content-address the check. The client's fingerprint moves from an
`If-None-Match` request header into the URL path, which is what makes the
response cacheable at all. Both routes now answer 200 JSON in every case;
the 304 is gone, because it never meant "not modified" — it meant "don't
call router.refresh()".
Three outcomes, and only one of them is cacheable:
unchanged { changed: false } s-maxage=30
changed { changed: true, etag } no-store
indeterminate { changed: false, reason: … } no-store
Every viewer inside an ISR window sends the same fingerprint, so unchanged
is one cache key per zone and answers nearly all traffic. Not caching
`changed` removes the random-fingerprint pollution vector and makes the
revalidateTag purge an invariant rather than a side effect of a cache miss.
The indeterminate outcome is new and is the safety-critical part: today an
upstream blip collapses into the same 304 as "you're current", which is
only safe because nothing is cached. Cached, it would blind every viewer at
that POP for the full TTL.
Staleness budget is unchanged — the fresh-fetch caches drop 60s → 30s, so
30s upstream + 30s edge is the same 60s, with the 300s ISR backstop behind
it.
Part B — freshness becomes an open-tab guarantee. RevalidateOnView was
mount-only, so a tab open for six hours never asked again. Worse on
warnings than forecasts: a home-page tab wouldn't see a newly issued alert.
It now re-checks on visibilitychange and on a slow interval while visible,
skipping while hidden and replacing rather than stacking its timer.
Expiry gets the same treatment for a different reason — it turns over on
the clock with no upstream event behind it, so a forecast can lapse with no
replacement and the banner is the viewer's only signal. ExpiryNotice
renders the server's answer first (computing Date.now() during the client's
first render would be a hydration mismatch), then re-evaluates on mount and
on a single setTimeout to the instant. Re-evaluating on mount also corrects
ISR HTML rendered before the instant and served after it.
Two things found while implementing:
- The forecast page returned early when nothing was published, so it never
mounted the freshness check at all — scenario 1, a zone's first publish,
could not work. Hashing `null` gives the absent state its own address, so
the page keeps asking, and an upstream-error page now self-heals too.
- The issue's premise that the sibling danger-map route is not
`force-dynamic` is wrong; it is, and its Cache-Control is honored anyway.
Verified against next@15.4.11 that force-dynamic does not suppress a
route handler's own header, and that dropping it would remove the only
structural guard against a future generateStaticParams baking a
"you're current" answer into the Full Route Cache. Kept, with the
reasoning in the file.
Part C — the noon valid-date rule is decided rather than left ambiguous:
expiry stays an absolute-instant comparison. The legacy widget has its noon
helper in scope as a global mixin and deliberately compares expires_time
directly; the avy app independently reached the same split. The noon rule
governs which day a product is for, derived from published_time. Recorded
in the architecture doc so spec and code stop disagreeing.
Verified against a production build and the live NAC API: both routes come
out `ƒ (Dynamic)` and absent from the prerender manifest, and all four
answers leave the server with the Cache-Control they set.
Refs #1226
The `[center]` segment is caller-controlled and gets interpolated straight into an upstream NAC API URL by `resolveZoneFromSlug`, so an arbitrary slug made us fan a request out to the AFP on someone else's say-so — and each distinct slug is its own upstream cache key. Pre-existing: the route has never had this guard, and the query-string version had the same shape. But both siblings that answer for a `[center]` already have it — `warning-freshness` and `danger-map` — and the freshness work is the moment the segment count grows, so it is worth closing here rather than leaving one of the three odd. Unknown centers now get the shared 404 + no-store before any upstream request, matching the siblings exactly. Also folds the route's test setup into one `upstreamAndCacheHold` helper while adding the case for this guard; the repetition it removes was introduced by the commit before this one.
A product can lapse with no replacement published, which produces no freshness change — so this banner is the viewer's only signal that what they are looking at is no longer current. Inserted silently into a page that has already been read, it would be announced to nobody. role="alert" matches the sibling WarningBanner bulletins. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A forecast page shows two safety-critical products and they turn over independently: an alert issued for the zone changes nothing about the forecast, so a forecast-only address answered "you're current" to an open tab that was missing a live banner until the ISR window came round. The address is now forecastPageFingerprint(forecast, warning). Purges stay per-product, so a warning-only change revalidates the warning tag alone rather than costing an upstream forecast re-fetch it has no reason to make. Ports the sibling warning-freshness route's blip rule down to the zone: the source collapses "no alert" and "this zone's request failed" to the same null, so a warning that has vanished while the cache still holds one is not trusted. Nothing is purged, the cached alert is held in the comparison, and an otherwise-current viewer gets indeterminate rather than a cacheable "you're current". A forecast change landing in the same check is still reported, so distrusting the warning half never suppresses a correction. Weather stays out of the address deliberately: it is fetched by the id the forecast points at, so fingerprinting it would mean a third upstream fetch per origin miss. It keeps riding along on the forecast's tag purge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The grid is a wall of live danger ratings and alerts on a 300s ISR window, and it was the last native product surface with no revalidate-on- view at all — its ISR window was the whole guarantee. It asks the same per-zone addresses the individual forecast pages ask, because a card's (forecast, warning) pair is exactly one zone page's. That is what makes the coverage close to free: it shares their edge cache entries rather than opening a new key per center, its origin misses are the same misses theirs already produce, and the per-zone purges invalidate this page's route cache through the same tags. A center-scoped endpoint of its own would instead have fanned two upstream requests per zone out on every miss. RevalidateOnView now takes a list of endpoints rather than one, so a page showing several products keeps a single timer and a single visibility listener — and a daily publish that moves every zone at once collapses into one router.refresh() instead of one per card. forecastFreshnessEndpoint is the single place the URL shape lives, so the route and its callers cannot drift apart. Also stabilizes the useRouter test mock, which returned a fresh object per render; that churned the effect's dependencies and would have hidden whether the component re-arms only when its endpoints actually change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
forecastFreshnessEndpoint is the single place the freshness URL shape lives, and two invariants ride on it with nothing asserting them: the final segment has to be something the route's isFingerprint guard accepts, or every viewer is locked out of the check with no test failing; and the zone slug has to be encoded, or a slug carrying a slash could forge extra path segments. Also pins the claim the all-zones grid's design rests on — that the grid and the zone page produce the same address for the same product pair, so the grid rides the zone page's edge cache entry rather than opening a key of its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two failures, both from content-addressing the check. `freezeFreshness` routed `**/forecast-freshness*`, and Playwright compiles a single `*` to `[^/]*` — it does not cross a path separator. The address is now `…/forecast-freshness/<zone>/<fingerprint>`, so the glob stopped matching, and `page.route` does not report a route that matched nothing: it failed open, silently. Every spec on the shared fixture was letting the real check run, which is the one thing fixture.ts exists to prevent — a page that reaches the endpoint purges the tags of every page sharing them, and the all-zones grid (new here) asks about four zones including the one whose product differs between build and serve. That would have consumed the one-way correction `freshness.e2e.spec.ts` runs last, alone and without retries to observe. The glob now covers both freshness routes, so a future spec that reaches the home page is frozen too. And the spec asserted `304` on an unchanged product. There is no 304 any more — every answer is a 200 and which of the three it is lives in the body, so it asserts the body. That is the stricter assertion anyway: `changed: false` alone distinguishes "you are current" from the indeterminate answer, which would also have left the page alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`isValidTenantSlug` tested `slug in AVALANCHE_CENTERS`, and `in` walks the prototype chain — so `constructor`, `toString`, `valueOf`, `hasOwnProperty`, `isPrototypeOf`, `propertyIsEnumerable`, `toLocaleString`, `__proto__` and the `__defineGetter__` family all passed a guard whose docblock calls itself the single source of truth for valid tenant slugs. Two things ride on it. The `/api/[center]/…` routes use it as the boundary that keeps a caller-controlled path segment out of an upstream NAC URL — the freshness work just added a third caller — and combined with an unguarded zone lookup it turned into a 500 rather than the 404 the guard exists to produce. And it narrows to `ValidTenantSlug`, so a caller indexing `AVALANCHE_CENTERS[slug]` could get `undefined` where TypeScript promised an object; `getProductionCustomDomain` only escapes that because it re-checks against a real array. Pre-existing, and not something the freshness routes introduced — but they are the reason there is now a third route resting on it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
**An uncaught throw is a fourth answer.** `resolveZoneFromSlug` reaches upstream for the center's zone list, and it is the one call in the forecast handler that throws on failure rather than returning null — `getAvalancheCenterMetadata` throws on a parse failure and `nacFetch` throws on any non-2xx. So a NAC outage produced an unhandled 500 on exactly the path the indeterminate answer exists for, with a cache policy nothing in the route decides. It is answered indeterminate now. A slug that simply isn't one of the center's zones is still a 404; those are different answers, which is what the null/undefined split carries. The sibling warning route already wrapped its fetches for this reason. **The path purge is the one purge a caller can reach.** Every other purge on either route is gated on a server-side comparison nobody outside can make true. `revalidatePath` is not: it fires whenever the fingerprint in the URL isn't the current one, and any well-formed 40-hex string qualifies. Content addressing made that much easier to reach than the `If-None-Match` header it replaced — a plain URL can be fired from an `<img src>` on someone else's page or by a crawler, and it is emitted verbatim into the home page's flight payload where a scraper will find it. Ungated, a loop of random fingerprints pins a `force-static` home page to a full re-render per request. Split by who observed the staleness: a change the server sees for itself purges unconditionally, a stale render only the caller can attest to purges at most once per center per 30s. The legitimate case loses nothing — one purge regenerates the page and every other behind-viewer's refresh then works, so a second inside the window was never doing anything. Gating the whole thing on `cacheIsStale` instead looks tidier but is wrong, and there is a test for why: the warning fetch's 300s window can move ahead of the home page's 3600s route cache, so the data really can be current while the render is not. `createCooldown` is deliberately per-instance rather than a real quota; the note in that file says what it is and isn't for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Indeterminate is correct precisely because it is silent: it changes nothing on screen and purges nothing, so a transient upstream blip cannot blank a last-known-good product. That also makes it the one failure in this system nobody would ever notice. No 5xx, no page error, no visible degradation — if the NAC API is down for an hour, every open tab quietly holds what it has and the site looks healthy from the outside. The answers are right; not knowing they are happening is the problem, and this is safety-critical software. Sentry, at warning level, tagged with the cause and the center. Throttled to one event per cause per center per minute, because under a real outage every viewer's check goes indeterminate and `sentry-base-config` sets no sample rate to absorb that — the useful signal is "this is happening, to nwac", not one event per request. One case is deliberately not reported: a fresh fetch that came back empty for a viewer whose page also had nothing. "None published" and "we could not fetch it" are the same null, and the benign form is what an off-season zone asks on every view all season. Reporting it would bury the outage it looks identical to. The caller's own fingerprint is the tell, and the case where it matters — a page that rendered a product and can no longer have it confirmed — still reports. Also extracts `reconcileCaches` from the forecast handler — a pure move of the two purge comparisons and the trusted-warning derivation, forced by the reporting above pushing `GET` past the 60-line unit-size gate. The handler reads better for it: guards, fetch, reconcile, answer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The doc opens by arguing that a five-minute ISR window is five minutes in which a correction is not being shown, and then re-checked every five minutes. A check carries its own 60s detection budget (30s fresh-fetch cache + 30s at the edge), so an untouched tab's worst case was ~360s — longer than the backstop the argument is against. Two minutes puts it at 180s, inside it. The doc now states the page-load number and the open-tab number separately, because they were being conflated: the 60s budget was only ever the first one. And a floor between checks. Returning to visibility is the one trigger a viewer fires at will, with nothing bounding how often — a viewer cycling between windows spent one request per endpoint per flip, a dozen of them on the all-zones grid. 30s, matched to the unchanged answer's edge TTL, so a suppressed check costs no freshness at all: inside that window the edge has nothing newer to say. It cannot starve the check either, being far shorter than the interval — flipping faster than the floor still yields a check every 30s, more often than the interval would have managed. The mount check stays unconditional; that one is a safety invariant, not something the floor gets a say in. Also covers the abort-on-unmount that only the timers were tested for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The expiry work argued that a product can lapse with no replacement published, which produces no freshness change at all — so the notice is the viewer's only signal. That argument is not specific to the zone page. The all-zones grid, which this branch just brought into the freshness system, shows a live danger rating per card and had no expiry signal at all: `ForecastHeader` prints "Expires: <time>", which is a fact to read, not a notice that arrives. `ProductExpiry` is the server half both surfaces now share. It has to be its own component rather than a helper inside `ExpiryNotice`, because that file is `'use client'` and the initial answer is exactly the thing that must be decided on the server. Also drops the notice from `role="alert"` to `role="status"`. Assertive interrupts whatever a screen reader is currently reading, and this can arrive mid-sentence in the hazard discussion. Polite still announces on insertion, and it waits for a pause. Matching WarningBanner was the stated reason for `alert` — but those are avalanche warnings, and this is a product going old. Reasonable to disagree with; it is one attribute either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three comments that claimed more, or less, than the code does. `forecastFreshnessEndpoint` said the path was "tenant-relative, so it is rewritten to the current center the same way every other in-app path is". It is not: `/api` is excluded from the middleware matcher, which is why the center has to be in the path at all. That exclusion is load-bearing for a reason worth writing down — no middleware means no `payload-tenant` `Set-Cookie` on the response, and Vercel's CDN will not cache a response carrying one. The whole of Part A rests on it. `FreshnessAnswer.etag` and `.reason` read as contract; the client acts on neither. They are diagnostic, and `etag` in particular should stay that way: the obvious use for it — remember the last fingerprint refreshed for, skip a repeat — is wrong here, because `router.refresh()` on a stale ISR entry is served the stale render *and* kicks off the regeneration, so the second refresh is the one that lands. Suppressing it would trade a bounded cost for unbounded staleness. Written down so the next reader does not helpfully add it. And two test fixes. "is the same address whichever page asks about a zone" compared f(x) to f(x) with identical arguments — it asserted that a function is a function, while reading like the invariant the grid's design rests on. It now maps the product twice, as the two pages' own fetches would, and the address-must-move half is its own case. The forecast route's `warningCacheTag` mock also skipped the dvac→nwac normalization the real one does, right below a `forecastCacheTag` mock that applies it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both cost an hour to diagnose and neither is discoverable from its symptom. `pnpm seed` cannot be run twice against the same database. It clears every collection in parallel, so `tenants` is not guaranteed to go after the tables pointing at it — and `event_tags`, `event_groups` and `settings` are generated with `tenant_id NOT NULL` together with `ON DELETE set null`, which is self-contradictory. When tenants goes first the cascade tries to write NULL into a NOT NULL column and the whole seed aborts on `delete from "tenants"`. An empty database has nothing to cascade to, which is why only the *first* seed works. Reproduced against a freshly seeded, current-schema DB, so this is not about a stale local file. `pnpm reseed`, or seeding into an empty database, gets you moving; the real fix is `ON DELETE cascade` for a required tenant relationship, and that belongs in the schema rather than here. And the afp-products suite fails as ~20 unrelated-looking locator timeouts when the rollout flags are not seeded, because every native page silently renders the NACWidget branch instead. `globalSetup` checks that the server is the mocked production build and that every upstream call had a golden, but not this — so there is nothing in the output pointing at the cause. The one-line sqlite query that answers it is now in the doc. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The seed turned on `nativeProducts.forecast` alone, so a seeded database never exercised the native warning banner or the native danger map at all — both shipped on this branch behind the same control and neither had a tenant reading them. A native tenant now gets all three. Which tenants are native also changes: snfac and nwac, with dvac and sac on the widget. dvac and nwac are one center upstream, so whichever of the pair is native, the other is what shows that Control 1 is per tenant and not per center. Holding the native side at nwac rather than dvac puts the widget's own dvac→nwac fallback under test, and costs the visible proof that a dvac page renders nwac zones — that page is no longer native. The alias is still proven structurally: no DVAC-keyed handler exists in the mock, the dvac pages fetch upstream in widget mode too (the platform gate, the zone lookup and the metadata description all run before the rollout flag is read), and a leaked dvac request fails the run. So the alias spec turns around. Its first two tests move to nwac, and the third asserts dvac gets the widget — plus one new assertion that the widget component is handed NWAC as its center id, which keeps a visible alias check on the widget branch. The suite's warm-up routes swap the same way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main merged 017-form-embeds-in-page while this branch carried 017-forecast-glossary and 018-native-product-page-architecture. The rebase moved them to 018 and 019; this picks up the DOMAIN_CONTEXT link that a later commit had pointed at 018, and re-acks the drift anchors whose targets merged from both sides. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
busbyk
force-pushed
the
native-product-pages
branch
from
September 11, 2026 17:51
8daca1d to
a856de2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Standing draft PR to keep a Vercel preview of the
native-product-pagesintegration branch live. Not for merge.native-product-pages; in-flight work is previewable on its own branch PR.main.