void;
onCollapseFestivalDrawer: () => void;
};
- mapActions: {
- setActiveCategory: (category: Category) => void;
- };
}
diff --git a/src/index.css b/src/index.css
index ba2e162..f6d0564 100644
--- a/src/index.css
+++ b/src/index.css
@@ -32,6 +32,7 @@
--app-shell-safe-y: 16px;
--app-shell-safe-x: 14px;
--app-header-height: 56px;
+ --app-sub-nav-height: 48px;
--app-header-gap: 10px;
--app-header-action-size: 44px;
--phone-shell-max-width: 430px;
@@ -176,6 +177,14 @@ textarea {
inset: var(--app-header-height) 0 0 0;
}
+.app-shell--with-subnav .phone-shell__body {
+ inset: calc(var(--app-header-height) + var(--app-sub-nav-height)) 0 0 0;
+}
+
+.app-shell--no-subnav .phone-shell__body {
+ inset: var(--app-header-height) 0 0 0;
+}
+
.app-header {
position: absolute;
top: 0;
@@ -276,6 +285,52 @@ textarea {
line-height: 1;
}
+.app-shell__sub-nav-slot {
+ position: absolute;
+ top: var(--app-header-height);
+ left: 0;
+ right: 0;
+ z-index: calc(var(--layer-app-header) - 1);
+ height: var(--app-sub-nav-height);
+ display: flex;
+ align-items: center;
+ padding: 0 var(--app-shell-safe-x);
+ background: rgba(255, 252, 249, 0.96);
+ border-bottom: 1px solid rgba(255, 197, 217, 0.22);
+ backdrop-filter: blur(14px);
+}
+
+.app-shell__sub-nav-slot .map-filter-strip {
+ position: relative;
+ top: auto;
+ left: auto;
+ right: auto;
+ z-index: auto;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ gap: 0;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ box-shadow: none;
+}
+
+.app-shell__sub-nav-slot .map-filter-strip .chip-row {
+ width: 100%;
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ overflow-y: hidden;
+ padding: 2px 0;
+ scrollbar-width: none;
+}
+
+.app-shell__sub-nav-slot .map-filter-strip .chip-row::-webkit-scrollbar {
+ display: none;
+}
+
.app-shell__content-slot {
position: absolute;
inset: 0;
diff --git a/test/e2e/app-shell.spec.ts b/test/e2e/app-shell.spec.ts
index df9b55f..b1008a9 100644
--- a/test/e2e/app-shell.spec.ts
+++ b/test/e2e/app-shell.spec.ts
@@ -95,3 +95,30 @@ test('UIUX-014 keeps tab content surfaces accessible inside the app shell', asyn
expect(surfaceBox.x + surfaceBox.width).toBeLessThanOrEqual(contentSlotBox.x + contentSlotBox.width + 1);
}
});
+
+test('UIUX-015 moves map filters into the app shell sub navigation flow', async ({ page }) => {
+ await installApiFixtures(page, createE2EAppState({ authenticated: false }));
+
+ await page.goto('/');
+
+ const phoneShell = page.locator('[data-app-shell="phone"]');
+ const subNavSlot = page.locator('[data-app-shell-slot="sub-nav"]');
+ const filterStrip = subNavSlot.locator('.map-filter-strip');
+
+ await expect(phoneShell).toHaveClass(/app-shell--with-subnav/);
+ await expect(subNavSlot).toBeVisible();
+ await expect(filterStrip).toBeVisible();
+ await expect(page.locator('.map-stage > .map-filter-strip')).toHaveCount(0);
+
+ const headerBox = await requireBoundingBox(page.locator('[data-app-shell-slot="header"]'));
+ const subNavBox = await requireBoundingBox(subNavSlot);
+ const contentBox = await requireBoundingBox(page.locator('[data-app-shell-slot="content"]'));
+
+ expect(subNavBox.y).toBeGreaterThanOrEqual(headerBox.y + headerBox.height - 1);
+ expect(contentBox.y).toBeGreaterThanOrEqual(subNavBox.y + subNavBox.height - 1);
+
+ await page.locator('[data-tab-key="my"]').click();
+
+ await expect(phoneShell).toHaveClass(/app-shell--no-subnav/);
+ await expect(page.locator('[data-app-shell-slot="sub-nav"]')).toHaveCount(0);
+});
diff --git a/test/unit/app-shell-subnav.test.tsx b/test/unit/app-shell-subnav.test.tsx
new file mode 100644
index 0000000..ac3abe3
--- /dev/null
+++ b/test/unit/app-shell-subnav.test.tsx
@@ -0,0 +1,64 @@
+/*
+ * File: app-shell-subnav.test.tsx
+ * Purpose: Verify the TSK-012 app shell sub-navigation slot contract.
+ * Primary Responsibility: Prove that optional sub navigation is owned by AppShell rather than map-stage overlays.
+ * Design Intent: Exercise the AppShell public props so layout internals can change without rewriting tests.
+ * Non-Goals: This test does not validate KTO map markers or broad CSS cleanup covered by later child issues.
+ * Dependencies: React Testing Library, Vitest, and the AppShell component.
+ */
+import { render, screen } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { AppShell } from '../../src/components/app-shell/AppShell';
+
+const globalUtility = {
+ sessionUserName: 'tester',
+ notifications: [],
+ unreadCount: 0,
+ onOpenNotification: vi.fn(),
+ onMarkAllNotificationsRead: vi.fn(),
+ onDeleteNotification: vi.fn(),
+};
+
+function renderShell(subNav?: ReactNode) {
+ render(
+
+ content
+ ,
+ );
+}
+
+describe('AppShell sub navigation contract', () => {
+ it('renders sub navigation as a shell slot between header and content', () => {
+ renderShell(
);
+
+ const phoneShell = screen.getByTestId('app-shell-phone');
+ const subNavSlot = screen.getByTestId('app-shell-sub-nav');
+ const contentSlot = screen.getByTestId('app-shell-content');
+
+ expect(phoneShell).toHaveClass('app-shell--with-subnav');
+ expect(phoneShell).not.toHaveClass('app-shell--no-subnav');
+ expect(subNavSlot).toHaveAttribute('data-app-shell-slot', 'sub-nav');
+ expect(subNavSlot).toHaveTextContent('filters');
+ expect(contentSlot).toContainElement(screen.getByTestId('content'));
+ });
+
+ it('uses the no-subnav variant when a screen has no stage-level navigation', () => {
+ renderShell();
+
+ const phoneShell = screen.getByTestId('app-shell-phone');
+
+ expect(phoneShell).toHaveClass('app-shell--no-subnav');
+ expect(phoneShell).not.toHaveClass('app-shell--with-subnav');
+ expect(screen.queryByTestId('app-shell-sub-nav')).not.toBeInTheDocument();
+ expect(screen.getByTestId('app-shell-content')).toContainElement(screen.getByTestId('content'));
+ });
+});