From bbd247267fcd4ae1a660ba737eea75cf814b8ffa Mon Sep 17 00:00:00 2001 From: mokjakA Date: Wed, 12 Aug 2026 11:09:52 +0900 Subject: [PATCH] =?UTF-8?q?fix(S15P11A705-438):=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EA=B0=A4=EB=9F=AC=EB=A6=AC=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=ED=9A=8C=EA=B7=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + .../components/SearchResultGallery.test.tsx | 56 +++- .../home/components/SearchResultGallery.tsx | 212 +++++++++++---- src/features/home/paperAperture.css | 242 ++++++++++++++++-- src/pages/HomePage.tsx | 1 + .../test_frontend_ci_contract.cpython-312.pyc | Bin 23926 -> 0 bytes 6 files changed, 439 insertions(+), 76 deletions(-) delete mode 100644 tests/__pycache__/test_frontend_ci_contract.cpython-312.pyc diff --git a/.gitignore b/.gitignore index bc1e64b..219d983 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ dist dist-ssr *.local +# Python test cache / bytecode +__pycache__/ +*.py[cod] + # Env .env .env.*.local diff --git a/src/features/home/components/SearchResultGallery.test.tsx b/src/features/home/components/SearchResultGallery.test.tsx index 5d01421..c17d557 100644 --- a/src/features/home/components/SearchResultGallery.test.tsx +++ b/src/features/home/components/SearchResultGallery.test.tsx @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { SearchResultItem } from '@/features/search/api/searchRecords'; import { SearchResultGallery } from './SearchResultGallery'; -const ITEMS: SearchResultItem[] = Array.from({ length: 4 }, (_, index) => ({ +const ITEMS: SearchResultItem[] = Array.from({ length: 7 }, (_, index) => ({ recordId: index + 1, similarity: 1 - index * 0.1, place: { @@ -40,6 +40,18 @@ afterEach(() => { }); describe('SearchResultGallery', () => { + it('검색 결과 6개를 한 페이지의 2행 3열용 그룹에 담는다', () => { + act(() => { + root.render(); + }); + + const pages = container.querySelectorAll('.pl-results__page'); + + expect(pages).toHaveLength(1); + expect(pages[0]?.querySelectorAll('.pl-results__card')).toHaveLength(6); + expect(container.querySelector('.pl-results__controls')).toBeNull(); + }); + it('가로 탐색 가능한 결과 영역과 응답 순서의 카드를 렌더한다', () => { const onSelectRecord = vi.fn(); @@ -51,16 +63,44 @@ describe('SearchResultGallery', () => { const cards = Array.from(container.querySelectorAll('.pl-results__card')); expect(gallery?.getAttribute('role')).toBe('region'); - expect(gallery?.getAttribute('aria-label')).toBe('검색 결과 4곳'); + expect(gallery?.getAttribute('aria-label')).toBe('검색 결과 7곳'); expect(gallery?.tabIndex).toBe(0); - expect(cards.map((card) => card.textContent)).toEqual([ - expect.stringContaining('장소 1/4'), - expect.stringContaining('장소 2/4'), - expect.stringContaining('장소 3/4'), - expect.stringContaining('장소 4/4'), - ]); + expect(container.querySelectorAll('.pl-results__page')).toHaveLength(2); + expect(cards.map((card) => card.textContent)).toEqual( + Array.from({ length: 7 }, (_, index) => expect.stringContaining(`장소 ${index + 1}/7`)), + ); act(() => cards[2].click()); expect(onSelectRecord).toHaveBeenCalledWith(3); }); + + it('운영체제 스크롤바 설정과 무관한 6개 단위 탐색 컨트롤을 제공한다', () => { + act(() => { + root.render(); + }); + + const gallery = container.querySelector('.pl-results__gallery'); + const previousButton = container.querySelector( + 'button[aria-label="이전 검색 결과 6개"]', + ); + const nextButton = container.querySelector( + 'button[aria-label="다음 검색 결과 6개"]', + ); + const pages = Array.from(container.querySelectorAll('.pl-results__page')); + const scrollTo = vi.fn(); + + Object.defineProperty(gallery, 'scrollTo', { configurable: true, value: scrollTo }); + Object.defineProperty(pages[1], 'offsetLeft', { configurable: true, value: 960 }); + + expect(previousButton?.disabled).toBe(true); + expect(nextButton?.disabled).toBe(false); + expect(container.querySelector('.pl-results__page-status')?.textContent).toBe('1/2'); + + act(() => nextButton?.click()); + + expect(scrollTo).toHaveBeenCalledWith({ left: 960, behavior: 'smooth' }); + expect(previousButton?.disabled).toBe(false); + expect(nextButton?.disabled).toBe(true); + expect(container.querySelector('.pl-results__page-status')?.textContent).toBe('2/2'); + }); }); diff --git a/src/features/home/components/SearchResultGallery.tsx b/src/features/home/components/SearchResultGallery.tsx index 86fa9e1..6a07549 100644 --- a/src/features/home/components/SearchResultGallery.tsx +++ b/src/features/home/components/SearchResultGallery.tsx @@ -1,9 +1,9 @@ -import { useEffect, useId, useRef } from 'react'; +import { useEffect, useId, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import type { SearchResultItem } from '@/features/search/api/searchRecords'; import { ContextStickyNote } from '@/shared/ui/ContextStickyNote'; import { PaperNoteParts } from '@/shared/ui/PaperNoteParts'; -import { PinStanding } from '@/shared/ui/PinSymbols'; +import { PinPushMount, PinStanding } from '@/shared/ui/PinSymbols'; import { FOCUSABLE_SELECTOR } from '@/shared/lib/focusableSelector'; interface SearchResultGalleryProps { @@ -11,8 +11,19 @@ interface SearchResultGalleryProps { onSelectRecord: (recordId: number) => void; } +const SEARCH_RESULT_PAGE_SIZE = 6; +const SEARCH_NOTE_METRICS = { + bodyFontPx: 15, + bodyLineHeightPx: 18, + metaFontPx: 9, + padXPx: 14, + padTopPx: 18, + padBottomPx: 12, + dividerGapPx: 6, +} as const; + /** - * 스마트 검색 결과 갤러리: 가로 스크롤 카드 목록. + * 스마트 검색 결과 갤러리: PC 한 쪽에 2행 × 3열로 놓이는 종이 메모 목록. * 근거: Jira S15P11A705-165, mockup(PinLog.responsive.dc.html) home-coverflow(1063~1104행). * 목업은 3D coverflow(카드 겹침) 효과를 쓰지만, 이번 범위는 가로 스크롤 + 카드 형태로 충분하다고 * 명시돼 있어 완전한 3D 구현은 하지 않았다. @@ -27,63 +38,162 @@ interface SearchResultGalleryProps { * 표) matchedContext.body는 항상 본인 Context 원문이라 공개 범위 규칙(타인 Context 원문 비공개) * 위반이 아니다. attachment='flat'을 쓴 이유는 lifted보다 회전·그림자가 절제돼 있어 좁은 * 카드 안에서 다른 항목(이름·위치·키워드)과 부딪히지 않기 때문 — 디자인 시안이 도착하면 - * 이 선택은 다시 확인한다. S15P11A705-437부터 카드 폭은 고정값이 아니라 갤러리 가용 폭의 - * 3등분이다. 바깥 결과 영역이 좁아져도 세 번째 카드만 잘리는 대신 세 카드가 함께 줄어든다. + * 이 선택은 다시 확인한다. S15P11A705-438부터 한 페이지는 6장(2행 × 3열)이다. 일곱 번째부터 + * 다음 종이 쪽에 놓고 페이지 단위로 넘긴다. 카드 외곽은 반듯한 노트 한 장과 뒤에 겹친 종이만 + * 남기고, 바깥 노트의 부착 장식은 앱 공용 `PinPushMount`로 통일한다. 안쪽 Context는 다른 + * 화면과 같은 공용 컴포넌트 판형(파스텔 종이·테이프)을 그대로 유지한다. * editable을 넘기지 않는다(기본 false) — 검색 결과 카드는 Context를 고치는 자리가 아니다. */ export function SearchResultGallery({ items, onSelectRecord }: SearchResultGalleryProps) { const total = items.length; + const pageCount = Math.ceil(total / SEARCH_RESULT_PAGE_SIZE); + const pages = Array.from({ length: pageCount }, (_, pageIndex) => + items.slice(pageIndex * SEARCH_RESULT_PAGE_SIZE, (pageIndex + 1) * SEARCH_RESULT_PAGE_SIZE), + ); + const galleryId = useId(); + const galleryRef = useRef(null); + const [pageIndex, setPageIndex] = useState(0); + + const scrollToPage = (nextPageIndex: number) => { + const gallery = galleryRef.current; + if (gallery === null) { + return; + } + + const boundedPageIndex = Math.max(0, Math.min(nextPageIndex, pageCount - 1)); + const targetPage = gallery.querySelectorAll('.pl-results__page')[boundedPageIndex]; + if (targetPage === undefined) { + return; + } + + setPageIndex(boundedPageIndex); + gallery.scrollTo({ left: targetPage.offsetLeft, behavior: 'smooth' }); + }; + + const handleGalleryScroll = () => { + const gallery = galleryRef.current; + if (gallery === null) { + return; + } + + const pageStarts = Array.from(gallery.querySelectorAll('.pl-results__page')).map( + (page) => page.offsetLeft, + ); + const nearestPageIndex = pageStarts.reduce( + (nearest, start, index) => + Math.abs(gallery.scrollLeft - start) < Math.abs(gallery.scrollLeft - pageStarts[nearest]) + ? index + : nearest, + 0, + ); + setPageIndex(nearestPageIndex); + }; return ( -
- {items.map((item, index) => ( - + ); + })}
+ ))} + - {item.keywords.length > 0 ? ( -
- {item.keywords.map((keyword) => ( - - {keyword} - - ))} -
- ) : item.keywordStatus === 'PROCESSING' ? ( -

- AI가 키워드를 분석 중이에요. 잠시 후 자동으로 채워집니다 -

- ) : ( -

이 기록엔 키워드가 없어요

- )} - - - - ))} + {pageCount > 1 && ( + + )} ); } diff --git a/src/features/home/paperAperture.css b/src/features/home/paperAperture.css index d1e79e8..f87a853 100644 --- a/src/features/home/paperAperture.css +++ b/src/features/home/paperAperture.css @@ -1097,23 +1097,26 @@ position: absolute; z-index: 15; left: 50%; - top: 50%; - width: min(960px, calc(100% - 320px)); + top: calc(50% + 38px); + width: min(1020px, calc(100% - 280px)); transform: translate(-50%, -50%); } -/* 437: 세 카드가 한 화면의 한 "쪽"이다. 네 번째부터는 같은 폭으로 오른쪽에 이어지고, 각 쪽의 - 첫 카드(1·4·7…)에만 snap을 걸어 휠·트랙패드 탐색이 세 장 단위로 안정된다. 스크롤 영역 자체에 - 포커스를 줄 수 있어 키보드 화살표/페이지 이동도 브라우저 기본 동작으로 사용할 수 있다. */ +/* 438: 한 쪽은 2행 × 3열, 총 6장이다. gallery는 쪽을 가로로 넘기는 레일이고 page가 실제 6칸 + 종이판이다. 한 카드씩 auto-column으로 흘리면 2행에서 순서가 1·3·5 / 2·4·6으로 섞이므로, + 반드시 6장씩 DOM을 묶은 page 안에서 row-major grid를 쓴다. */ +.pl-results__viewport { + min-width: 0; +} + .pl-results__gallery { - display: grid; - grid-auto-flow: column; - grid-auto-columns: calc((100% - 2rem) / 3); - gap: 1rem; + display: flex; + height: min(610px, calc(100cqh - 230px)); + min-height: 440px; overflow-x: auto; overflow-y: hidden; - padding: 4px 2px 12px; - scroll-padding-inline: 2px; + padding: 10px 12px 8px; + scroll-padding-inline: 12px; scroll-snap-type: x mandatory; scrollbar-color: theme('colors.log-mint') rgba(250, 247, 246, 0.82); outline: none; @@ -1125,13 +1128,146 @@ outline-offset: 4px; } -.pl-results__card:nth-child(3n + 1) { +.pl-results__page { + box-sizing: border-box; + display: grid; + flex: 0 0 100%; + grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-rows: repeat(2, minmax(0, 1fr)); + gap: 16px 18px; + min-width: 0; + padding: 4px 8px 8px; scroll-snap-align: start; scroll-snap-stop: always; } +/* 438 사용자 피드백: 찢긴 가장자리와 장식 종류를 여러 개 섞으면 종이보다 실루엣이 먼저 보여 + 글을 읽는 데 방해가 된다. 테두리 없는 흰 노트 한 페이지와 은은한 괘선만 남기고, + 고정 장치는 앱 공용 PinPushMount 하나만 쓴다. */ +.pl-results__card { + position: relative; + isolation: isolate; + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + gap: 7px; + padding: 19px 18px 15px; + border: 0; + background: + repeating-linear-gradient(0deg, transparent 0 30px, rgba(59, 183, 162, 0.04) 30px 31px), #fff; + color: theme('colors.pin-navy'); + text-align: left; + transition: + box-shadow 180ms ease, + transform 180ms ease; + box-shadow: 0 8px 16px rgba(4, 33, 66, 0.11); +} + +.pl-results__card:hover { + z-index: 2; + box-shadow: 0 12px 20px rgba(4, 33, 66, 0.15); + transform: translateY(-3px); +} + +.pl-results__card:focus-visible { + outline: 2px solid theme('colors.log-mint'); + outline-offset: 2px; +} + +.pl-results__index { + flex: none; + font-family: theme('fontFamily.hand'); + font-size: 14px; + font-weight: 700; + letter-spacing: 0.08em; + color: theme('colors.log-mint'); +} + +.pl-results__place { + min-width: 0; + flex: none; + padding-bottom: 5px; + border-bottom: 1px dashed rgba(4, 33, 66, 0.16); +} + +.pl-results__place-name { + overflow: hidden; + font-size: 16px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pl-results__address { + overflow: hidden; + margin-top: 1px; + color: theme('colors.log-mint'); + font-size: 11px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pl-results__keywords { + display: flex; + min-height: 19px; + flex: none; + gap: 10px; + overflow: hidden; +} + +/* 칩 대신 메모지에 형광펜으로 밑줄 친 낱말처럼 보이게 한다. */ +.pl-results__keyword { + position: relative; + z-index: 0; + flex: none; + padding-inline: 2px; + font-size: 11px; + font-weight: 700; + color: theme('colors.pin-navy'); +} + +.pl-results__keyword::before { + content: ''; + position: absolute; + z-index: -1; + right: 0; + bottom: 1px; + left: 0; + height: 7px; + background: rgba(59, 183, 162, 0.24); + transform: rotate(-1.5deg); +} + +.pl-results__keyword-status { + min-height: 19px; + flex: none; + font-size: 10px; + color: theme('colors.ink-gray-light'); +} + +.pl-results__context { + min-height: 0; + flex: 1; + overflow: visible; + padding: 13px 7px 7px; +} + +.pl-results__context .context-sticky-note { + min-height: calc(100% - 4px); +} + +/* 회전된 Context의 모서리는 자르지 않되, 긴 본문이 다음 카드까지 침범하지 않게 본문만 3줄로 묶는다. */ +.pl-results__context .context-sticky-note > p:first-of-type { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + .pl-results__gallery::-webkit-scrollbar { - height: 12px; + height: 10px; } .pl-results__gallery::-webkit-scrollbar-track { @@ -1151,6 +1287,75 @@ background-color: theme('colors.pin-navy'); } +.pl-results__controls { + display: flex; + align-items: center; + gap: 10px; + width: min(520px, calc(100% - 24px)); + margin: 8px auto 0; + color: theme('colors.pin-navy'); +} + +.pl-results__page-button { + display: grid; + flex: none; + width: 30px; + height: 30px; + place-items: center; + border: 1px solid theme('colors.line-card'); + border-radius: 9999px; + background: rgba(250, 247, 246, 0.94); + box-shadow: 0 5px 14px rgba(4, 33, 66, 0.12); + font-size: 22px; + line-height: 1; + transition: + color 160ms ease, + background-color 160ms ease, + transform 160ms ease; +} + +.pl-results__page-button:not(:disabled):hover { + color: white; + background: theme('colors.log-mint'); + transform: translateY(-1px); +} + +.pl-results__page-button:focus-visible { + outline: 2px solid theme('colors.log-mint'); + outline-offset: 2px; +} + +.pl-results__page-button:disabled { + cursor: default; + opacity: 0.35; +} + +.pl-results__page-track { + position: relative; + flex: 1; + height: 8px; + overflow: hidden; + border: 1px solid theme('colors.line-card'); + border-radius: 9999px; + background: rgba(250, 247, 246, 0.88); +} + +.pl-results__page-thumb { + position: absolute; + inset-block: 0; + left: 0; + border-radius: inherit; + background: theme('colors.log-mint'); + transition: transform 220ms ease; +} + +.pl-results__page-status { + min-width: 32px; + font-size: 11px; + font-weight: 700; + text-align: center; +} + /* ── 반응형: 축소가 아니라 제본 방식이 바뀐다 ───────────────────────── 넓으면 4판(세로쓰기 곁텍스트 포함), 중간이면 상하판만, 좁으면 상판 위주. 창의 비율 자체가 판형을 따라 바뀐다. @@ -1202,11 +1407,14 @@ font-size: 12px; } .pl-results__gallery { - grid-auto-columns: min(86%, 288px); - scroll-snap-type: x mandatory; + height: min(610px, calc(100cqh - 210px)); + } + .pl-results__page { + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: repeat(3, minmax(0, 1fr)); } - .pl-results__card:nth-child(n) { - scroll-snap-align: start; + .pl-results__controls { + width: calc(100% - 24px); } } diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 3300207..4c3249e 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -178,6 +178,7 @@ export function HomePage() { {hasResults && (
item.recordId).join('-')} items={searchMutation.data.items} onSelectRecord={setOpenRecordId} /> diff --git a/tests/__pycache__/test_frontend_ci_contract.cpython-312.pyc b/tests/__pycache__/test_frontend_ci_contract.cpython-312.pyc deleted file mode 100644 index 3b3723c19930305abbb01ff7bb2f94966c679e43..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23926 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 be{HQbS