본문으로 건너뛰기

Pointly 02. 컨벤션 린트 고정과 CSS 488줄 정리

·15 min read·2 / 2

Pointly의 아키텍처 경계는 FOLDER_STRUCTURE.md에 문장으로 적혀 있었다. 엔진 스코프는 React를 값으로 가져오지 않는다, 체험 랜딩은 AI 공급자를 참조하지 않는다 같은 것들이다. 이날은 그 문장들을 ESLint 규칙으로 옮기고, 크롬에 아직 남아 있던 CSS 488줄을 Tailwind 유틸리티로 옮겼다. 로그인 없이 바로 만져보는 체험 랜딩 /preview도 같이 붙였다.

체험 랜딩의 AI 호출은 0으로 못 박았다

Pointly는 "클릭하고 말로 적으면 바뀐다"가 전부인 제품인데, 그걸 설명하려면 문장이 세 줄 필요하다. 그냥 만져보게 하는 게 빠르다.

/preview에 예시 사이트를 놓고, 방문자가 아무거나 클릭하고(클릭할수록 더 깊이 들어간다), 요청을 고르거나 직접 적고, 게시까지 해 볼 수 있게 했다.

절대 규칙 하나를 뒀다. 이 페이지의 AI 호출은 0이어야 한다. 모든 요청은 준비된 데이터에 대한 로컬 정규식으로 해석한다. 로그인도 없는 공개 페이지가 AI 공급자를 호출하면 그건 그냥 누구나 쓸 수 있는 무료 API다.

세션을 쿠키로 옮기면서, 처음 방문한 로그아웃 사용자는 로그인 폼이 아니라 /preview로 보낸다. 아직 계정이 없는 사람에게 제일 먼저 보여줄 화면이 입력칸 두 개일 이유가 없다.

없는 주소에는 404 화면도 붙였다.

문서에 적힌 규칙은 어겨도 아무 일이 안 일어났다

FOLDER_STRUCTURE.md에 적어두고 주석으로 당부하던 불변식들을 ESLint 규칙으로 옮겼다.

경계로 잡은 것:

  • 엔진 스코프(src/editor)는 React를 값으로 import 못 하고(타입만 가져올 수 있다), 제품 UI를 참조 못 한다
  • 체험 랜딩(src/try)은 AI 공급자를 참조 못 한다 — 위에서 말한 "호출 0"을 문장이 아니라 규칙으로
  • 문서 스코프(src/site)와 편집기 크롬(편집기 화면을 감싸는 패널·툴바 UI)은 서로 격리
  • pubuilder 소스 직접 참조 금지

컨벤션으로 잡은 것도 있다. default export 금지, 인라인 타입 import, export function 컴포넌트, 스코프를 넘는 import는 @/. 여기에 .dom.test.*의 jsdom docblock 강제는 자체 규칙을 하나 짜서 넣었다. jsdom docblock은 파일 맨 위 주석에 @vitest-environment jsdom을 적어 그 테스트만 DOM 환경에서 돌리게 하는 표시다.

규칙이 켜지자마자 실제 위반이 나왔다.

  • src/editor/thumbnails.ts가 엔진 스코프에서 React 훅을 값으로 import하고 있었다. FOLDER_STRUCTURE.md에 이미 "캡처 함수와 훅은 나눈다"라고 적혀 있었는데, 적혀만 있었다.
  • 스코프를 넘는 ../ import 8곳
  • server/dev-ai.ts: JSON.parseany를 신뢰 경계에서 좁히고, Connect 미들웨어에 넘기던 async 핸들러를 guarded()로 감쌌다. guarded()는 async 핸들러를 감싸 rejection을 붙잡아 에러 핸들러로 넘겨주는 래퍼다. rejection이 dev 서버를 소리 없이 죽이고 있었다.
  • src/App.tsx: Cmd+Enter 경로에서 await 없이 버려지던 Promise에 void를 붙였다

문서에 적힌 규칙은 지켜지지 않아도 아무 일이 안 일어난다. 린트로 옮기면 안 지킨 순간 빨간 줄이 뜬다. 이날 나온 위반들은 전부 문서에 이미 적혀 있던 것이었다.

Prettier를 도입했다가 같은 날 절반 물렀다

린트 작업에서는 "Prettier는 도입하지 않는다"라고 적었다. printWidth 기반 재포매팅이 손으로 묶은 import와 긴 한국어 문자열을 헤집을 것 같았고, 합의된 축만 @stylistic으로 강제하려 했다.

