From 460ce9f43b6288e09dc723b380cb68495ca771e9 Mon Sep 17 00:00:00 2001 From: ClarusIubar Date: Sat, 13 Jun 2026 10:24:53 +0900 Subject: [PATCH 1/2] feat: add app shell subnav slot --- ...TSK-012-03-app-shell-subnav-grid-layout.md | 48 ++++++++++++++ src/App.tsx | 6 +- src/components/AppMapStageView.tsx | 19 ++++-- src/components/MapTabStage.tsx | 6 -- src/components/app-shell/AppShell.tsx | 20 +++++- src/components/map-stage/mapTabStageTypes.ts | 6 +- src/index.css | 55 ++++++++++++++++ test/e2e/app-shell.spec.ts | 27 ++++++++ test/unit/app-shell-subnav.test.tsx | 64 +++++++++++++++++++ 9 files changed, 234 insertions(+), 17 deletions(-) create mode 100644 reports/completion/TSK-012-03-app-shell-subnav-grid-layout.md create mode 100644 test/unit/app-shell-subnav.test.tsx diff --git a/reports/completion/TSK-012-03-app-shell-subnav-grid-layout.md b/reports/completion/TSK-012-03-app-shell-subnav-grid-layout.md new file mode 100644 index 00000000..afef4b24 --- /dev/null +++ b/reports/completion/TSK-012-03-app-shell-subnav-grid-layout.md @@ -0,0 +1,48 @@ +# TSK-012-03 App Shell SubNav Grid Layout + +Scope-ID: `TSK-012-03-APP-SHELL-SUBNAV-GRID-LAYOUT` +Issue: https://github.com/STH-1-Class-One-Group/JamIssue/issues/407 +PR: `TBD-TSK-012-03` +Branch: `app-shell-subnav-grid-layout` +Status: `validated-local` +Parent Issue: https://github.com/STH-1-Class-One-Group/JamIssue/issues/404 +Child Issue: https://github.com/STH-1-Class-One-Group/JamIssue/issues/407 + +## 목적 + +2차 UI/UX 구현 명세의 #407 범위를 구현한다. 앱 셸에 optional `subNav` 슬롯을 추가하고, 지도 카테고리 필터를 지도 본문 오버레이가 아니라 헤더 아래 48px flow 레이어로 이동한다. + +## 변경 요약 + +- `AppShell`에 `subNav` prop과 `app-shell--with-subnav` / `app-shell--no-subnav` 변형을 추가했다. +- 지도 탭에서는 `AppMapStageSubNav`가 `MapStageCategoryStrip`을 shell subNav 슬롯에 렌더링한다. +- `MapTabStage`는 지도 표면과 시트만 담당하도록 축소했다. +- `.app-shell__sub-nav-slot`과 subNav 내부 `.map-filter-strip` 오버라이드를 추가했다. +- #410 범위인 광범위한 absolute/fixed CSS debt cleanup은 수행하지 않았다. + +## Architecture Boundary Gate + +- Responsibility map: `AppShell`은 header, optional subNav, content, bottom tab 슬롯을 소유한다. `AppMapStageSubNav`는 map stage의 subNav composition을 소유한다. `MapTabStage`는 map surface와 sheet 렌더링만 소유한다. +- Dependency direction: `App.tsx -> AppShell`이 shell slot을 조립하고, `AppMapStageView.tsx -> MapStageCategoryStrip` 방향으로 map-local UI를 위임한다. stage 내부가 shell layout을 직접 수정하지 않는다. +- Test seam: `test/unit/app-shell-subnav.test.tsx`와 `test/e2e/app-shell.spec.ts`가 AppShell public DOM slot과 active tab behavior를 검증한다. +- Scope map: 변경 파일은 App shell composition, map stage composition, 최소 CSS layout override, unit/e2e tests, completion report로 제한했다. +- Architecture risk: 기존 `.map-filter-strip`와 `.map-surface-frame` absolute offset debt는 남아 있으며 #410에서 정리해야 한다. 이번 PR은 subNav 슬롯 이동과 flow 배치 증명까지만 다룬다. + +## 검증 결과 + +- [x] `npm.cmd run check:numeric-literals` +- [x] `npm.cmd run lint` +- [x] `npm.cmd run typecheck` +- [x] `npm.cmd run test:unit` +- [x] `npm.cmd run test:integration` +- [x] `npm.cmd run test:regression` +- [x] `npm.cmd run test:e2e` +- [x] `npm.cmd run build` +- [x] `git diff --check` +- [x] UTF-8 integrity check + +## 남은 후속 작업 + +- #408: Event tab festival-only 정리. +- #409: KTO tourism map layer 구현. +- #410: app shell CSS offset cleanup과 남은 absolute/fixed debt 제거. diff --git a/src/App.tsx b/src/App.tsx index 9203daba..24fb731e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { AppMapStageView } from './components/AppMapStageView'; +import { AppMapStageSubNav, AppMapStageView } from './components/AppMapStageView'; import { AppPageStage } from './components/AppPageStage'; import { AppShell } from './components/app-shell/AppShell'; import { @@ -55,6 +55,9 @@ export default function App() { (Boolean(mapStageProps.mapData.selectedPlace) && mapStageProps.mapData.drawerState === 'full') || (Boolean(mapStageProps.mapData.selectedFestival) && mapStageProps.mapData.drawerState === 'full') ); + const subNav = activeTab === 'map' ? ( + + ) : null; return ( {activeTab === 'map' ? ( diff --git a/src/components/AppMapStageView.tsx b/src/components/AppMapStageView.tsx index 9b787ab3..f7dbb1e0 100644 --- a/src/components/AppMapStageView.tsx +++ b/src/components/AppMapStageView.tsx @@ -1,5 +1,6 @@ import { memo } from 'react'; import { MapTabStage } from './MapTabStage'; +import { MapStageCategoryStrip } from './map-stage/MapStageCategoryStrip'; import type { ApiStatus, Category, DrawerState, FestivalItem, Place, ReviewMood, RoutePreview } from '../types/core'; import type { SessionUser } from '../types/auth'; import type { BootstrapResponse } from '../types/review'; @@ -51,6 +52,20 @@ interface AppMapStageViewProps { }; } +type AppMapStageSubNavProps = Pick; + +export function AppMapStageSubNav({ + mapData, + mapActions, +}: AppMapStageSubNavProps) { + return ( + + ); +} + export const AppMapStageView = memo(function AppMapStageView({ mapData, mapActions, @@ -58,7 +73,6 @@ export const AppMapStageView = memo(function AppMapStageView({ return ( ); }); diff --git a/src/components/MapTabStage.tsx b/src/components/MapTabStage.tsx index fb082e90..aec9bb03 100644 --- a/src/components/MapTabStage.tsx +++ b/src/components/MapTabStage.tsx @@ -1,4 +1,3 @@ -import { MapStageCategoryStrip } from './map-stage/MapStageCategoryStrip'; import { MapStageMapSurface } from './map-stage/MapStageMapSurface'; import { MapStageSheets } from './map-stage/MapStageSheets'; import type { MapTabStageProps } from './map-stage/mapTabStageTypes'; @@ -9,14 +8,9 @@ export function MapTabStage({ viewportData, placeSheet, festivalSheet, - mapActions, }: MapTabStageProps) { return (
- ; onBottomTabChange: (nextTab: Tab) => void; onNavigateBack: () => void; + subNav?: ReactNode; } export function AppShell({ @@ -25,8 +26,10 @@ export function AppShell({ globalUtility, onBottomTabChange, onNavigateBack, + subNav, }: AppShellProps) { const isMapStage = activeTab === 'map'; + const hasSubNav = Boolean(subNav); return (
@@ -34,8 +37,10 @@ export function AppShell({ className={[ 'phone-shell', isMapStage ? 'phone-shell--map' : '', + hasSubNav ? 'app-shell--with-subnav' : 'app-shell--no-subnav', ].filter(Boolean).join(' ')} data-app-shell="phone" + data-testid="app-shell-phone" > {globalStatus && (
@@ -51,8 +56,21 @@ export function AppShell({ globalUtility={globalUtility} onNavigateBack={onNavigateBack} /> + {hasSubNav && ( +
+ {subNav} +
+ )}
-
+
{children}
void; onCollapseFestivalDrawer: () => void; }; - mapActions: { - setActiveCategory: (category: Category) => void; - }; } diff --git a/src/index.css b/src/index.css index ba2e1623..f6d05648 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 df9b55fd..b1008a9c 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 00000000..ac3abe3c --- /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')); + }); +}); From ffd1c04684349406f31c374f42f741c4e78c097a Mon Sep 17 00:00:00 2001 From: ClarusIubar Date: Sat, 13 Jun 2026 10:25:59 +0900 Subject: [PATCH 2/2] docs: record subnav PR evidence --- reports/completion/TSK-012-03-app-shell-subnav-grid-layout.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reports/completion/TSK-012-03-app-shell-subnav-grid-layout.md b/reports/completion/TSK-012-03-app-shell-subnav-grid-layout.md index afef4b24..a0359f12 100644 --- a/reports/completion/TSK-012-03-app-shell-subnav-grid-layout.md +++ b/reports/completion/TSK-012-03-app-shell-subnav-grid-layout.md @@ -2,7 +2,7 @@ Scope-ID: `TSK-012-03-APP-SHELL-SUBNAV-GRID-LAYOUT` Issue: https://github.com/STH-1-Class-One-Group/JamIssue/issues/407 -PR: `TBD-TSK-012-03` +PR: https://github.com/STH-1-Class-One-Group/JamIssue/pull/414 Branch: `app-shell-subnav-grid-layout` Status: `validated-local` Parent Issue: https://github.com/STH-1-Class-One-Group/JamIssue/issues/404