pubuilder 11. standalone 번들과 builtin 스킬 전환, 퍼블리싱 되돌리기
pubuilder는 React 앱 안에 오버레이를 띄우는 라이브러리다. 진입점 자체가 React 앱을 전제하고 있어서, 번들러도 React도 없는 사이트에서는 켤 방법이 없었다. 이날은 그 전제를 걷어내는 것부터 시작했다.
번들러도 React도 없는 사이트에서는 아예 켤 수 없었다
React와 react-dom을 함께 감싼 IIFE(즉시 실행 함수로 감싸 전역에 객체 하나만 노출하는 번들 포맷) 파일을 따로 만들었다. <script>로 넣고 전역 함수를 호출하면 라이브러리 경로와 동일한 오버레이가 뜬다.
<script src="dist/standalone.js"></script>
<script>
window.pubuilder.mount({ pages });
</script>중복 mount 방어와 unmount()를 같이 뒀다. iframe 안에서는 마운트하지 않도록 막았다. 페이지 썸네일을 iframe으로 띄우고 있어서, 그 안에서 또 마운트되면 재귀가 된다.
번들에서 걸린 건 개발 환경 판정이었다. 번들 과정에서 NODE_ENV가 production으로 치환되므로 standalone 경로에서는 항상 프로덕션으로 굳었다. isProductionEnv 판정을 덮는 override를 뒀다.
프로젝트 감지 쪽도 손봤다. 컴포넌트 프레임워크 의존성이 없고 .html 파일이 실재하면 Vanilla HTML/CSS로 감지해, 발견한 페이지 목록과 스타일 시스템(Plain CSS / Sass)을 프롬프트에 싣는다. react/react-dom peer dependency는 optional로 표기했다. standalone 경로에서는 필요 없기 때문이다.
프레임워크에 기대지 않는 경로를 하나 내니 라이브러리가 그동안 무엇을 전제하고 있었는지가 한꺼번에 드러났다. React, 번들러, 그리고 오버레이 격리 가정이었다.
전역 CSS를 과감하게 쓰는 사이트에서 오버레이가 깨졌다
React 앱에서는 문제가 없던 스타일이 순수 HTML 사이트에서 무너졌다. 호스트가 body와 태그 셀렉터에 전역 스타일을 직접 걸어두면 그게 오버레이까지 상속되고 새어 들어온다.
오버레이 루트([data-pubuilder])에 방어선을 세웠다. all: initial(그 요소의 모든 CSS 속성을 초기값으로 되돌리는 선언, 상속받은 값까지 끊는다)로 호스트 body 상속을 잘라내고, 폼 요소와 svg·img에 호스트 태그 셀렉터가 닿지 않도록 최소 규칙을 걸었다.
여기까지가 한계다. 호스트가 !important로 박아둔 규칙은 all: initial로도 못 막는다. 완전한 격리는 Shadow DOM으로 올려야 가능하다.
서버가 조용히 채워주던 슬롯 기본값을 걷어냈다
skills/_defaults/*.md를 지우고 같은 내용을 skills/<name>/SKILL.md builtin 스킬로 올렸다. React/Next.js용 5종에 순수 HTML/CSS용 5종(framework-html-css, styling-html-css, design-system-html-css, icon-html-css, image-html-css)을 더해 번들로 제공한다.
서버가 기본값을 조용히 끼워 넣는 편의는 사용자에게 지금 무엇이 적용됐는지를 감춘다. 그래서 첫 실행에는 슬롯이 비어 있고, 조립 영수증의 상태도 장착 / 비어 있음 둘로 줄였다. 기본 상태를 없앴다.
비어 있음을 그대로 보여주는 대신 한 번에 채울 수단을 줬다. 스킬함에서 감지된 프로젝트에 맞는 스킬에 권장 배지를 붙이고, 권장 세트 장착 한 번으로 5개 슬롯을 함께 채운다.
덤으로 새 기본 스킬을 추가하는 비용도 내려갔다. 기본값이 _defaults 파일과 로더 조합에 묶여 있어서 스킬 하나를 늘리려면 서버 코드를 같이 고쳐야 했는데, 이제 서버는 장착된 것만 조립한다. 대신 시그니처가 바뀌었다. loadCoreAndDefaults()는 loadCore()가 됐고, assembleSlots({ equipped })는 defaults 인자를 받지 않는다.
[확인 필요] 마커를 첫 줄에서만 찾다가 확인 요청을 완료로 오분류했다
에이전트가 되묻는 [확인 필요] 응답을 질문 한 줄과 선택지로 파싱해서 confirm 이벤트에 question·choices로 실었다. 문제는 그 마커를 응답의 첫 줄에서만 찾고 있던 것이다. 에이전트가 구현에 들어가기 전에 [현재 구현] 요약을 먼저 출력하면 마커가 첫 줄에서 밀려나고, 확인 요청이 완료로 분류됐다.
단독 줄로 놓인 마커를 위치와 무관하게 찾도록 바꿨다. 마커 기반 프로토콜에서 위치를 고정하면 프롬프트가 조금만 풍부해져도 깨진다. 위치가 아니라 형식만 규정하는 편이 견고했다.
선택지 UI는 두 단계로 나눴다. 선택지를 고른 뒤 이 선택으로 진행을 눌러야 실제로 전송된다. 버튼 하나로 바로 나가면 오클릭 한 번이 그대로 실행으로 이어진다. 직접 입력 같은 메타 선택지는 버튼에서 제외하고, 프롬프트에서도 생성하지 못하게 했다.
Figma 링크 없이 자연어 지시만으로 수정하는 direct 모드용 코어 지침 _core/direct-head.md와 direct-tail.md도 추가했다. "지시 범위 밖은 건드리지 않는다"를 명시적으로 강제하는 문서다. 코어 지침에는 주변 코드 컨벤션을 먼저 파악하는 단계(§0), 렌더 결과를 소스 구조로 오해해 값을 하드코딩하지 말라는 금지, 퍼블리싱 전에 소스 분석을 출력하라는 요구를 넣었다. 커지고 있던 파일도 갈랐다. Claude CLI 어댑터, Design IR(Figma 노드를 퍼블리싱용 중간 표현으로 정규화한 데이터) 타입, 슬롯 메타 정의를 각각 별도 파일로 뺐다.
되돌리기는 스냅샷 복원이 아니라 그 사이 누가 무엇을 바꿨는지의 문제였다
완료 카드가 이 실행이 편집한 파일 수와 목록을 보여주고, 이 실행 되돌리기 버튼으로 job 시작 시점 스냅샷으로 복원한다. 다만 복원을 그냥 밀어버리면 그 사이 사용자가 손댄 것까지 날아간다. POST /api/publish/:id/revert가 파일별로 자동 복원 · 충돌 · 롤백 불가를 분류하게 했다. 되돌린 뒤 사용자가 직접 편집했거나 이후 실행이 같은 파일을 덮었으면 자동 복원하지 않고 기존 파일 단위 충돌 화면으로 넘긴다. 진행 중인 job에 대한 요청은 409로 거절한다.
적용 버튼은 두지 않았다. 에이전트가 워킹트리에 직접 쓰기 때문에 따로 적용할 대상이 없다. 대신 tool 이벤트를 SSE로 내보낼 때 프로젝트 루트 상대경로를 함께 실어, 실행 중에도 어떤 파일이 만져지는지 보이게 했다.
요청 본문도 넓혔다. POST /api/publish에 mode(figma 또는 direct)와 followUp을 추가하고, 패널에 Figma 링크와 AI 지시란을 함께 노출했다. 채운 조합에 따라 동작이 결정된다. mode·followUp이 없는 구버전 본문에서만 instruction을 후속 답변으로 해석해 하위 호환을 유지했다. 슬롯 구성 안내는 첫 실행을 막는 게이트에서 스킬함 한 줄 안내로 옮겼다. 패널 스크롤과 진행 로그 자동 따라가기가 어긋나던 것도 같이 고쳤다.
1000줄 넘은 CSS 문자열을 새 화면 얹기 전에 갈랐다
단일 CSS 문자열로 들고 있던 styles.ts가 1000줄을 넘겼다. foundation·canvas·panel·drawer·skill-inventory·skill-connections·skill-detail 7개 모듈로 나누고, styles.ts는 조립과 <style> 주입만 담당하게 했다. PascalCase로 섞여 있던 컴포넌트 파일명은 kebab-case로 통일하고 import 경로를 맞췄다. playground도 같은 규칙으로 옮기면서 단일 styles.css를 base·chat·detail·my-run으로 나눴다. IA 트리 레이아웃에는 유닛 테스트를 붙였다.
1000줄 CSS 문자열과 파일명 혼용 같은 누적 부채는 기능을 얹기 직전이 정리 적기였다. 새 화면을 그 위에 쌓기 전에 갈라뒀다.
블록 라벨이 형제 요소 글자와 뭉쳐 읽을 수 없었다
블록 라벨을 textContent로 그대로 뽑고 있었다. 그러면 형제 요소의 글자까지 한 덩어리로 붙어 나와서 무엇을 가리키는 라벨인지 알 수 없다. 자식 단위로 끊어서 전체 · Coding · Research 외 2개 형태로 요약하도록 바꿨다. 태그명을 그대로 노출하던 블록 칩도 제목 · 버튼 · 목록 · 영역처럼 사람이 읽는 표기로 바꿨다.
호스트의 React 커밋 도중에 root.unmount()를 호출했다
오버레이 root를 정리하는 effect cleanup이 호스트 앱의 React 커밋 단계에서 root.unmount()를 동기 호출해 "Attempted to synchronously unmount a root while React was already rendering" 경고가 났다. StrictMode가 effect를 두 번 호출하기 때문에 dev에서는 페이지를 로드할 때마다 재현됐다.
unmount와 호스트 노드 제거를 queueMicrotask로 감쌌다. 현재 실행 중인 작업이 끝난 직후에 콜백을 돌리므로, 정리 작업이 React 커밋 밖으로 밀린다. 이 수정은 v0.9.9로 나갔다.
지원 범위와 사용자 통제권을 같이 넓힌 날이었다. 순수 HTML/CSS 사이트가 대상에 들어왔고, 무엇이 장착됐는지와 무엇을 되돌릴 수 있는지가 화면에 드러나게 됐다. standalone 번들과 builtin 스킬 전환, 확인 필요 UI, direct 모드는 v0.9.8로 배포했다.
아직 손대지 못한 것들이 있다. @xyflow/react 의존성이 실제로 쓰는 범위가 좁은지 확인해 자체 구현으로 대체할지 봐야 한다. 배포 타입 dist/types.d.ts가 비공개 워크스페이스 패키지를 참조하는지도 점검해야 한다. 노드가 많은 맵에서 라이브 iframe 썸네일의 메모리·CPU 비용은 아직 측정하지 않았다. standalone 경로의 오버레이 격리를 Shadow DOM으로 올릴지는 정하지 못했다.