그러다 같은 날 뒤집었다. 이유는 단순하다 — 포매팅 담당이 둘이면 --fix가 서로를 되돌린다. @stylistic 층을 걷어내고 Prettier만 남겼다.

printWidth는 110. 그냥 고른 게 아니라 세어 봤다: 90에서 361줄, 100에서 206줄, 110에서 124줄, 120에서 74줄이 넘친다. 한국어 본문은 어차피 문자열 안이라 잘리지 않으니, 그 아래에서 실제로 재배치되는 코드 줄만 세어 고른 값이다.

Prettier 대상에서 뺀 경로도 있다. shadcn 소유 경로는 더블 쿼트와 907자 클래스 문자열이 그쪽 포매터의 결과물이라, 다시 쓰면 shadcn add마다 diff가 난다. 마크다운은 손으로 줄 맞춘 한국어 산문이다.

실제 재포맷은 커밋을 따로 떼어내고, .git-blame-ignore-revs에 등록했다. git blame이 특정 커밋을 건너뛰고 그 앞의 진짜 변경을 보여주게 만드는 목록 파일이다. 검증 결과도 같이 적어뒀다: prettier --check 통과, eslint 0 error, tsc 통과, 테스트 190개 통과.

한 줄로 압축해둔 CSS는 그 자체가 신호였다

재포맷 결과에서 CSS가 제일 크게 움직였다. 이런 리셋 블록들이 원인이었다.

.demo-site ul { margin: 0; padding: 0; list-style: none; }

한 줄로 압축해 둔 블록이 3줄씩 펼쳐졌다. 그런데 한 줄이라는 것 자체가 "훑고 지나갈 블록"이라는 신호였다. Prettier 도입 때 논의한 건 TS 쪽(import 묶음, 긴 한국어 문자열)뿐이었고 CSS는 애초에 범위에 없었다. .prettierignore*.css를 넣고 되돌렸다.

포매터 도입은 되돌릴 수 있게 커밋을 쪼개 놓는 게 절반이다.

bg-void는 에러 없이 아무것도 하지 않았다

--voidindex.cssstyles.css 양쪽에 정의돼 있었고, @theme에는 --color-void 매핑이 없었다. @theme은 Tailwind v4에서 CSS 변수를 유틸리티 클래스로 노출하는 블록이다.

Tailwind v4는 모르는 이름을 쓴 유틸리티를 에러 없이 무시한다. bg-void는 클래스로는 통과하고, CSS로는 아무것도 하지 않았다.

PaperField.tsx는 이미 var(--void)를 임의값으로 직접 써서 우회하고 있었다. 우회가 있다는 건 누가 이미 부딪혔다는 뜻이다.

  • 색 토큰 정의 자리를 src/index.css:root, .dark 하나로 모음 (중복 11개 제거)
  • @theme--color-void, --color-line, --color-line-soft, --color-point-soft 추가
  • scripts/check-theme-tokens.mjs 추가 (pnpm lint:tokens) — 정의된 색 토큰에 @theme 매핑이 있는지, 그리고 우리 토큰 이름을 쓰는 유틸리티가 실제로 해석되는지 양방향으로 검사한다. 이름이 우리 토큰과 겹칠 때만 보므로 border-t·bg-gradient-to-r 같은 기본 유틸리티를 오탐하지 않는다.
  • Tailwind 스캔 범위를 source("../src")로 좁힘. source()는 Tailwind v4가 클래스 이름을 찾아 훑을 경로를 지정하는 지시자다. 기본값이 저장소 전체여서 README의 "static business site"에서 .static이 유틸리티로 생성되고 있었다.

빌드된 CSS를 변경 전후로 대조해서 값이 달라진 선언이 없음을 확인했다 (중복 15개 제거 + 별칭 5개, 나머지 3,400여 조각 동일).

크롬 CSS 488줄을 유틸리티로 옮겼다

