Pointly 06. 400줄 규칙 도입과 브릿지 너머 편집 기능 복원
Pointly는 개발자가 아닌 사이트 오너가 바꾸고 싶은 부분을 클릭하고, 원하는 결과를 말로 적고, 미리 보고, 배포하는 도구다. 캔버스를 iframe으로 갈아 끼우면서 꺼 뒀던 기능들을 브릿지 너머로 다시 켜는 날이었고, 그러기 전에 App.tsx를 갈랐다.
고를 것이 없으면 이 제품은 아무것도 못 한다
빈 문서 템플릿에 제목 한 줄과 문단 한 줄을 놓아 두었더니, 그것을 지우는 일이 오너의 첫 작업이 됐다. 프리셋 설명이 이미 같은 말을 하므로 지우게 만들 것을 놓지 않는다.
대신 body가 뷰포트를 채운다. 그런데 그러려면 브릿지가 body를 짚을 수 있어야 했다.
조상 사슬은 body를 뺀다. 내용이 있는 문서에서 페이지 루트는 body가 아니라 그 안의 래퍼이기 때문이다. 그런데 포인터가 body 자신에 닿는 일이 흔하다. 빈 문서로 시작한 사이트에는 요소가 하나도 없고, 내용이 있는 사이트에서도 배경·여백·섹션 사이의 빈틈이 전부 body다. 예전에는 그때 아무것도 나가지 않았다. 빈 문서에서는 첫 요청조차 보낼 수 없었다.
곁딸린 것 둘도 같은 종류였다. cssPath가 body에서 빈 문자열을 냈고, 콘솔의 isSelection이 그것을 버려서 브릿지는 보냈는데 화면은 그대로인 실패가 됐다.
그리고 그 선택을 뭐라고 부를지도 정했다. body를 골랐을 때 그것은 페이지 그 자체이므로 '선택한 영역'이라고 부르면 무엇을 골랐는지 알 수 없다. 발췌도 붙이지 않는다. blockLabel은 단서가 없으면 <body>처럼 태그를 그대로 돌려주고, 그것을 덧붙이면 오너에게 소스 구조가 노출된다.
색도 타이포도 정하지 않았다. 여기서 정한 값은 오너가 고르지 않은 취향이고, 첫 편집에서 AI가 그것을 존중하거나 뒤집어야 하는 짐이 된다.
하나의 클릭을 두 가지 뜻으로 쓸 수 없다
모드가 없으면 "이 버튼을 고쳐줘"와 "이 버튼을 눌러보고 싶다"가 같은 동작이 되고, 오너는 자기가 무엇을 하는지 모른 채 누른다. select(짚는다) / interact(사이트 그대로)로 갈랐다.
편집 모드에서는 투명 유리를 사이트 위에 깐다. preventDefault로는 부족한 이유가 정확하다. preventDefault는 클릭의 결과만 막는다. :hover로 버튼이 뜨고 :active로 눌리고 커서가 손가락이 되고 글자가 드래그로 잡히는 것은 그대로 남는다. 오너 눈에는 그것이 "동작하는 사이트"다. 마우스가 사이트 요소에 닿지 않아야 그 넷이 한꺼번에 사라진다.
유리 위에서는 mouseover가 처음 한 번만 오므로 mousemove로 다시 판정하고 elementsFromPoint()(그 좌표에 겹친 요소를 위에서부터 배열로 돌려준다)로 아래 요소를 찾는다. 프레임당 한 번으로 묶고, position: fixed라 문서 높이를 늘리지 않는다.
콘솔 쪽 토글도 붙였다. 범위 전환기는 짚기가 꺼져 있을 때 비활성으로 둔다. 고를 수 없는데 범위만 바꿀 수 있으면 거짓말이다. 초기값은 브릿지의 DEFAULT_MODE와 같은 'select'다. 다르면 첫 클릭이 다르게 군다.
프레임이 끝없이 자라던 이유
예전에는 브릿지가 알려준 문서 높이를 프레임에 줬다. 오버레이 좌표가 공짜로 맞는 대신 사이트의 vh가 망가졌다.
iframe 안의 vh/dvh는 그 iframe의 높이를 기준으로 풀린다. 문서 높이를 프레임에 주면 사이트가 자기 높이를 기준으로 자기 높이를 정하게 된다.
그 순환은 조용하지 않았다.
flowchart LR
A["히어로 min-height: 100dvh"] --> B["문서 높이 D"]
B --> C["프레임 높이 D"]
C --> D2["히어로 D"]
D2 --> E["문서 D + 아래 섹션"]
E --> B
실제로 편집 중에 프레임이 무한히 길어졌다.
그래서 프레임은 이제 뷰포트다. 방문자가 실제로 보는 한 화면이고, 그 안에서 사이트가 스스로 스크롤한다. vh는 다시 "화면 한 장"을 뜻하고, 편집기가 보여주는 것이 게시본과 같아진다.
대신 스크롤이 iframe 안으로 들어가므로 사이트가 자기 스크롤 위치를 알려야 한다. 사각형은 여전히 문서 좌표다. 그래야 스크롤 중에도 값이 흔들리지 않는다. 프레임당 한 번만 보낸다. 붙는 순간의 위치도 함께 보낸다. 이벤트만 보내면 처음 위치는 아무도 말하지 않는데, 브라우저는 새로고침에서 스크롤을 복원한다.
문서 좌표에서 프레임 좌표로 바꾸는 일은 frameBox 한 곳에서만 한다. 오버레이마다 빼면 한 곳이 빠지고, 그 하나가 스크롤할 때만 어긋난다.
아무도 안 읽는 값을 걷어냈다
프레임이 뷰포트가 되면서 문서 높이를 쓰는 곳이 사라졌다. 남은 것은 상태와 resize 핸들러뿐이었다. 계산해서 들고 있다가 아무 데도 안 쓰는 값이다.
브릿지가 보내는 것을 멈추는 일은 따로 뒤로 뺐고, 그 순서에 이유가 있다. 프로토콜에서 resize를 먼저 지우면 읽는 쪽의 case 'resize'가 컴파일되지 않는다. 지우는 변경에서는 읽는 쪽이 먼저다.
보내는 쪽을 멈추면서 실제로 사라진 비용도 있었다. 유저 사이트마다 리플로우가 날 때마다 문서 높이를 재고 postMessage를 쏘고 있었고, 콘솔은 그것을 받아 아무 데도 쓰지 않는 state에 넣었다. 남의 문서에 얹히는 스크립트라 이런 것이 그대로 비용이다.
편집 엔진이 오너의 사이트 하나를 겨누게 했다
편집 엔진을 오너의 실제 사이트 디렉터리에 연결하는 단계를 S2로 잡았다. 범위가 상수에서 값이 됐다. ALLOW_DIRS = ['src/site']에서 EditScope { root, allow }로. 디스크에는 사이트가 여러 개이므로 화이트리스트가 있어도 "어느 사이트"가 없으면 경계가 없다.
스냅샷이 자기 범위를 들고 다닌다. 되돌리기 엔드포인트는 사이트 id를 받지 않는다. 오너가 누르는 것은 "이 변경 되돌리기" 하나이고, 그 시점에 어느 사이트였는지는 스냅샷만 안다.
CLI의 cwd가 그 사이트 폴더다. --allowedTools는 경로 패턴까지 받을 수 있고, cwd는 기본 접근 범위를 좁히는 것이지 그 자체로 벽은 아니다. 그래서 사후 탈출 검사를 그대로 남긴다. 절대 경로 쓰기는 여전히 가능하다.
금지 목록은 따로 두지 않았다. 허용이 사이트 하나의 public/으로 좁아진 지금 그 밖은 전부 금지이고, 목록을 둘로 두면 둘이 어긋날 자리가 생긴다.
문구 가드도 정적 HTML로 다시 썼다. JSX 시절의 짝은 copy.X와 data-copy였고, 정적 HTML에서 짝의 상대는 요소의 텍스트다. 네 글자 미만 문구로는 판정하지 않는다. '문의'는 문서 어디에나 있고, 멀쩡한 삭제를 되돌리는 것이 짝을 한 번 놓치는 것보다 나쁘다.
편집이 끝나면 프레임을 다시 읽는다. 정적 파일이라 HMR이 없고 cross-origin이라 reload()도 막혀 있어, 부모가 쓸 수 있는 건 src를 다시 지정하는 쪽이다. 없으면 "수정했어요"와 그대로인 화면이 함께 남는다. 다시 붙을 때 선택은 놓는다. 새 문서는 방금 파일이 바뀐 문서이므로 낡은 윤곽이 엉뚱한 자리에 남는 것이 선택을 잃는 것보다 나쁘다.
"재시작하면 됩니다"는 답이 아니라 결함이다
브릿지 번들을 한 번 묶고 프로세스가 죽을 때까지 그것을 주고 있었다.
콘솔 코드는 Vite가 HMR로 갈아 끼우는데 브릿지만 옛것이 남아, 고친 것이 화면에 없는 상태가 반복됐다. 스크롤 통지가 안 오고, 모드 명령을 브릿지가 버렸다. 둘 다 코드는 맞았고 서버를 껐다 켜지 않았을 뿐이다.
소스 트리의 mtime으로 판정한다. 파일 몇 개의 stat이라 요청마다 재도 싸고, 여기서 필요한 것은 "바뀌었나"뿐이다.
문서만 400줄을 규약이라 말하고 있었다
ARCHITECTURE_ANALYSIS.md가 400줄을 규약으로 선언하고 있는데 ESLint는 꺼져 있었다. 문서가 그렇게 말하면 다음 사람은 켜져 있다고 믿는다.
켜기 전에 넘던 파일들을 전부 갈랐다.
| 가른 것 | 결과 |
|---|---|
| 브릿지 테스트 → 판정 / 모드 | 411줄이던 파일을 둘로 |
slot.ts → 모양·기하·문구·판정 | 482줄이던 파일을 넷으로 |
dev-ai.ts → 경계·말·프롬프트·시안·미들웨어 | 666줄이던 파일을 다섯으로 |
dev-ai 테스트 | 모듈 옆으로 이동 |
| 상단바·레일을 App에서 들어냄 | 1116 → 1002 |
| 오른쪽 편집 패널 | 1002 → 673 |
| 캔버스 영역 | 673 → 437 |
| 문구 이력·시안 첨부를 훅으로 | 437 → 337 |
가른 기준은 크기가 아니라 변경 이유다. slot.ts의 넷은 서로 다른 이유로 바뀌고, dev-ai.ts의 다섯도 그렇다. 특히 edit-report.ts(나가는 말을 깎는다)와 edit-prompt.ts(들어가는 말을 짓는다)는 방향이 반대라 한 파일에 두면 두 관심사가 섞인다.
App.tsx를 셋으로 가른 기준도 화면 위치가 아니었다. EditPanel은 선택한 것 하나에 대한 모든 것을, EditorTopbar는 사이트 전체에 대한 행동을, CanvasWorkspace는 오너가 사이트를 어떻게 보는지를 갖는다. 셋을 가르는 기준은 무엇에 대한 행동인가이지 화면 위치가 아니다.
props는 묶었다. 하나씩 풀면 EditPanel 28개, CanvasWorkspace 31개다. 그 수가 나오면 이것은 경계가 아니라 App의 상태를 그대로 복제한 통로다.
그리고 규칙을 켰다. skipBlankLines와 skipComments를 같이 켰다. ESLint max-lines는 이 둘로 빈 줄과 주석을 세는 줄에서 뺄 수 있다. 이 저장소는 주석에 결정을 적으므로, 세면 설명을 지우는 쪽으로 압력이 간다. 그것이 이 규칙이 만들 수 있는 가장 나쁜 결과다.
예외 목록은 두지 않는다. 목록을 만들면 목록이 규칙이 되고, 그 목록은 줄어들지 않는다.
문서의 예측도 함께 고쳤다. "S1b가 옮기며 저절로 줄어든다"고 적혀 있었는데 넷 다 더 커졌다(1246→1578, 580→991, 482→821, 409→698).
CI가 없었다
Claude 훅은 Claude가 쓴 파일에만 붙으므로 사람이 직접 편집해 커밋하면 아무것도 검사되지 않는다. 400줄 규칙이 오늘까지 한 번도 안 잡힌 것도 결국 같은 종류의 구멍이었다. lint·format·type-check·test 게이트를 세웠다.
자리 지정을 브릿지 너머로 넘겼다
캔버스를 iframe으로 갈아 끼우면서 "여기에 추가"가 토스트만 띄우게 된 이유는 단순하다. deepestContainer는 container.children을 걷고 readColumns는 getComputedStyle을 읽는데, 콘솔에는 그 문서가 없다. 자리 지정 판정을 브릿지로 옮기는 슬라이스를 S1b-2a로 잡았다.
계획을 세우며 정한 것 둘이 이 슬라이스의 모양을 정했다.
- 그리는 일은 콘솔에 남고 판정만 브릿지로 간다. 콘솔이 iframe 위에 얹는 레이어는 브릿지의 유리판보다 위이므로 드래그는 콘솔이 받는다. 배율 변환도 콘솔에 있고,
mousemove를postMessage로 흘리지 않아도 된다. - Measure 주입을 살려 둔다. jsdom(Node에서 DOM을 흉내 내는 라이브러리로, 레이아웃 계산은 하지 않는다)에는 레이아웃이 없어 그 포트가
slot.dom.test.ts426줄이 도는 유일한 이유다.
그리고 두 번째 결정이 검증 방법이 됐다. 옮긴 테스트가 새 자리에서 초록인 것이 이전이 맞았다는 증거다. slot.dom.test.ts 43건이 slot-resolve.dom.test.ts로 그대로 건너가 통과한다.
타입과 순수 계산이 먼저 건너가고(동작은 하나도 안 바뀐다), 프로토콜이 늘고, 판정이 옮겨갔다.
프로토콜에서는 판정한 컨테이너 요소 자체를 건네지 않는다. postMessage는 구조화 복제로 값을 복사해 보내는데 DOM 요소는 복제 대상이 아니라 아예 보낼 수 없고, 콘솔이 그것으로 하는 일은 열 안내선뿐이라 containerBox와 columns면 된다.
검증 함수 isPlacement는 kind로 갈라 갈래마다 필수 필드를 본다. kind만 보면 container 없는 flow 자리가 통과하고, 그때 판정은 오류 없이 빈 결과를 낸다. 오너 눈에는 "그렸는데 아무 일도 안 일어난다"로 보인다.
판정을 옮기고 나니 SlotDrawLayer에서 pageRoot와 measure가 사라졌다. 콘솔이 사이트 DOM을 만지는 마지막 자리였다.
그리고 손을 뗀 뒤의 규율도 정했다. 판정하지 못해도 답은 보낸다. 콘솔은 답을 기다리고 있고 침묵은 "영원히 판정 중"과 구분되지 않는다. 손을 떼면 한 번 더 물어보고 답을 기다렸다 확정한다. 직전 답으로 확정하면 마지막 몇 픽셀이 빠진 자리로 들어간다.
이걸로 "자리를 그려 새 영역을 추가한다"가 실제로 켜졌다. drawing = false as boolean이 state로 돌아왔다.
켜자마자 두 개가 터졌다
축소된 캔버스에서 사각형이 커서를 따라오지 않았다. 드로잉 레이어가 transform: scale() 안쪽에 있어서, getBoundingClientRect()가 주는 화면 픽셀을 레이아웃 픽셀로 나누지 않았다. 기본값이 맞춤(66%)이라 처음 그려 보는 순간 바로 드러난다. pointOf에서 한 번만 되돌린다. 그 뒤로는 같은 좌표계이므로 배율을 다시 생각하지 않아도 된다.
<main> 래퍼가 있는 사이트에서 겹치기 갈래가 사라졌다. 자리 판정은 페이지 루트의 직계 자식에서 섹션을 찾는데, 루트를 body로 고정해 두었더니 <main> 하나뿐이라 섹션을 못 찾았다.
템플릿 둘이 서로 다르다는 것이 이 함수가 필요한 이유다. studio는 body 밑에 섹션이 바로 있고, AI가 첫 편집에서 세우는 뼈대에는 <main>이 들어간다. data/sites/의 두 사이트가 정확히 그 둘이었다. slotPageRoot가 자식 하나뿐인 래퍼를 통과해 내려간다. 래퍼는 구조이지 경계가 아니다.
곁딸린 수정이 하나 더 있었는데 이쪽이 더 무섭다. slot-resolve.ts가 선택자를 만들 때 전역 document를 쓰고 있었다(5곳). 브릿지는 넘겨받은 창에서 돈다는 규율이 그 자리에서 깨져 있었다. 지금은 같은 문서라 티가 나지 않지만, 그 가정이 깨지는 날 선택자가 조용히 다른 문서를 가리킨다.
스펙에 남아 있던 "프로토콜 간극 셋"도 지웠다. 셋 다 이미 닫혀 있었는데 스펙이 따라오지 않았다. 남겨 두면 다음 사람이 없는 일을 한다.
문구 편집은 이전이 아니라 재작성이었다
자리 지정은 이전이었지만 문구 편집은 재작성이었다. 고칠 객체가 화면에 없어서, 저장 경로와 입력 방식이라는 제품 결정이 먼저였다. 이 슬라이스가 S1b-2b다.
계획서를 쓰기 전에 "서버가 HTML의 글자를 어떻게 바꾸는가"를 실제로 재 봤다. dom.serialize()는 파일 전체를 다시 쓴다. 155줄이 145줄이 되고 모든 줄이 달라진다. 그 파일은 AI도 고치는 파일이라 diff가 무의미해진다.
그래서 includeNodeLocations: true로 파싱한다. 이 옵션을 켜면 파싱한 노드가 원본 소스의 어느 구간에서 왔는지 알 수 있고, dom.nodeLocation(node)가 원본의 문자 오프셋 범위를 준다. 그 구간만 잘라 붙이면 고친 글자 자리만 바뀐다.
CSS 선택자를 그대로 쓸 수 있는 것이 이 방법의 값이다. data-copy 표식만 정규식으로 찾으면 표식 없는 요소는 영영 못 고친다.
테스트가 검사하는 방식도 그에 맞췄다. "글자가 바뀌었나"와 "바뀌지 않아야 하는 것이 그대로인가"를 같은 무게로 검사한다.
거절하는 셋을 골랐다.
- mixed —
<p>안녕 <b>거기</b></p>의 "그 문구"는 하나로 정해지지 않는다. 반만 바꾸는 것이 못 바꾸는 것보다 나쁘다. - missing — 문법이 틀린 선택자도 여기다.
querySelector가 던지면 dev 서버가 500으로 죽는데, 선택자는 브라우저에서 온 값이다. - empty — 지우는 것은 편집이 아니다.
오너가 친 글자는 <, >, &를 이스케이프한다. 여기서 새면 그 스크립트가 게시본에 실려 나간다.
새 안전 경계를 만들지 않는다
/api/text는 AI를 부르지 않는다. 오너가 친 문자열이 그대로 들어간다. AI 경로를 쓰면 호출 비용과 몇 초가 들고, 모델이 문장을 다듬어 버리면 오너가 친 그대로가 아니다.
그러면서도 siteScope·isAllowed·takeSnapshot을 /api/edit과 똑같이 쓴다. 화이트리스트를 두 벌 두면 둘이 어긋나고, 그 틈으로 남의 파일이 열린다.
그 덕에 되돌리기가 한 경로가 됐다. 되돌리기는 편집의 종류가 아니라 파일의 상태를 다룬다. 스냅샷은 바꿀 수 있다고 판정한 뒤에 뜬다. 거절한 편집이 이력을 늘리면 오너는 되돌릴 것이 없는 항목을 보게 된다.
테스트가 구멍 하나를 잡았다. page로 ../public/index.html을 보내면 정규화되어 같은 파일로 돌아온다. 탈출은 아니지만 한 파일에 이름이 둘 생기고, 그 모호함이 나중에 "왜 두 번 스냅샷이 떴지"로 나온다.
브릿지가 지금 적힌 글자를 알려준다
read-text / text-read 한 쌍을 더했다. 오너가 문구를 고치려 할 때만 묻는다.
BridgeSelection에 글자를 얹지 않은 이유는 이렇다. picked가 보내는 것은 조상 사슬 전체라, 거기에 텍스트를 실으면 클릭 한 번에 문서의 글자가 여러 벌 건너온다.
editable 판정은 브릿지가 한다. DOM을 봐야 하고 콘솔에는 그 문서가 없다. 서버의 loneTextNode와 같은 규칙이고 양쪽 주석이 서로를 가리킨다. 갈라지면 "고칠 수 있다"고 해놓고 저장에서 422로 거절당한다.
없는 선택자에도, 못 고치는 자리에도 답은 보낸다. 침묵은 "영원히 읽는 중"과 구분되지 않는다.
화면에 붙이고 나니 죽은 코드가 드러났다
영역을 고르면 브릿지에 글자를 묻고, 오너가 고쳐 적용하면 /api/text로 보낸다. TextEditor가 상태 셋을 다르게 보여준다. 읽는 중 / 고칠 수 있다 / 고칠 수 없다. 셋을 구별해 주지 않으면 오너는 "왜 입력란이 없지"와 "아직 안 떴나"를 구분할 수 없다.
refuse('문구 편집은 사이트 프레임으로 옮기는 중이에요')가 사라졌다. 이제 된다.
도달 불가가 된 갈래를 지우니 pushCopy가 미사용이 됐다. 그 뜻이 뼈아프다. 되돌리기 이력에 쌓는 곳이 하나도 없다. 유일하게 쌓던 곳이 방금 지운 그 갈래였다. 상단바의 되돌리기·다시 실행은 늘 비활성이고, 거짓말은 아니지만(되돌릴 것이 정말 없다) 이 이력을 파일 스냅샷에 붙이는 것이 다음 결정이다.
죽은 코드를 지우면 그것이 유일한 소비처였던 다른 코드가 드러난다. 오늘은 그게 제품 기능이었다.
오늘 두 가지 반대 방향의 일을 동시에 했다. 콘솔이 사이트 DOM에서 손을 완전히 떼는 일과, 그렇게 뗀 뒤에 기능을 다시 켜는 일.
떼는 쪽에서 배운 건 이거다. 손을 떼면 침묵이 새로운 실패 모드가 된다. 전에는 콘솔이 직접 querySelector를 했으니 결과가 없으면 null이었다. 지금은 물어보고 기다리는데, 답이 안 오면 그것은 null이 아니라 "영원히 판정 중"이다. 그래서 브릿지 쪽 여러 곳에 같은 문장이 들어갔다. 판정하지 못해도 답은 보낸다. 없는 선택자에도, 못 고치는 자리에도 답은 보낸다.
경계를 세우는 값은 세는 게 쉽다. 세션이 안 샌다, 게시본이 가볍다. 대가는 세기 어렵다. 동기 호출이 비동기 대화가 되면, 없던 상태가 하나 생긴다. "아직 모른다." 그 상태를 화면이 말할 수 있게 만드는 것까지가 경계를 세우는 일이었다.
400줄을 켠 것도 같은 날인 게 우연은 아닌 것 같다. App.tsx 1116줄 안에서는 CanvasScaler도 EditorBreadcrumb도 테스트할 수 없었고, 테스트할 수 없는 것은 조용히 틀린다. 손을 떼려면 먼저 손이 닿는 자리를 세어야 했다.