diff --git a/incoming/ROUND.md b/incoming/ROUND.md index 0be58e2b4..03c0c3b02 100644 --- a/incoming/ROUND.md +++ b/incoming/ROUND.md @@ -151,60 +151,60 @@ await browser.close(); ## 후보 -10월 21일 포크 `dev`(`7d3b969`)를 둘러보고 적었어요. 바퀴 45 부터 이 표에서 골라요. 바퀴 9~44 의 `## 바퀴` 줄에 적힌 후보 번호는 지난 표의 번호예요. 파일 뒤의 줄 번호는 그날 기준이에요. 고칠 곳은 `web/src/` 아래면 그 앞을 줄였어요. +10월 23일 포크 `dev`(`1b9a04a`)를 둘러보고 적었어요. 바퀴 66 부터 이 표에서 골라요. 창작자 지시 5 대로 1~24위는 Pattern Lab 과 Lab 에서 보드로 가는 길이에요. 처음 온 사람이 레이어를 더하고 노브를 돌려 To hardware 로 보내기까지를 390px 터치와 1440px 마우스, 키보드로 따라가며 쟀고, 미리보기와 내보낸 JS, C++ 헤더가 어긋나는 곳은 작은 스크립트로 돌려 봤어요. 25위 아래와 10, 13, 18~20, 22~24위는 10월 21일 표에서 남은 줄이에요. 그 줄의 파일 줄 번호는 오늘 것으로 고쳤고, 쪽 속도나 대비처럼 다시 재지 않은 값은 그날 잰 값이에요. 그 값을 내는 코드는 그대로예요. 바퀴 9~60 의 `## 바퀴` 줄에 적힌 후보 번호는 지난 표의 번호예요. 고칠 곳은 `web/src/` 아래면 그 앞을 줄였어요. | 순위 | 갈래 | 무엇 | 까닭 | 고칠 곳 | |---|---|---|---|---| -| 1 | UI | WebGL 을 못 쓰는 브라우저에서도 첫 화면과 Build, Pattern, Inside 탭이 오류 화면 대신 글과 단추를 보여 준다 | 크로미움을 `--disable-3d-apis` 로 띄워 빌드한 사이트를 열면 `/`(1440px), `/build`, `/pattern`, `/inside`, `/inside/jon-usa`(390px, 1440px 둘 다) 열 번 가운데 아홉 번이 「This page couldn't load」 화면이 돼요. 오류는 R3F `Canvas`(`components/sections/InsideGlobe/Globe.tsx:1302`, `components/3d/HeroScene.tsx:627`)가 렌더러를 만들다 던진 「Error creating WebGL context」이고, 이걸 그리는 `components/3d/ViewerPanel.tsx:51` 위에 오류 경계가 없어 쪽 전체로 올라가요. 390px `/` 만 살아남는데, 3D 칸이 `display: none`(`app/globals.css:244`)이라 캔버스를 만들지 않아서예요. 같은 조건에서 가이드 다섯 쪽과 Pattern Lab 은 열려요. 가이드는 `components/guide/world/GuideWorld.tsx:45` 의 `hasWebGL()` 로 먼저 묻고 `:66` 의 `StageGuard` 로 감싸요. 이 둘을 `ViewerPanel` 에 똑같이 붙이고, 묻는 함수는 `lib/` 로 옮겨 같이 써요. `HeroScene.tsx:131` 의 `ModelGuard` 는 모델만 감싸서 캔버스 오류는 못 잡아요. | `components/3d/ViewerPanel.tsx`, `components/guide/world/GuideWorld.tsx`, 새 `lib/webgl.ts`, 새 `components/3d/ViewerPanel.test.tsx` | -| 2 | UI | 첫 화면 영상 밑 사진이 2.1 MB 원본 대신 화면 폭에 맞춘 20 KB 남짓으로 와서 느린 망에서도 1초 안팎에 뜬다 | `components/sections/Hero.tsx:127`~`:139` 의 포스터는 `` 그대로라 4032×2268, 2,150,860바이트 원본을 받아 폰에선 340×192px, 1440 에선 567×320px 에 그려요. 첫 화면 칸이 숨은 `/build`, `/pattern`, `/inside` 에서도 DOM 에 있어서 같은 2.15 MB 를 받아요. 이 사진이 첫 화면의 LCP 예요. 9 Mbps 에서 움직임 줄이기를 켜고 재 보니 LCP 가 폰 8.7초, 데스크톱 5.6초였고, 같은 서버의 `/_next/image` 1080폭 WebP(17,388바이트)를 넣으니 0.8초와 1.1초였어요. 640폭은 7,234바이트, 1200폭은 20,740바이트예요. `CasePicker.tsx:131` 처럼 `next/image` 의 `fill` 과 `sizes` 를 쓰고, 첫 화면 그림이니 `priority` 를 줘요. `:126` 의 eslint 예외 줄은 빠져요. `Hero.test.tsx:56`, `:103` 이 `src="/product_v2.jpg"` 를 찾으니 같이 고쳐요. | `components/sections/Hero.tsx`, `components/sections/Hero.test.tsx` | -| 3 | UI | Build, Pattern 탭과 폰 첫 화면이 보이지 않는 지구본의 사진 65장을 받지 않는다 | `components/3d/ViewerPanel.tsx:31` 은 hero 에도 지구본을 고르고, 첫 장면을 정하는 store 의 `homeTab` 은 `'hero'`(`store/useAppStore.ts:65`)로 시작해서 `components/sections/RightPanel.tsx:115` 의 effect 뒤에야 바뀌어요. 그래서 `/build`, `/pattern` 에서도 0.8초쯤 `GlobeViewer` 가 붙고(`ViewerPanel.tsx:51`), 그 미리 받기(`components/sections/InsideGlobe/GlobeViewer.tsx:77`~`:83`, `loading="eager"`)가 핀 28개마다 첫 사진을 3장까지(`builds.ts:72`) 모두 65장을 불러요. 390px·DPR3 에서 8,384,194바이트, 1440 에서 1,480,190바이트예요. 폰 첫 화면은 3D 보기가 `display: none`(`app/globals.css:244`)이라 지구본을 그리지도 않는데(5초 동안 requestAnimationFrame 0번) 같은 65장을 받아요. 9 Mbps 에서 이 요청을 막아 보니 폰 `/build` 의 load 가 11.1초에서 3.7초로, 움직임 줄이기를 켠 폰 `/` 의 LCP 가 8.7초에서 3.8초로 줄었어요. `HomeView.tsx:15` 의 `initialTab` 을 `ViewerPanel` 에 넘겨 첫 장면을 정하고, 미리 받기는 지구본이 보일 때(901px 이상이거나 Inside 탭)만 그려요. 폭 판단은 `lib/hooks/useMediaQuery.ts:9` 를 써요(`PatternPanel.tsx:97` 꼴). `HeroScene.tsx:85` 의 `useGLTF.preload`(`model.glb` 282,620바이트)는 다음 조각이에요. WebGL 줄과 같은 파일이라 하나가 먼저 머지된 뒤에 해요. | `components/3d/ViewerPanel.tsx`, `components/HomeView.tsx`, `components/sections/InsideGlobe/GlobeViewer.tsx`, 새 `components/3d/ViewerPanel.test.tsx` | -| 4 | 근간 | 커뮤니티 API 가 본문이 `null` 인 요청에 500 대신 400 과 까닭을 돌려준다 | 쓰기 라우트 22곳이 `await request.json()` 의 결과를 `Record` 으로 바로 읽어요. `null` 은 맞는 JSON 이라 `catch`(예: `app/api/community/patterns/route.ts:130`)를 지나고 다음 `raw.title`(`:135`)에서 TypeError 가 나요. 로그인과 Origin 머리를 달고 보내 보니 13곳이 500 이었어요: `atlas` POST·DELETE(`atlas/route.ts:94`), `decks` POST(`decks/route.ts:53`), `decks/[id]` PATCH(`:110`), `comments/[id]` PATCH(`:74`), `patterns` POST, `patterns/[id]` PATCH(`:137`), 그 아래 `comments`(`:56`)·`header`(`:115`)·`performance`(`:138`) POST, `posts` POST(`:53`), `presence` POST(`:79`), `reports` POST(`:44`). `[]`, `"x"`, `1`, `{}` 는 400 이나 200 이었어요. 500 응답엔 JSON 도 CORS 머리도 없어서 다른 출처의 Pattern Lab 은 까닭을 못 읽어요. 관리자 라우트를 포함한 아홉 파일도 같은 꼴이에요(코드로 확인). `territories/[code]/pin/route.ts:82` 만 읽기가 `try` 안이라 괜찮아요. `lib/lab/serialize.ts:270` 의 `isRecord` 꼴로 「객체를 읽거나 400 을 돌려주는」 함수를 하나 두고 21곳이 불러요. | 새 `lib/community/jsonBody.ts` 와 `lib/community/jsonBody.test.ts`, `app/api/community/**/route.ts` 21곳 | -| 5 | UI | 끝이 잘린 코드를 붙이면 Lab 과 Live Editor 가 코드가 끝나기 전에 끊겼다고 말한다 | `lib/pattern/harness.ts:432` 의 `compilePatternCode` 는 붙인 코드 뒤에 `return { setup, update, draw }`(`:437`)를 덧대 한 함수로 만들어요. 코드가 `display.setPixel(x, y, ` 에서 끊기면 그 `return` 이 호출 안으로 들어가 「Unexpected token 'return'」이 떠요. 닫는 괄호만 빠지면 `new Function` 이 두른 `})` 때문에 「Unexpected token ')'」이 떠요. 둘 다 사용자가 쓰지 않은 글자예요. 문법 오류엔 줄 번호도 없어요(`:413`). Lab Code 패널에 Paste 로 붙이니 오류 상자(`app/pattern-lab/panels/CodePanel.tsx:149`)에 「Unexpected token 'return'」 한 줄만 떴어요. Live Editor 도 편집기 아래(`components/sections/PatternPanel.tsx:272`)에 같은 글이 떴어요. 문법 오류가 나면 문자열과 주석을 건너뛰며 열린 괄호를 세요. 끝까지 닫히지 않으면 "The code stops before it ends: 2 { and 1 ( never close. Was the paste cut off? Last line: …" 처럼 알려요. | `lib/pattern/harness.ts`, 새 `lib/pattern/harness.test.ts`, `web/public/pattern-sandbox.html`(`npm run build:sandbox` 로 다시 만들고 `check:sandbox-sync` 가 봐요) | -| 6 | UI | 폰에서 Lab 의 Recent 와 Panels 메뉴가 화면 안에 열리고, 창 닫기 단추를 누르기 쉽다 | `.menuPop` 은 `right: 0`(`app/pattern-lab/LabPanels.module.css:80`)이라 단추 오른쪽 끝에 붙어 왼쪽으로 펼쳐져요. 800px 아래에선 단추 줄이 왼쪽으로 붙는데(`:47`~`:58`) 메뉴는 그대로예요. 390×844 에서 터치로 눌러 재니 Recent 메뉴가 x −105~85px, Panels 메뉴가 x −27~163px 이었고, 「Park this and start fresh」는 「art fresh」로 잘려 보였어요. 같은 미디어 쿼리에 `.menuPop { left: 0; right: auto }` 를 넣으니 Recent 는 14~204px, Panels 는 93~283px 로 화면 안에 들었어요. 801px 과 1000px 에선 지금처럼 오른쪽에 붙었어요. 메뉴 칸 높이도 33px 라 같은 쿼리에서 44px 로 올려요. Lab 창들이 같이 쓰는 `.modalHeader button`(`app/pattern-lab/PatternLab.module.css:489`)은 `min-width`, `min-height` 가 30px 라, 같은 폰에서 Code guide 의 닫기 단추가 30×30px 였어요. 커뮤니티 창은 바퀴 27 에서 이 단추를 44×44px 로 키우고 음수 여백으로 머리줄 높이를 지켰어요(`components/community/Community.module.css:2558`~`:2572`). 그 꼴을 따라요. | `app/pattern-lab/LabPanels.module.css`, `app/pattern-lab/PatternLab.module.css` | -| 7 | 근간 | 기기 주소 칸을 비우거나 `http://` 를 붙여 넣어도 「Send over Wi-Fi」 링크가 그 보드로 간다 | `lib/community/deviceHost.ts:65` 의 `patternsUrl` 은 저장된 값을 `trim()` 만 하고 `http://${host}/patterns?src=` 를 만들어요. 칸을 비우면 `:32` 가 빈 문자열을 그대로 돌려줘서 `http:///patterns?src=…` 가 되고, 브라우저는 호스트를 `patterns` 로 읽어요. `http://192.168.0.44` 를 붙여 넣으면 호스트가 `http` 예요(jsdom 에서 훅을 그리고 `new URL(...).host` 로 쟀어요). 같은 일을 하는 네 곳은 `deviceHost.trim() \|\| "patternflow.local"` 을 저마다 적어 빈 칸을 막아요(`components/community/ZipDownload.tsx:90`, `components/community/ShippedPackCard.tsx:106`, `app/update/UpdateClient.tsx:63`, `app/features/TryInstall.tsx:35`). `patternsUrl` 을 쓰는 여섯 곳(`DeckPanel.tsx:178`, `SendModuleModal.tsx:131`, `:240`, `DeckDock.tsx:474`, `app/community/d/[id]/DeckDetailClient.tsx:382`, `:549`)만 이 보호가 없어요. `:69` 의 `updateUrl` 은 부르는 곳이 없고, 가리키는 `/api/community/builds/[id]/firmware` 라우트도 없어요. 주소를 다듬는 함수 하나(앞의 `http://` 와 뒤의 `/` 를 떼고, 비면 `patternflow.local`)를 이 파일에 두고 `patternsUrl` 과 네 곳이 같이 써요. 시험은 `ZipDownload.test.tsx:32` 처럼 `localStorage` 에 값을 넣고 봐요. | `lib/community/deviceHost.ts`, `components/community/ZipDownload.tsx`, `components/community/ShippedPackCard.tsx`, `app/update/UpdateClient.tsx`, `app/features/TryInstall.tsx`, 새 `lib/community/deviceHost.test.ts` | -| 8 | UI | Lab 과 Live Editor 의 복사 단추가 복사에 실패하면 그렇다고 말한다 | 브라우저가 클립보드 쓰기를 거절하게 바꾸고 눌러 봤어요. Lab Code 패널의 「Copy prompt」(`app/pattern-lab/panels/CodePanel.tsx:64`~`:69`)는 try 가 없어, 단추 글이 그대로 남고 처리되지 않은 오류만 났어요. Live Editor 의 「Copy creation prompt」(`components/sections/PatternPanel.tsx:134`~`:140`)는 결과를 기다리지 않고 「Creation prompt copied to clipboard!」 alert 를 띄워서, 복사가 안 됐는데 됐다고 말해요. To hardware 의 복사(`app/pattern-lab/HardwareModal.tsx:110`~`:117`)는 오류를 잡기만 하고 아무것도 보이지 않아요. 이 창은 이 빌드에 없어 코드로 봤어요. 같은 조건에서 Pixel 의 「Copy as code」는 「Copy failed」를 띄웠어요(`panels/PixelPanel.tsx:118`~`:121`). 그 꼴을 따라 실패하면 단추 글을 잠깐 「Copy failed」로 바꾸고, Live Editor 는 성공했을 때만 알려요. | `app/pattern-lab/panels/CodePanel.tsx`, `app/pattern-lab/HardwareModal.tsx`, `components/sections/PatternPanel.tsx` | -| 9 | 근간 | 커뮤니티에서 Lab 으로 연 패턴을 손대지 않고 다시 올려도 크기 검사에 막히지 않는다 | 서버는 라이선스 머리말과 꼬리말을 떼고 100,000자(`lib/community/validate.ts:9`)를 재요(`app/api/community/patterns/route.ts:143`~`:149`, `app/api/community/patterns/[id]/route.ts:292`). 상한 바로 밑에서 받아 준 패턴이 내려받기나 포크로 돌아오면 몇백 자가 넘어 거절되던 일을 고친 것이라고 주석에 적혀 있어요. 그런데 공유 창은 감싼 채로 `code.length` 를 재요(`components/community/PublishModal.tsx:152`). 「Open in Pattern Lab」과 「Edit in Pattern Lab」(`app/community/p/[id]/PatternDetailClient.tsx:223`)은 저장된 코드를 머리말째 넘기고, Lab 은 그것을 레이어 코드로 그대로 둬요(`app/pattern-lab/openIncomingPattern.ts:47`. 510자가 510자로 남았어요). 머리말과 꼬리말은 원작이면 447자, 포크면 576자예요. 본문 99,700자인 포크는 저장하면 100,276자라서, 서버는 받는데 창이 먼저 「100 KB of code」라며 막아요. 작성자가 자기 글을 고치러 다시 열 때(`editOf`)도 같은 검사를 지나요. 창도 `stripShareWrapping`(`lib/pattern/share.ts:177`)을 거친 길이로 재요. | `components/community/PublishModal.tsx`, `components/community/PublishModal.test.tsx` | -| 10 | 근간 | 커뮤니티 API 가 원작 패턴의 라이선스를 거둔 MIT 나 CC0 로 바꾸는 요청을 거절한다 | `app/api/community/patterns/[id]/route.ts:185`~`:187` 주석은 거둔 라이선스는 지금 값을 지킬 때만 되고 바꾸는 것은 막는다고 해요. 그런데 코드는 거둔 둘이 든 `KNOWN_LICENSES`(`lib/pattern/share.ts:38`)에서 찾고, 포크일 때만 막아요(`:190`). localhost:3100 에서 CC-BY-SA-4.0 원작을 올린 뒤 `PATCH {"license":"CC0-1.0"}` 와 `{"license":"MIT"}` 가 둘 다 200 이었고, 패턴 쪽의 소스가 `SPDX-License-Identifier: MIT` 로 바뀌었어요. 편집 창은 지금 값과 `LICENSE_OPTIONS` 만 보여 줘요(`components/community/EditDetailsModal.tsx:73`~`:76`). 그러니 이 길은 API 를 바로 부를 때만 열려요. `share.ts:29`~`:31` 의 「새 작업엔 더 내놓지 않는다」를 서버도 지키게, 값이 바뀔 때는 `LICENSE_OPTIONS` 에서만 찾아요. 로드맵 `biz-market`(`app/roadmap/roadmap-data.ts:1099`)이 토대로 삼는 라이선스 기록도 함께 지켜져요. 시험은 `app/api/community/decks/[id]/route.test.ts` 처럼 임시 DB 와 진짜 세션으로 라우트를 불러요. | `app/api/community/patterns/[id]/route.ts`, 새 `app/api/community/patterns/[id]/route.test.ts` | -| 11 | UI | 커뮤니티 창이 Esc 로 닫히고, 열리면 초점이 창 안으로 들어가며, 닫히면 연 단추로 돌아온다 | 커뮤니티의 `role="dialog"` 13곳(파일 12개) 가운데 Esc 를 듣는 건 `components/community/ShareDeckPackModal.tsx:46` 하나예요. 1440px 에서 키보드로 열고 Esc 를 눌러 보니 로그인(`AuthModal.tsx:135`), 신고(`ReportModal.tsx:70`), 「Edit details」(`EditDetailsModal.tsx:111`), 「Add firmware header」(`AddHeaderModal.tsx:127`), 「Publish a performance」(`AddPerformanceModal.tsx:80`) 다섯 창이 모두 열린 채였어요. 넷은 열린 뒤에도 초점이 뒤쪽의 여는 단추에 있었고, Tab 을 25번 누르면 초점이 창 밖으로 나갔어요. Pattern Lab 의 창 셋은 `lib/hooks/useDialog.ts` 로 이걸 이미 해요(`app/pattern-lab/panels/CodeGuideModal.tsx:19` 꼴, 시험 `lib/hooks/useDialog.test.tsx`). 첫 조각은 이 다섯 창이에요. `AuthModal` 은 `embedded` 일 때(`:132`) ref 를 달지 않아요. `PublishModal` 과 `SendModuleModal` 은 To hardware 창이 대신 Esc 를 받으니(`app/pattern-lab/HardwareModal.tsx:139`~`:146`) 다음 조각으로 미뤄요. | `components/community/AuthModal.tsx`, `ReportModal.tsx`, `EditDetailsModal.tsx`, `AddHeaderModal.tsx`, `AddPerformanceModal.tsx`, 새 `components/community/dialogKeys.test.tsx` | -| 12 | UI | 폰에서 지도의 거르개 단추 줄이 지도 위에 따로 서서 점 이름을 가리지 않고, 지도 아래 빈 칸과 폰에서 안 되는 휠 안내가 없어진다 | 390px 에서 거르개 7개가 두 줄(324×51px)로 지도 위에 떠 있는데, 켜지지 않은 단추는 `.toolBtn` 의 `background: transparent`(`components/community/Atlas.module.css:41`)를 `composes`(`:113`)로 물려받아 밑의 점 이름이 비쳐요. 크로미움에서 세어 보니 지도 글자 83개 가운데 21개가 단추 밑에 깔렸고 1440px 에선 0개예요. 900px 아래 `.mapWrap` 은 `height: 60vh` 가 `flex: 1`(`:73`)에 밀려 `min-height: 380px`(`:810`)로 380px 인데, SVG 의 `height: 100%`(`:81`)가 풀리지 않아 1000:640 비율대로 228px 만 그려서 아래 152px 가 비어요. 줄 위 안내 「wheel to zoom · drag to pan · double-click to reset」(`components/community/AtlasClient.tsx:116`, 한국어 `:166`)은 폰에서 하나도 안 돼요. 배율을 바꾸는 곳은 휠(`:253`)뿐이고, 끌기는 `clampView`(`:1285`)가 배율 1 에서 0 으로 묶어요. 900px 아래에서 `.mapTop`(`Atlas.module.css:88`)을 `position: static` 으로 두고 `.mapWrap` 높이를 풀어 주입해 보니, 단추 밑 글자 0개, 칸 높이 291px, 가로 넘침이 없었어요. 안내는 `Community.module.css:5752`~`:5756` 이 `(hover: none)` 에서 `.zoomHint` 를 숨기는 꼴을 따라요. | `components/community/Atlas.module.css`, `components/community/communityCss.test.ts` | -| 13 | UI | 약관, 연락, 기능, 로드맵, 404, 커뮤니티 쪽이 머리의 Patternflow 링크 때문에 첫 화면의 3D 코드 600 KB 를 미리 받지 않는다 | 이 쪽들의 ``(`app/terms/page.tsx:30`, `components/contact/ContactPage.tsx:10`, `app/features/page.tsx:107`, `app/roadmap/RoadmapMap.tsx:392`, `app/not-found.tsx:7`, `app/community/layout.tsx:83`)가 화면에 들면 Next 가 `/` 를 미리 받으면서 그 쪽의 클라이언트 덩이까지 받아요. 390px 로 열고 5초 기다리며 `/?_rsc=` 요청만 막아 비교하니 스크립트 전송량이 `/terms`, `/contact`, 404 는 766,098 에서 148,456, `/features` 는 773,316 에서 155,674, `/roadmap` 은 806,407 에서 188,765, `/community` 는 985,557 에서 407,495 바이트로 줄었어요. 더 받던 몫은 three.js 덩이(gzip 237,537 / 원본 892,770)와 후처리 덩이(gzip 212,365) 같은 첫 화면 3D 코드예요. `components/community/PatternCard.tsx:421` 처럼 `prefetch={false}` 를 줘요. 누르면 그때 받아요. | `app/terms/page.tsx`, `components/contact/ContactPage.tsx`, `app/features/page.tsx`, `app/roadmap/RoadmapMap.tsx`, `app/not-found.tsx`, `app/community/layout.tsx` | -| 14 | UI | 모든 쪽이 쓰지 않는 DM Sans 를 받지 않고, Newsreader 와 Silkscreen 은 그 글꼴이 쓰이는 쪽에서만 받는다 | `app/layout.tsx:21` 의 DM Sans 는 `:174` 에서 변수만 붙고 `--font-dm-sans` 를 읽는 곳이 `web/` 어디에도 없는데, next/font 가 모든 쪽 머리에 미리 받기 링크를 넣어요. 390px `/` 에서 미리 받는 글꼴 7개 가운데 화면 글자가 쓰지 않는 것은 DM Sans 37,503, Newsreader 58,675, Newsreader 기울임 65,023, Silkscreen 4,049 와 3,729 바이트로 모두 168,979 바이트예요(이 쪽 글꼴 전체는 10개 448,958 바이트). Newsreader 는 `--pf-serif`(`app/globals.css:167`)로 저널과 가이드 `.note`(`components/guide/Guide.module.css:1243`), `/terms` 만 쓰고, Silkscreen 은 가이드(`Guide.module.css:33`)만 써요. DM Sans 를 빼고 둘에 `preload: false` 를 줘요. 쓰는 쪽은 글자가 나올 때 받아요. `app/metadata.test.ts:18` 의 `next/font/google` 흉내에서도 `DM_Sans` 를 빼요. | `app/layout.tsx`, `app/metadata.test.ts` | -| 15 | 근간 | Lab 에서 노브 1, 4 를 0..1 밖의 범위로 잡아도 패턴이 받는 노브 칸 수가 맞다 | `lib/pattern/controls.ts:76` 의 `wrapKnobDelta` 는 값이 0..1 이라고 보고 ±0.5 를 넘으면 1 을 더하거나 빼요. `lib/lab/engine.ts:168`~`:173` 은 노브 1, 4 에 늘 이 감기를 걸어요. 그래서 `@knobs Hue=0..360` 패턴에선 한 칸(7.5) 움직임이 6.5 로 읽혀 `input.knobDeltas[0]` 이 1 이 아니라 0.867 이에요. 100 에서 100.7 로 가면 +0.093 이 아니라 −0.040 으로 거꾸로 읽혀요. Director 처럼 0 에서 360 을 120프레임에 훑으면 패턴이 받은 칸 합이 48 이 아니라 32 예요. 가이드 시뮬레이터는 `@knobs` 가 있으면 감기를 꺼요(`lib/guide/deviceSim.ts:846`). 커뮤니티 미리보기(`components/community/SandboxPreview.tsx:53`)와 가이드 데스크(`components/guide/desk/community/live.ts:114`)는 Lab 처럼 감기를 걸어요. `knobTargetToDelta`(`lib/pattern/harness.ts:627`)가 이미 받는 `unitsPerTurn` 에 `TURNS_PER_FULL_RANGE` 를 곱해 범위 폭을 얻고 그 절반에서 감으면, 0..1 은 그대로고 세 곳이 한 번에 맞아요. | `lib/pattern/controls.ts`, `lib/pattern/harness.ts`, `lib/pattern/controls.test.ts`, `web/public/pattern-sandbox.html`(`npm run build:sandbox`) | -| 16 | 근간 | Pattern Lab 에서 0% 레이어 위의 마스크가 미리보기와 내보낸 JS, C++ 헤더에서 같은 레이어를 가린다 | 미리보기는 불투명도 0% 레이어도 그 위 마스크의 닻으로 남겨요(`lib/lab/engine.ts:273`~`:277`). 내보낸 JS 와 C++ 헤더는 묶기 전에 0% 레이어를 걸러내서(`lib/lab/flatten.ts:117`, `lib/lab/hExport.ts:161`) 마스크가 그 아래 레이어에 붙어요. 두 파일의 묶는 코드(`flatten.ts:233`, `hExport.ts:183`)는 0% 레이어를 닻으로 두게 짜여 있지만, 이미 걸러진 뒤라 그 레이어를 보지 못해요. 2×1 판에 위에서부터 마스크(왼쪽만 켬), 0% 레이어, 빨강 100% 를 쌓아 돌려 봤어요. 미리보기는 `[255,0,0 \| 255,0,0]`, 내보낸 JS 는 `[255,0,0 \| 0,0,0]` 이었고, C++ 표는 `MASK_LAYER {1}` 이 빨강 묶음에 붙었어요. 미리보기 안에서도 갈려요. 처음부터 0% 인 코드 레이어는 한 번도 돌지 않아 버퍼가 없으니(`engine.ts:187`, `:258`) 마스크가 아래로 붙어요(`[255,0,0 \| 0,0,0]`). 한 프레임 100% 로 돌린 뒤 0% 로 내리면 닻이 돼요(`[255,0,0 \| 255,0,0]`). 「0% 레이어 위의 마스크는 아무것도 가리지 않는다」를 세 곳에 같게 적어요. `lib/lab/compositor.test.ts:117` 의 무작위 판은 불투명도를 1, 0.5, 0.25, 0.8 에서만 골라 0 이 나오지 않아요. | `lib/lab/engine.ts`, `lib/lab/flatten.ts`, `lib/lab/hExport.ts`, `lib/lab/compositor.test.ts` | -| 17 | 근간 | Lab 에서 레이어를 하나 더 얹어도 코드 레이어가 내보낸 JS 에서 미리보기와 같은 픽셀을 찍는다 | 코드 레이어 하나면 코드를 그대로 내보내고(`lib/lab/flatten.ts:100` 의 `needsFlatten`), 둘 이상이면 각 레이어를 `flatten.ts:162` 의 `__mkDisplay` 위에서 돌려요. 이 판은 좌표를 `x \| 0` 으로 자르고(`:166`, `:174`) 색을 `Uint8ClampedArray` 에 그대로 넣어요(`:170`). 미리보기와 Live Editor 의 판은 `Math.floor` 로 자르고 `clampByte` 로 반올림해요(`lib/pattern/harness.ts:482`, `:502`, `:49`). 그래서 −1 과 0 사이 좌표의 점은 내보낸 쪽에서만 0번 줄에 찍히고, 반 값은 내보낸 쪽이 짝수로 반올림해요. 빈 픽셀 레이어 위에 코드 레이어를 얹어 128×64 를 비교했어요. (20,32) 둘레 반지름 40 원은 (42,0), (43,0) 두 픽셀이 내보낸 쪽에만 찍혔어요. `setPixel(x, y, x*2+0.5, …)` 기울기는 8,192픽셀이 다 1씩 달랐어요((0,0) 이 미리보기 1, 내보낸 쪽 0). `__mkDisplay` 를 harness 의 두 규칙대로 고치고, `lib/lab/compositor.test.ts` 의 「preview matches the flattened export」에 코드 레이어 판을 더해요. 지금 그 시험은 픽셀 레이어만 쌓아요(`:81`, `:112`). | `lib/lab/flatten.ts`, `lib/lab/compositor.test.ts` | -| 18 | UI | Lab 의 창 안에서 글을 끌어 고르다 창 밖에서 놓아도 창이 닫히지 않는다 | 크로미움은 누른 곳과 뗀 곳이 다르면 click 을 두 곳의 공통 조상에 보내요. To hardware 의 `closeOnBackdrop`(`app/pattern-lab/HardwareModal.tsx:136`)은 `event.target === event.currentTarget` 만 봐요. 헤더 칸에서 끌어 바깥 판에서 떼면 공통 조상이 바깥 판이라 이 검사를 지나 창이 닫히고, 붙인 헤더는 이 창의 `useState`(`:50`, `:51`)에만 있어 같이 사라져요. 이 빌드엔 To hardware 가 없어서 같은 구조를 낱장 HTML 로 만들어 크로미움에서 끌어 봤어요. 오늘 규칙으론 1번 닫혔고, 누름도 바깥 판에서 시작했을 때만 닫는 규칙으론 0번이었어요. 바깥 판을 한 번 누르면 두 규칙 다 닫아요. 실제 Lab 에서도 Code guide 의 코드 글(`panels/CodeGuideModal.tsx:16`)과 Import image 제목(`panels/ImportImageModal.tsx:219`)을 끌어 바깥에서 떼니 창이 닫혔어요. 바깥 판의 `onPointerDown` 에서 누름이 판 자신에서 시작했는지 적어 두고, click 은 그때만 닫아요. | `app/pattern-lab/HardwareModal.tsx`, `app/pattern-lab/panels/CodeGuideModal.tsx`, `app/pattern-lab/panels/ImportImageModal.tsx`, `app/pattern-lab/HardwareModal.test.tsx` | -| 19 | UI | Pattern 탭의 고급 안내와 To hardware 칸의 두 문단, Build 탭의 다른 길 안내가 제 글자 크기로 보인다 | `app/globals.css:837` 의 `.panel-body p`(20px, 900px 아래 17px `:858`)는 명시도 (0,1,1)이라 CSS 모듈 클래스 하나를 이겨요. 1440 에서 `.advancedNote`(`components/sections/PatternPanel.module.css:283`)와 `.hardwareNote` 둘(`:344`)은 14px 대신 20px, `.otherPaths`(`components/sections/BuildPanel.module.css:173`)는 15px 대신 20px 예요. 옆 카드 글은 15px 이고 칸 제목 `hardwareTitle` 은 18px 이라 안내가 제목보다 커요. `margin: 0` 도 져서 아래 여백 28px 가 붙고, To hardware 칸은 572px 로 363px 보다 209px 길어요. `InsidePanel.module.css:105` 의 `.body .mapNote`, `InsideGlobe/BuildCard.module.css:10` 의 `.card .empty` 꼴을 따라요. `BuildPanel.tsx:237` 은 `styles.buildPanel` 이 있고, `PatternPanel.tsx:148` 의 `panel-body` 엔 모듈 클래스를 하나 더해요. | `components/sections/PatternPanel.module.css`, `components/sections/PatternPanel.tsx`, `components/sections/BuildPanel.module.css` | -| 20 | UI | Make 가이드의 덱 순서 경고가 지금 펌웨어 판을 말한다 | 「Your deck」 단계는 「Up to firmware v3.10.4, Send over Wi-Fi drops this order; the next firmware keeps it」라고 해요(`components/guide/copy/community.ts:71`, 한국어 `:137`, 주석 `:23`~`:25`). 그 다음 펌웨어 3.10.5 가 10월 2일에 나와 덱 순서를 지키고(`CHANGELOG.md:113`), 지금은 3.11.0 이에요(`firmware/patternflow/net_config.h:114`). 「3.10.5 부터」처럼 판 번호로 적어요. | `components/guide/copy/community.ts` | -| 21 | UI | 가로로 든 폰에서 Build, Pattern, Inside 탭의 글이 화면의 절반 넘게 보인다 | 900px 아래에선 3D 보기(`--mb-viewer-h: 44vh`, `app/globals.css:185`, `:263`)와 탭 막대 52px(`:2784`, `:2795`)가 위에 붙어요. 844×390 에서 재 보니 둘이 224px 를 쥐어서 글은 166px(43%)만 보이고, 740×360 에선 150px 예요. Pattern 탭의 편집기는 하한 120px(`components/sections/PatternPanel.module.css:414`)라 166px 가운데 120px 를 덮어서, 그 주석(`:404`~`:411`)이 막으려던 일이 가로 화면에서 다시 생겨요. `@media (max-width: 900px) and (max-height: 500px)` 에서 `--mb-viewer-h` 를 30vh 로 주니 글이 221px(57%) 보였고, 편집기는 131px 로 따라왔어요. 세로 390×844 는 그대로예요. 변수가 vh 그대로라 `components/sections/InsideGlobe/BuildCard.tsx:38`~`:41` 의 계산도 맞아요. | `app/globals.css` | -| 22 | UI | 폰에서 로드맵 마디를 누르면 뜨는 창이 닫기 단추까지 화면 안에 든다 | 창 폭은 360px 로 박혀 있고(`app/roadmap/RoadmapMap.tsx:97`, `:704`), 오른쪽에 자리가 없으면 마디 왼쪽으로 넘기기만 해서(`:373`~`:374`) 0 아래로 내려가도 잡지 않아요. 390px 에서 맨 왼쪽 마디를 누르니 창이 x −384~−24 에 떠서 닫기 단추(−78~−45)까지 하나도 안 보였고, 가운데 마디는 −153 에서 시작해 날짜와 제목이 132px 잘렸어요. 폭을 `min(360, 캔버스 − 28)` 로, `left` 를 `margin`~`캔버스 − 폭 − margin` 사이로 잡아요. `top` 은 이미 `:375` 에서 그렇게 잡아요. 계산을 함수로 빼서 390 폭을 시험해요. `roadmap-data.ts` 는 건드리지 않아요. | `app/roadmap/RoadmapMap.tsx`, `app/roadmap/RoadmapMap.test.tsx` | -| 23 | UI | 폰에서 덱 쪽과 프로필 쪽이 긴 덱 카드 때문에 옆으로 밀리지 않는다 | 폰 규칙의 `.deckGrid { grid-template-columns: 1fr }`(`components/community/Community.module.css:5759`~`:5761`)는 `minmax(auto, 1fr)` 이라 카드가 제목 줄의 최소 폭보다 좁아지지 않아요. 제목 줄엔 제목과 「2 slots」, 비공개면 「private」 칩, 작성자가 한 줄로 서고(`components/community/DeckCard.tsx:47`~`:57`) 제목만 `nowrap` 말줄임이에요(`Community.module.css:2980`). 로그인해서 20자 제목의 비공개 덱이 있는 `/community/decks` 를 390px 로 여니 카드가 386px 라 `scrollWidth` 가 402px 였고, 360px 에서도 카드는 386px 그대로였어요. 바로 아래 `.profileGrid`(`:5763`)처럼 `minmax(0, 1fr)` 로 주입하니 카드 358px, 제목은 말줄임, `scrollWidth` 390px 이었어요. 같은 격자를 `app/community/u/[username]/page.tsx:134` 도 써요. | `components/community/Community.module.css`, `components/community/communityCss.test.ts` | -| 24 | UI | 기기 주소칸 앞 글자를 눌러도 칸에 초점이 가고, 보내기 창에서도 칸 테두리가 보이며, 폰에서 칸이 누르기 쉬운 높이가 된다 | 「Device address:」 글자와 칸이 `