크롬은 Tailwind + shadcn으로 짜기로 했는데 src/components/*.css가 488줄 남아 있었다. 두 방식이 섞이면서 위의 --void 이중 정의와 bg-void 무효화가 생긴 거였다. 세 파일을 순서대로 옮겼다.

site-map.css는 139줄이었다. 값이 다른 곳은 임의값으로 정확히 맞췄다. --r-sm은 5px인데 rounded-sm은 4px이고, ease는 Tailwind의 ease-in-out과 다르다. var(--s2, 6px)의 폴백은 거짓이었다 — --s2는 8px이다. 카드 본문 배경(#f2eee6)은 크롬 토큰으로 바꾸지 않았다. 그게 "이건 고객 문서다"라는 신호니까.

flow-canvas.css는 110줄에서 3규칙으로 줄었다. 처음엔 팬/줌 기하 전체를 CSS로 남길 계획이었는데, 실제로 남아야 하는 건 FlowCanvas.tsxclassList로 쓰는 이름들뿐이었다.

Tailwind v4는 스타일을 @layer로 나눠 넣는다. CSS 캐스케이드 레이어에서는 선택자 명시도보다 레이어 순서가 먼저 승패를 갈라서, 뒤에 오는 유틸리티 레이어가 components 레이어를 이긴다.

  • .pnt-flow / .pnt-flow.pnt-flow-panning (grab↔grabbing) — 이 짝은 쪼개면 안 된다. grab만 cursor-grab으로 올리면 유틸리티 쪽이 이겨서 grabbing이 절대 적용되지 않는다. 복합 선택자라 명시도(선택자가 얼마나 구체적인지 계산한 점수)가 더 높아도 레이어에서 먼저 진다.
  • .pnt-flow-world-moving — 팬/줌 중에만 GPU가 따로 처리하는 별도 레이어로 뺀다. 상시로 걸면 확대해도 처음 래스터한 텍스처를 재사용해서 썸네일이 뭉개진다.
  • pnt-flow-hud는 스타일 없는 마커로 남김 — closest('.pnt-flow-hud')가 HUD 위 제스처를 팬에서 제외하는 데 쓴다.

path의 transform은 [transform:translate(4000px,4000px)]로 옮겼다. translate-x-[4000px]transform이 아니라 별개의 translate 속성을 쓰므로 SVG에서 같다고 보장할 수 없다. JS는 한 줄도 안 고쳤다.

editor-panel.css는 132줄에서 0이 됐다. 피드·확인 카드·지도 오버레이는 전부 App.tsx 한 곳에서만 쓰여서 남길 JS 계약이 없다. 파일과 @import를 같이 지웠다. 피드 색은 CSS에서 .feed-done p 같은 후손 선택자로 걸려 있었다. 유틸리티는 색을 쓰는 요소에 직접 붙어야 하니, 종류에서 색으로 가는 대응을 FEED_TONE 한 곳에 모아 <p>에 붙였다.

죽은 선언 세 벌도 찾아서 버렸다: .feed-done b, .feed-error b, .feed-confirm b. <b>는 도구 진행 줄({count}건)에만 렌더되므로 이 세 규칙은 적용된 적이 없다.

조심한 곳: :disabledcursor: defaultdisabled:cursor-default로 남겼다. 지우면 base 레이어의 button:disabled { cursor: not-allowed }가 드러나 커서가 슬쩍 바뀐다.

세 작업 모두 컴파일된 CSS를 선언 단위로 대조해서 검증했다. 간격, transform-origin, 색까지 맞춰 놓고 옮겼다.

그 밖에 손댄 것들

  • three·gsap·phosphor 의존성 추가, vitest 셋업 연결
  • 누를 수 있는 요소에 포인터 커서 지정 — 체감 차이는 이게 제일 컸다
  • 디바이스 상수·문구 변형 테이블을 모듈로 분리
  • 계기판 반응형 레이아웃과 모바일 요청 시트
  • 사이트 디자인 토큰 상태 design.ts 추가
  • 로그인 화면 모션·레이아웃 모듈 분리 — PaperField, Reveal, Wordmark, motion.ts, layout.ts

오늘 고친 것들은 전부 "조용히 아무 일도 안 일어나는" 종류였다.

  • 문서에 적힌 규칙은 어겨도 아무 일이 안 일어난다
  • bg-void는 모르는 토큰이어도 에러 없이 넘어간다
  • async 핸들러의 rejection은 dev 서버를 소리 없이 죽인다
  • .feed-done b는 적용된 적이 없는데도 계속 파일에 있었다

에러는 눈에 띄어서 고쳐진다. 조용한 실패는 검사기를 따로 만들어야 잡힌다. 그래서 오늘 늘어난 코드 중 제일 쓸 만한 건 check-theme-tokens.mjs 115줄이다.