diff --git a/changelog.d/shell-followups.fixed.md b/changelog.d/shell-followups.fixed.md
new file mode 100644
index 0000000..1886e9c
--- /dev/null
+++ b/changelog.d/shell-followups.fixed.md
@@ -0,0 +1 @@
+Preserve PolicyEngine shell client boundaries and fix default shell navigation edge cases.
diff --git a/src/layout/PolicyEngineHeader.tsx b/src/layout/PolicyEngineHeader.tsx
index 9f593aa..4b20f71 100644
--- a/src/layout/PolicyEngineHeader.tsx
+++ b/src/layout/PolicyEngineHeader.tsx
@@ -3,6 +3,7 @@
import { Header, type CountryConfig, type HeaderProps, type NavItemConfig } from './header';
import {
DEFAULT_POLICYENGINE_BASE_URL,
+ getPolicyEngineCountrySwitchUrl,
getPolicyEngineCountryUrl,
getPolicyEngineNavItems,
policyEngineCountries,
@@ -35,7 +36,10 @@ export function PolicyEngineHeader({
onCountryChange ??
((countryId: string) => {
if (typeof window !== 'undefined') {
- window.location.href = getPolicyEngineCountryUrl(countryId, baseUrl);
+ window.location.href = getPolicyEngineCountrySwitchUrl(countryId, {
+ baseUrl,
+ countries,
+ });
}
});
diff --git a/src/layout/PolicyEngineSiteConfig.ts b/src/layout/PolicyEngineSiteConfig.ts
index 43a827d..17c1ade 100644
--- a/src/layout/PolicyEngineSiteConfig.ts
+++ b/src/layout/PolicyEngineSiteConfig.ts
@@ -21,6 +21,35 @@ export function getPolicyEngineCountryUrl(
return `${normalizeBaseUrl(baseUrl)}/${country}`;
}
+export interface PolicyEngineCountrySwitchUrlOptions {
+ baseUrl?: string;
+ countries?: CountryConfig[];
+ pathname?: string;
+ search?: string;
+ hash?: string;
+}
+
+export function getPolicyEngineCountrySwitchUrl(
+ country: PolicyEngineCountryId,
+ {
+ baseUrl = DEFAULT_POLICYENGINE_BASE_URL,
+ countries = policyEngineCountries,
+ pathname = typeof window !== 'undefined' ? window.location.pathname : '',
+ search = typeof window !== 'undefined' ? window.location.search : '',
+ hash = typeof window !== 'undefined' ? window.location.hash : '',
+ }: PolicyEngineCountrySwitchUrlOptions = {},
+) {
+ const countryIds = new Set(countries.map(({ id }) => id));
+ const segments = pathname.split('/');
+
+ if (countryIds.has(segments[1])) {
+ segments[1] = country;
+ return `${normalizeBaseUrl(baseUrl)}${segments.join('/')}${search}${hash}`;
+ }
+
+ return getPolicyEngineCountryUrl(country, baseUrl);
+}
+
export function getPolicyEngineUrl(
country: PolicyEngineCountryId = 'us',
path = '',
@@ -34,7 +63,7 @@ export function getPolicyEngineNavItems(
country: PolicyEngineCountryId = 'us',
baseUrl = DEFAULT_POLICYENGINE_BASE_URL,
): NavItemConfig[] {
- return [
+ const navItems: NavItemConfig[] = [
{
label: 'Research',
href: getPolicyEngineUrl(country, 'research', baseUrl),
@@ -47,10 +76,6 @@ export function getPolicyEngineNavItems(
label: 'API',
href: getPolicyEngineUrl(country, 'api', baseUrl),
},
- {
- label: 'Python',
- href: getPolicyEngineUrl(country, 'python', baseUrl),
- },
{
label: 'About',
href: getPolicyEngineUrl(country, 'team', baseUrl),
@@ -77,6 +102,15 @@ export function getPolicyEngineNavItems(
href: getPolicyEngineUrl(country, 'donate', baseUrl),
},
];
+
+ if (country === 'us') {
+ navItems.splice(3, 0, {
+ label: 'Python',
+ href: getPolicyEngineUrl(country, 'python', baseUrl),
+ });
+ }
+
+ return navItems;
}
export function getPolicyEngineFooterLinks(
diff --git a/src/layout/header/HeaderNavItem.tsx b/src/layout/header/HeaderNavItem.tsx
index 8792e54..ae112af 100644
--- a/src/layout/header/HeaderNavItem.tsx
+++ b/src/layout/header/HeaderNavItem.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import type { NavItemConfig } from './Header';
@@ -29,6 +29,27 @@ const hoverHandlers = {
},
};
+const dropdownItemStyle: React.CSSProperties = {
+ display: 'flex',
+ alignItems: 'center',
+ width: '100%',
+ textAlign: 'left',
+ padding: '11px 16px',
+ borderRadius: '10px',
+ border: 'none',
+ background: 'transparent',
+ cursor: 'pointer',
+ fontSize: '14px',
+ fontFamily: 'var(--font-sans)',
+ fontWeight: 600,
+ color: 'var(--color-teal-800)',
+ transition:
+ 'background-color 0.12s ease, color 0.12s ease, opacity 0.3s ease',
+ lineHeight: '1.3',
+ letterSpacing: '-0.01em',
+ textDecoration: 'none',
+};
+
/**
* Apple-style dropdown panel with smooth height reveal and content fade.
*/
@@ -60,20 +81,6 @@ function AppleDropdown({
}
}, [open]);
- const handleSelect = useCallback(
- (item: { label: string; href: string }) => {
- onClose();
- if (onNavigate) {
- onNavigate(item.href);
- } else if (linkComponent) {
- // linkComponent handles its own navigation
- } else {
- window.location.href = item.href;
- }
- },
- [onClose, onNavigate, linkComponent],
- );
-
if (!open && contentHeight === 0) {
return null;
}
@@ -118,57 +125,65 @@ function AppleDropdown({
}}
>
- {items.map((item, i) => (
-
- ))}
+ {content}
+
+ );
+ }
+
+ return (
+
+ {content}
+
+ );
+ })}
>
diff --git a/src/layout/index.ts b/src/layout/index.ts
index b845fc8..3cffa2e 100644
--- a/src/layout/index.ts
+++ b/src/layout/index.ts
@@ -24,6 +24,7 @@ export {
} from './PolicyEngineShell';
export {
DEFAULT_POLICYENGINE_BASE_URL,
+ getPolicyEngineCountrySwitchUrl,
getPolicyEngineCountryUrl,
getPolicyEngineFooterLinks,
getPolicyEngineNavItems,
diff --git a/tests/consumer-types/client-directives.test.ts b/tests/consumer-types/client-directives.test.ts
new file mode 100644
index 0000000..d2fef56
--- /dev/null
+++ b/tests/consumer-types/client-directives.test.ts
@@ -0,0 +1,23 @@
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { describe, expect, it } from 'vitest';
+
+const ROOT = path.resolve(__dirname, '..', '..');
+const DIST_INDEX = path.join(ROOT, 'dist', 'index.js');
+const DIST_LAYOUT = path.join(ROOT, 'dist', 'layout.js');
+
+const distAvailable =
+ fs.existsSync(DIST_INDEX) && fs.existsSync(DIST_LAYOUT);
+
+describe('client directives in built artifacts', () => {
+ if (!distAvailable) {
+ it.skip('(skipped — run `bun run build` first to inspect dist/ artifacts)', () => {});
+ return;
+ }
+
+ it('marks shell entrypoints as client modules for Next App Router consumers', () => {
+ for (const file of [DIST_INDEX, DIST_LAYOUT]) {
+ expect(fs.readFileSync(file, 'utf8').startsWith('"use client";')).toBe(true);
+ }
+ });
+});
diff --git a/tests/layout/Layout.test.tsx b/tests/layout/Layout.test.tsx
index d1ba6c5..2333ac7 100644
--- a/tests/layout/Layout.test.tsx
+++ b/tests/layout/Layout.test.tsx
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import { DashboardShell } from '../../src/layout/DashboardShell';
import { SidebarLayout } from '../../src/layout/SidebarLayout';
import { SingleColumnLayout } from '../../src/layout/SingleColumnLayout';
@@ -7,6 +8,7 @@ import { Header } from '../../src/layout/header';
import { InputPanel } from '../../src/layout/InputPanel';
import {
getPolicyEngineNavItems,
+ getPolicyEngineCountrySwitchUrl,
PolicyEngineFooter,
PolicyEngineHeader,
PolicyEngineShell,
@@ -58,6 +60,19 @@ describe('PolicyEngine shell components', () => {
label: 'API',
href: 'https://policyengine.org/uk/api',
});
+ expect(navItems.some((item) => item.label === 'Python')).toBe(false);
+ });
+
+ it('preserves the current route when building country switch URLs', () => {
+ expect(
+ getPolicyEngineCountrySwitchUrl('uk', {
+ pathname: '/us/california-wealth-tax/results',
+ search: '?household=1',
+ hash: '#chart',
+ }),
+ ).toBe(
+ 'https://policyengine.org/uk/california-wealth-tax/results?household=1#chart',
+ );
});
it('renders the canonical PolicyEngine header', () => {
@@ -81,6 +96,26 @@ describe('PolicyEngine shell components', () => {
expect(screen.getByText('Tool content')).toBeInTheDocument();
expect(screen.getAllByText('Donate').length).toBeGreaterThan(0);
});
+
+ it('renders dropdown links with a custom link component', async () => {
+ const Link = ({
+ children,
+ href,
+ ...props
+ }: React.AnchorHTMLAttributes & { href: string }) => (
+
+ {children}
+
+ );
+
+ render();
+
+ await userEvent.click(screen.getByRole('button', { name: /about/i }));
+ expect(screen.getByRole('link', { name: /Team/i })).toHaveAttribute(
+ 'href',
+ 'https://policyengine.org/us/team',
+ );
+ });
});
describe('InputPanel', () => {
diff --git a/vite.config.ts b/vite.config.ts
index c15303f..1f9a665 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -2,9 +2,34 @@ import { resolve } from 'path';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
+import type { Plugin } from 'vite';
+
+function preserveClientDirectives(): Plugin {
+ return {
+ name: 'preserve-client-directives',
+ generateBundle(_options, bundle) {
+ for (const chunk of Object.values(bundle)) {
+ if (chunk.type !== 'chunk') continue;
+
+ const isClientEntry =
+ chunk.facadeModuleId?.endsWith('/src/index.ts') ||
+ chunk.facadeModuleId?.endsWith('/src/layout/index.ts');
+ const hasClientLayoutModule = chunk.moduleIds.some(
+ (id) =>
+ id.includes('/src/layout/PolicyEngine') ||
+ id.includes('/src/layout/header/'),
+ );
+
+ if ((isClientEntry || hasClientLayoutModule) && !chunk.code.startsWith('"use client";')) {
+ chunk.code = `"use client";\n${chunk.code}`;
+ }
+ }
+ },
+ };
+}
export default defineConfig({
- plugins: [tailwindcss(), react()],
+ plugins: [tailwindcss(), react(), preserveClientDirectives()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),