diff --git a/.github/scripts/audit-app-zone-shell.mjs b/.github/scripts/audit-app-zone-shell.mjs index 1964e806a..3e8f355e5 100644 --- a/.github/scripts/audit-app-zone-shell.mjs +++ b/.github/scripts/audit-app-zone-shell.mjs @@ -36,6 +36,29 @@ export const REQUIRED_NAV_LABELS = ["Research", "Model", "API", "Donate"]; const TOP_SHELL_SELECTOR = 'header, nav, [data-testid*="header" i], [data-testid*="site-header" i], a, button, img, [aria-label]'; +// Routes intentionally served WITHOUT a child-rendered PolicyEngine header. +// These tools are embedded under policyengine.org, which already provides the +// site header/nav, so the maintainers chose not to duplicate the PolicyEngine +// shell inside the child app. These routes are still audited for liveness +// (HTTP status, runtime errors, blank pages) — only the top-shell brand/nav +// assertion is skipped. Remove an entry to re-enforce the full shell on it. +// /uk/scotland-income-tax-reform — PolicyEngine/scotland-income-tax-reform#8 +// /uk/student-loan-visualisation — PolicyEngine/student-loan-visualisation#3 +// /us/obbba-household-explorer — PolicyEngine/obbba-household-by-household#240 +// /uk/uc-rebalancing — PolicyEngine/uc-rebalancing +export const SHELL_BRAND_EXEMPT_SOURCES = [ + "/uk/scotland-income-tax-reform", + "/uk/student-loan-visualisation", + "/us/obbba-household-explorer", + "/uk/uc-rebalancing", +]; + +export function isShellBrandExempt(source) { + return SHELL_BRAND_EXEMPT_SOURCES.some( + (base) => source === base || source.startsWith(`${base}/`), + ); +} + export function parseArgs(argv) { const options = {}; for (let i = 0; i < argv.length; i += 1) { @@ -411,7 +434,7 @@ async function inspectTopShell(page) { return inspectTopShellData(elements); } -async function inspectShell(page, url, timeout) { +async function inspectShell(page, url, timeout, enforceShell = true) { let response; try { @@ -446,6 +469,15 @@ async function inspectShell(page, url, timeout) { }; } + if (!enforceShell) { + return { + ok: true, + status, + reason: "loaded — exempt from PolicyEngine shell brand/nav check", + exempt: true, + }; + } + const { hasBrand, navHits } = await inspectTopShell(page); if (!hasBrand) { @@ -474,14 +506,20 @@ async function auditRoute(browser, route, baseUrl, timeout, allowDestinationFall viewport: { width: 1440, height: 1000 }, userAgent: "policyengine-app-zone-shell-audit/1.0", }); + const enforceShell = !isShellBrandExempt(route.source); const sourceUrl = resolveUrl(baseUrl, route.source); - let result = await inspectShell(page, sourceUrl, timeout); + let result = await inspectShell(page, sourceUrl, timeout, enforceShell); let testedUrl = sourceUrl; let usedFallback = false; if (!result.ok && result.status === 404 && allowDestinationFallback) { const destinationUrl = resolveUrl(baseUrl, route.destination); - const fallbackResult = await inspectShell(page, destinationUrl, timeout); + const fallbackResult = await inspectShell( + page, + destinationUrl, + timeout, + enforceShell, + ); if (fallbackResult.ok || fallbackResult.status !== 404) { result = fallbackResult; testedUrl = destinationUrl; @@ -569,7 +607,7 @@ export async function main(argv = process.argv.slice(2), env = process.env) { timeout, allowDestinationFallback, ); - const mark = result.ok ? "OK" : "FAIL"; + const mark = result.ok ? (result.exempt ? "SKIP" : "OK") : "FAIL"; console.log(`${mark} ${result.source}`); console.log(` ${result.reason}`); if (result.usedFallback) { @@ -586,7 +624,16 @@ export async function main(argv = process.argv.slice(2), env = process.env) { await browser.close(); const failures = results.filter((result) => !result.ok); - console.log(`\n${results.length - failures.length}/${results.length} app-zone routes have the PolicyEngine shell.`); + const exempt = results.filter((result) => result.exempt); + const enforced = results.length - exempt.length; + console.log(`\n${enforced - failures.length}/${enforced} enforced app-zone routes have the PolicyEngine shell.`); + + if (exempt.length > 0) { + console.log(`\n${exempt.length} route(s) skipped the PolicyEngine shell brand/nav check (loaded OK, header intentionally omitted):`); + for (const route of exempt) { + console.log(` - ${route.source}`); + } + } if (failures.length > 0) { console.error("\nRoutes missing the PolicyEngine shell:"); diff --git a/.github/scripts/audit-app-zone-shell.test.mjs b/.github/scripts/audit-app-zone-shell.test.mjs index 5ef4870eb..3208467de 100644 --- a/.github/scripts/audit-app-zone-shell.test.mjs +++ b/.github/scripts/audit-app-zone-shell.test.mjs @@ -5,6 +5,7 @@ import { extractRoutes, extractSitemapLocs, inspectTopShellData, + isShellBrandExempt, resolveDestinationForSource, shouldAllowDestinationFallback, sourcePathFromSitemapLoc, @@ -237,3 +238,20 @@ describe("shouldAllowDestinationFallback", () => { ); }); }); + +describe("isShellBrandExempt", () => { + test("exempts configured routes and their subpaths", () => { + assert.equal(isShellBrandExempt("/uk/scotland-income-tax-reform"), true); + assert.equal( + isShellBrandExempt("/uk/student-loan-visualisation/budget-impact"), + true, + ); + assert.equal(isShellBrandExempt("/uk/uc-rebalancing"), true); + assert.equal(isShellBrandExempt("/us/obbba-household-explorer"), true); + }); + + test("does not exempt other routes or partial-name collisions", () => { + assert.equal(isShellBrandExempt("/uk/marriage"), false); + assert.equal(isShellBrandExempt("/uk/uc-rebalancing-extended"), false); + }); +}); diff --git a/app/public/assets/posts/uc-rebalancing.avif b/app/public/assets/posts/uc-rebalancing.avif new file mode 100644 index 000000000..7cacee483 Binary files /dev/null and b/app/public/assets/posts/uc-rebalancing.avif differ diff --git a/app/src/data/apps/apps.json b/app/src/data/apps/apps.json index 306083c25..4f820ffb6 100644 --- a/app/src/data/apps/apps.json +++ b/app/src/data/apps/apps.json @@ -162,6 +162,19 @@ "date": "2026-05-19 12:00:00", "authors": ["david-trimmer"] }, + { + "type": "iframe", + "slug": "uc-rebalancing", + "title": "UK Universal Credit rebalancing analysis dashboard", + "description": "PolicyEngine analysed the household and fiscal impact of the Universal Credit Act 2025 rebalancing package: an above-inflation uplift to the standard allowance and a fixed monthly health element", + "source": "https://uc-rebalancing.vercel.app/uk/uc-rebalancing", + "tags": ["uk", "featured", "policy", "interactives"], + "countryId": "uk", + "displayWithResearch": true, + "image": "uc-rebalancing.avif", + "date": "2026-06-01 12:00:00", + "authors": ["vahid-ahmadi"] + }, { "type": "iframe", "slug": "wv-sb392-tax-cut", diff --git a/changelog_entry.yaml b/changelog_entry.yaml index 05f7b91ed..493eca708 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -1,4 +1,4 @@ - bump: patch changes: added: - - Add QBI deduction calculator multizone at /us/qbi-calculator + - Add UK Universal Credit rebalancing analysis dashboard at /uk/uc-rebalancing diff --git a/website/src/data/appZoneRoutes.ts b/website/src/data/appZoneRoutes.ts index d22bf2273..ec719a304 100644 --- a/website/src/data/appZoneRoutes.ts +++ b/website/src/data/appZoneRoutes.ts @@ -60,8 +60,7 @@ export const appZoneRoutes: AppZoneRoute[] = [ }, { source: "/us/working-parents-tax-relief-act", - destination: - "https://wptra.vercel.app/us/working-parents-tax-relief-act", + destination: "https://wptra.vercel.app/us/working-parents-tax-relief-act", }, { source: "/us/utah-2026-tax-changes", @@ -84,12 +83,12 @@ export const appZoneRoutes: AppZoneRoute[] = [ }, { source: "/us/qbi-calculator", - destination: - "https://qbi-visualizer.vercel.app/us/qbi-calculator", + destination: "https://qbi-visualizer.vercel.app/us/qbi-calculator", }, { source: "/us/watca", - destination: "https://working-americans-tax-cut-act-one.vercel.app/us/watca", + destination: + "https://working-americans-tax-cut-act-one.vercel.app/us/watca", }, { source: "/us/california-wealth-tax", @@ -130,7 +129,8 @@ export const appZoneRoutes: AppZoneRoute[] = [ }, { source: "/uk/marriage", - destination: "https://marriage-zeta-beryl.vercel.app/us/marriage?country=uk", + destination: + "https://marriage-zeta-beryl.vercel.app/us/marriage?country=uk", deepDestination: "https://marriage-zeta-beryl.vercel.app/us/marriage/:path*?country=uk", }, @@ -175,6 +175,10 @@ export const appZoneRoutes: AppZoneRoute[] = [ destination: "https://uk-public-services-imputation.vercel.app/uk/public-services-spending", }, + { + source: "/uk/uc-rebalancing", + destination: "https://uc-rebalancing.vercel.app/uk/uc-rebalancing", + }, { source: "/us/aca-calc", destination: "https://aca-calc.vercel.app/us/aca-calc", @@ -200,8 +204,7 @@ export const appZoneRoutes: AppZoneRoute[] = [ }, { source: "/us/state-eitcs-ctcs", - destination: - "https://us-state-eitcs-ctcs.vercel.app/us/state-eitcs-ctcs", + destination: "https://us-state-eitcs-ctcs.vercel.app/us/state-eitcs-ctcs", }, { source: "/us/2024-election-calculator", @@ -233,7 +236,8 @@ export const appZoneRoutes: AppZoneRoute[] = [ }, { source: "/us/ads-dashboard", - destination: "https://policyengine-ads-dashboard.vercel.app/us/ads-dashboard", + destination: + "https://policyengine-ads-dashboard.vercel.app/us/ads-dashboard", }, { source: "/us/ai-inequality",