Commit Graph
152 Commits
Author SHA1 Message Date
DongHyeonkaandClaude Opus 5 ad8f322b43 fix: 질문 머리말이 관계에 담긴 프로젝트를 읽는다
질문 상세는 프로젝트를 `question` 이 아니라 `relations.primaryProject` 에 담는다 —
Case/Reference 와 다른 자리다. `question` 에서 찾고 있었으므로 머리말의 프로젝트 칸이 늘
비어 있었다.

그 자리의 값은 `RelatedEntry` 라 `title`/`path` 를 쓴다. 머리말이 기다리는 것은
`name`/`slug` 이므로 옮겨 준다 — slug 는 경로의 마지막 마디다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 22:56:21 +09:00
DongHyeonkaandClaude Opus 5 ab4d822956 fix: 게시된 Open Question 의 공개 상세가 열리게 한다
게시한 질문의 공개 상세가 「요청을 처리하지 못했습니다」만 띄웠다. 게이트웨이가 `points`
를 `{group, items}` 배열로 읽고 `.filter` 를 불렀는데, 계약의 `QuestionPointGroup` 은
`facts`/`assumptions`/`unknowns`/`constraints` 를 키로 갖는 객체다. 객체에는 `.filter` 가
없으니 매핑이 통째로 터졌다.

목록은 이 칸들을 빈 배열로 두고 만들기 때문에 탐색에서는 멀쩡히 보였다. 그래서 "게시했는데
public 에 안 뜬다" 로만 드러났고 어느 층이 깨졌는지는 보이지 않았다. `as` 캐스트가 그
어긋남을 타입 검사에서 가렸다 — 이제 계약의 타입을 그대로 써서 모양이 바뀌면 컴파일이
먼저 막는다.

관계도 같은 종류로 어긋나 있었다. 계약이 주는 이름은 `resultCase`/`producedDecision`/
`derivedReferences` 인데 매퍼는 `derivedCases`/`projectDecisions`/`relatedQuestions` 를
찾고 있었고, 하나도 맞지 않아 이유 자리에 영문 키가 그대로 나왔다. `primaryProject` 는
관계가 아니라 이 질문이 속한 프로젝트이므로 관계 목록에서 뺀다 — 머리말이 이미 보여 준다.

이 사고가 지나간 이유는 HTTP 게이트웨이의 질문 상세 매핑을 지나는 테스트가 없었기
때문이다. 화면 테스트는 정적 픽스처 어댑터를 쓰므로 계약 모양을 한 번도 통과시키지
않는다. 계약 모양 그대로의 응답을 진짜 게이트웨이에 넣고 네 칸이 채워져 나오는지 묻는
테스트를 넣는다 — 되돌려 보면 운영에서 난 것과 같은 `points.filter is not a function`
으로 실패한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 22:50:14 +09:00
DongHyeonkaandClaude Opus 5 344a163d84 fix: 탐색 주제 필터가 slug 를 보내고, 편집기를 넓혀 두 칸이 함께 스크롤한다
주제 필터를 걸면 「조건에 맞는 공개 기록이 없습니다」만 남았다. 선택지가
`<option>{이름}</option>` 이라 값이 없어 이름이 그대로 나갔고 — `topic=OAuth/OIDC 인증
경계` — API 는 slug 로 거르므로 0건을 돌려줬다. 프로젝트 선택지는 처음부터
`value={slug}` 였고, 그래서 프로젝트만 멀쩡했다. 주제도 같은 모양으로 맞춘다.

테스트가 이 결함을 통과시킨 이유는 픽스처의 주제 이름이 `JPA`, `Authentication` 처럼
slug 와 구분되지 않는 값이어서다. 이름과 slug 가 다른 값을 쓰는 운영에서만 드러났다.
선택지의 값이 slug 인지 직접 묻는 단언을 넣는다.

`RecordFilters.topic` 은 어댑터마다 뜻이 달랐다. 정적 어댑터는 이름으로, HTTP 어댑터는
그 값을 그대로 API 에 넘겨 slug 로 걸렀다. 프로젝트가 이미 slug/제목 둘 다 받는 것과
같이 주제도 둘 다 받게 해서 두 어댑터가 같은 값을 이해하게 한다. 주제 페이지도 이름
대신 경로의 slug 로 묻는다.

「전체」를 고른 칸은 조건이 아니다. 빈 값까지 실어 보내고 있었고, URL 이 지저분해질 뿐
아니라 이 값을 그대로 API 에 넘기는 화면에서는 `topic=` 이 "slug 가 빈 문자열인 주제"로
해석되어 0건이 된다.

편집기는 미리보기를 붙박이로 두고 자체 스크롤을 줬다. 편집기를 내려도 미리보기는
제자리였고, 보려면 그 안을 따로 굴려야 했다 — 나란히 둔 이유가 둘을 같이 보는 것인데
움직임이 갈라지면 그 이점이 없다. 둘 다 페이지 스크롤을 그대로 타게 한다.

폭도 넓힌다. `.studio-main` 은 모든 Studio 화면이 1180px 를 함께 쓰는데, 본문 두 벌이
들어가야 하는 이 화면에서는 한 칸이 566px 였다. 편집기가 놓인 경우에만 1600px 로
넓히고 헤더도 같이 넓혀 좌우 끝을 맞춘다. 다른 화면은 그대로다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 17:30:11 +09:00
DongHyeonkaandClaude Opus 5 60c8c82097 fix: 편집과 미리보기를 한 화면에서 보고, 저장·게시를 아래에 고정한다
편집기는 탭이었다. 고친 것이 어떻게 보이는지 확인하려면 편집하던 자리를 화면에서
치워야 했고, 돌아오면 스크롤 위치도 잃었다. 두 패널을 나란히 두면 그 왕복이 통째로
없어진다.

작업 상태와 저장·게시는 오른쪽 세로 rail 이었다. 그 자리는 편집기와 미리보기가 함께
쓸 가로 폭을 가져갔고, 편집 칸이 길어질수록 rail 은 위에 붙은 채 본문만 멀어졌다.
화면 아래에 고정하면 폭을 돌려주면서 스크롤 위치와 무관하게 손이 닿는다.

탭을 없앴으므로 각 패널이 스스로 이름을 가져야 한다. `aria-labelledby` 로 제목을
가리켜 landmark 로 만든다 — 탭 목록이 하던 "여기는 편집, 저기는 미리보기" 안내를
대신한다. `aside` 와 「작업 상태」라는 이름은 그대로 둔다. 자리가 바뀐 것이지 이
묶음이 무엇인지가 바뀐 것은 아니다.

미리보기가 늘 떠 있게 되면서 Picker 테스트의 단언도 함께 고쳤다. 「Picker 가
좁아졌는가」를 화면 전체에 묻고 있었는데, 이제는 미리보기 쪽의 같은 Asset 까지
세게 된다. 그 김에 `.studio-asset-panel` 의 `aria-labelledby` 가 role 없는 div 에서
아무 이름도 만들지 못하던 것도 `role="group"` 으로 실효화했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 15:49:18 +09:00
DongHyeonkaandClaude Opus 5 b3119952d5 fix: 제목 아래에 문서의 요약을 그린다
Reference 머리말에 「이 기준을 쓰는 이유」와 같은 글이 나오고 있었다. Case 도 마찬가지로
바로 아래 「문제」와 같은 글을 두 번 말했다.

문서가 스스로 밝히는 한 줄 요약이 공개 응답에 없어서, 화면이 유형별 요약
(`problemSummary` / `scopeSummary`)을 대신 쓰고 있었다. Question 에는 이미 `summary` 가
있었으므로 셋이 같은 자리를 갖게 한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-25 21:01:52 +09:00
DongHyeonkaandClaude Opus 5 7211dd1a92 fix: 공개 Reference 가 계약이 주는 이름을 읽게 한다
Reference 를 공개했는데 Studio 에서는 다 보이고 공개 화면만 비어 있었다.

게이트웨이가 읽던 이름이 계약에 없는 것들이었다 — `purposeSummary`,
`applyWhenMarkdown`, `exceptionsMarkdown`, `examplesMarkdown`. 계약이 주는 이름은
`scopeSummary`, `appliesTo`, `excludedScope` 다. 전부 undefined 로 떨어졌고, `as string`
단언 때문에 타입 검사는 아무 말도 하지 않았다.

규칙은 `content` 마크다운을 잘라 만들고 있었다. Reference 의 본문은 마크다운 한 덩어리가
아니라 제목이 붙은 규칙의 목록이고, Studio 의 편집기가 그렇게 받아 `body_markdown` 은
비워 둔다 — 자를 것이 없으니 언제나 빈 목록이었다. 계약이 구조로 주는 것을 그대로 쓴다.

값이 아니라 이름을 지키는 테스트를 둔다. 계약에서 그 칸이 사라지면 `satisfies` 가 먼저
깨진다 — 이번 결함은 값을 검사해서는 잡히지 않았다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-25 19:08:37 +09:00
DongHyeonkaandClaude Opus 5 3036b8d788 feat: Ctrl+S 로 저장한다
브라우저의 Ctrl+S 는 "페이지를 파일로 저장"이다. 글을 쓰다가 그 손버릇이 나오면 저장
대화상자가 뜨고 편집한 내용은 그대로 남는다 — 저장한 줄 알고 창을 닫으면 잃는다.

문서·프로젝트·릴리즈 세 편집기에서 기본 동작을 막고 그 화면의 저장으로 돌린다.

저장할 것이 없을 때도 기본 동작은 막는다. 막지 않으면 "저장할 것이 없다"는 화면 상태와
브라우저의 저장 대화상자가 동시에 나와, 무엇이 일어났는지 알 수 없다.

Ctrl+Shift+S 와 Alt 조합은 가로채지 않는다 — 다른 뜻으로 쓰는 곳이 있다.

버튼에 단축키를 적어 둔다. 단축키는 알려 주지 않으면 없는 것과 같다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-25 17:05:14 +09:00
DongHyeonkaandClaude Opus 5 fd73bc88a1 fix: Decision 미리보기의 결정일 요구를 풀고, 화면 테스트가 실제 동작을 다시 말하게 한다
Decision 은 결정일이 없으면 미리보기가 열리지 않았다. 검증은 그것을 경고로만 다루므로
날짜 없이 게시할 수 있는데 렌더 모델이 필수로 요구했다 — 작성자는 "경고라면서 왜 안
되냐"를 만난다. 계약을 nullable 로 열고 화면이 "결정일 미정"이라고 말하게 한다.

한 칸의 실패가 화면을 통째로 날리지 않게 한다. `Promise.all([gateway.foo()])` 은 foo 가
거절하는 것만 잡는다 — 호출이 동기적으로 던지면 배열을 만드는 중에 터져 rejection
handler 를 지나지 못하고, 그러면 홈 focus 한 칸 때문에 대시보드 전체가 빈 화면이 된다.
프로젝트 편집도 같은 모양이라 함께 고친다.

`IntersectionObserver` 가 없는 환경을 견딘다. 목차는 픽스처 Case 하나에서만 쓰여 그런
환경을 만난 적이 없었는데, 모든 Case 가 목차를 받게 되면서 jsdom 에서 문서가 통째로
깨졌다. 없으면 "지금 읽는 절" 표시만 못 할 뿐이다.

픽스처의 최근 기록에서 릴리스를 뺀다. 서버의 `latestEntries` 는 공개 투영에서 고르므로
릴리스가 없고, 홈이 릴리스를 따로 읽어 합친다 — 픽스처가 넣으면 같은 릴리스가 두 번
나온다.

화면 테스트는 `test:unit` 이 아니라 `test:tech-log` 가 돌린다. 그것을 돌리지 않아 위
두 결함과, 라우트 두 개·`--body-copy`·Case 배치 통합·활동 링크 제거처럼 의도한 변경에
고정돼 있던 단언들이 23건 빨간 채로 여러 커밋을 지나갔다. 단언을 실제 동작으로 옮긴다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 18:03:11 +09:00
DongHyeonkaandClaude Opus 5 e5770dfbc8 fix: Decision 미리보기 오류가 할 일을 말하게 한다
"PROJECT public path is required" 는 사실이지만 작성자가 무엇을 해야 하는지 말하지
않는다. Decision 의 공개 주소는 프로젝트 주소 아래에 있으므로, 프로젝트를 먼저
게시해야 한다는 것과 그 화면이 어디인지 적는다.

프로젝트를 고르지 않았을 때의 문구도 같이 바꾼다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 17:35:46 +09:00
DongHyeonkaandClaude Opus 5 a7fe069a6d feat: 프로젝트 활동을 게시가 남기는 로그로 바꾼다
활동을 Studio 에서 손으로 적어야 했다. 그러면 "언제 무엇을 올렸는가" 가 실제로 올린
사실과 따로 관리되고, 적기를 잊으면 타임라인에 구멍이 남는다. 게시가 곧 사건이므로
게시가 기록한다.

공개 화면에서는 링크를 없앤다. 각 줄에 그 기록으로 가는 링크가 있으면 같은 글에 닿는
길이 둘이 되고, 읽는 사람은 "기록"과 "활동"이 어떻게 다른지 매번 다시 판단해야 한다.
활동은 날짜와 무엇을 올렸는지만 말하고, 글을 읽는 자리는 기록 화면 하나로 둔다.

Studio 의 활동 칸도 읽기 전용 로그가 된다 — 만들기·고치기·지우기 폼을 걷어낸다.

맥락에서 경계와 시스템 개요 칸을 뺀다. 저장은 되지만 공개 화면 어디에도 나오지 않는다
— 프로젝트 화면이 그리는 것은 목적 하나다. 쓰는 사람에게는 어딘가 실릴 것처럼 보이는
칸이었고 실제로는 아무도 읽지 않았다. 열의 데이터는 남겨 두고 서버 값을 그대로
왕복시킨다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 15:33:43 +09:00
DongHyeonkaandClaude Opus 5 ab67eb912c fix: 릴리즈 편집 하단 버튼이 두 벌 나오던 것을 하나로 되돌린다
저장·공개·공개에서 내리기가 각각 두 개씩 나왔다.

편집 폼을 목록에서 떼어 낼 때 잘라 온 조각에 이미 그 푸터가 들어 있었는데, 새 화면을
조립하면서 같은 푸터를 한 번 더 붙였다. 둘 다 같은 핸들러를 부르므로 어느 쪽을 눌러도
동작은 했고, 그래서 화면을 열어 개수를 세기 전까지는 드러나지 않았다.

자식이 하나뿐이라 감쌀 이유가 없어진 프래그먼트도 함께 걷어낸다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 02:32:12 +09:00
DongHyeonkaandClaude Opus 5 e9b866184a fix: 릴리즈 목록에서 편집 흔적을 걷어내고, 타입 검사가 실제로 돌게 한다
앞 커밋에서 편집 폼만 떼어 내고 그것을 움직이던 상태와 핸들러를 목록에 남겨 두었다.
`GuardedStudioLink` import 는 빠졌고 `navigate` 는 아예 정의된 적이 없다. 운영에서
릴리즈 목록이 ReferenceError 로 비었다.

`npx tsc --noEmit` 이 통과했기 때문에 이것을 못 봤다. 루트 tsconfig 는 `"files": []` 에
project references 만 나열하므로 그 명령은 한 파일도 검사하지 않고 성공한다 — 실제 검사는
`npm run check:types` 가 여섯 개 프로젝트를 돌며 한다. 그 명령으로 돌리자 이 저장소에
남아 있던 다른 오류도 함께 드러났다:

- `CatalogEntry` 는 `contracts/studio/contract.ts` 가 export 하지 않는다 (project-editor,
  home-focus-editor). 문서 편집기처럼 generated 에서 지역 타입으로 뽑는다.
- 라우트 파라미터는 `unknown` 으로 들어온다. `params.id` 를 그대로 문자열 자리에 넘기고
  있었다.
- `message("route.auth.returnTo", …)` 는 파라미터를 받는 키로 등록되지 않았다.
- `ReleaseIndexItem` 에는 `summary` 가 없다 — 목록 행은 마크다운을 싣지 않는다. 대신
  공개 경로를 적는다.

목록의 남은 로직도 정리한다. 새 릴리즈를 만들면 그 행을 `aria-current` 로 짚어 준다 —
목록이 길면 어느 것이 새것인지 알기 어렵다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 01:47:41 +09:00
DongHyeonkaandClaude Opus 5 84d72c4f60 feat: 릴리즈 편집을 자기 주소로 옮긴다
편집 폼이 릴리즈 목록 아래에 열렸다. 릴리즈가 열 개 스무 개로 늘면 편집하려고 목록
전체를 지나 내려가야 하고, 저장 버튼은 그보다 더 아래에 있다. 목록이 길어질수록 편집이
멀어지는 구조다.

`/studio/releases/:id` 를 연다 — 문서와 프로젝트가 각자 편집 주소를 갖는 것과 같은
이유다. 목록의 "편집" 은 토글이 아니라 링크가 되고, 새 릴리즈를 만들면 곧바로 그 화면으로
간다(만들자마자 편집할 것이 분명하다).

라우트가 하나 늘어 CI 게이트가 함께 움직였다 — 아티팩트 기준선 133→134, 증거 개수
112→113, 게이트 형태 다이제스트 재계산(f9e7e521… 을 이전 gates.json 에서 먼저 재현해
계산 방법을 확인했다), 서빙 패턴 하나 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 01:41:04 +09:00
DongHyeonkaandClaude Opus 5 6b9dc3f0a2 fix: 릴리즈 하단 버튼을 한 줄에 세우고, 로그인을 화면으로 만든다
릴리즈 편집 맨 아래에서 저장과 공개가 서로 다른 높이에 서 있었다.
`.studio-secondary-button` 는 목록 행에서 쓰려고 전역으로 `margin-top: 24px` 를 갖는데
하단 푸터에서 primary 만 그 여백을 풀어 주고 있었다 — 그래서 저장은 위에, 공개와
공개에서 내리기는 24px 아래에 앉았다.

로그인은 화면이 없었다. `ui-page` 위에 제목 한 줄과 버튼 하나가 전부라, 보호된 주소를
열었을 때 페이지처럼 보이지 않았다. 카드 하나로 세우고 eyebrow·제목·설명·행동을 갖춘
뒤, 로그인하면 어디로 돌아오는지까지 적는다 — 그것이 이 화면이 답해야 할 질문이다.

오류 화면과 다른 모양이어야 한다. 같은 모양이면 작성자는 무언가 고장 난 줄로 읽는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 00:56:16 +09:00
DongHyeonkaandClaude Opus 5 f9d20e8946 fix: setState 업데이터 안에서 event.currentTarget 을 읽지 않는다
프로젝트 편집 화면에서 활동 유형이나 제목을 한 글자 치면 화면이 통째로 죽었다 —
"Cannot read properties of null (reading 'value')".

setState 업데이터는 핸들러가 끝난 뒤 다음 렌더에 실행된다. 그때 React 는 이미
`event.currentTarget` 을 null 로 되돌려 놓았으므로, 업데이터 안에서 그것을 읽으면
반드시 터진다. 타입 검사도 lint 도 이것을 잡지 못했고, 첫 입력에서야 드러났다.

값은 핸들러가 도는 동안 지역 변수로 꺼내 두고 업데이터에는 그 값을 넘긴다.

같은 실수를 다시 못 하도록 lint 규칙을 세운다: `setXxx(...)` 에 곧바로 넘기는 화살표
함수 안에서는 `currentTarget` 을 읽을 수 없다. 처음 쓴 선택자는 동기 핸들러까지 잡아
(map 콜백, 즉시 호출되는 update 등 아홉 자리) 너무 넓었으므로, 실제로 위험한 setter
업데이터만 겨냥하도록 좁혔다. 결함을 되돌려 규칙이 잡는 것을 확인했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 21:50:47 +09:00
DongHyeonkaandClaude Opus 5 014f21b9e1 feat: 프로젝트 주제와 활동 연결을 열고, Case 목차를 되살리고, Studio 화면을 기존 디테일에 맞춘다
주요 주제 — 프로젝트 화면은 처음부터 `project.topics` 를 읽고 있었는데 공개 계약에
그 자리가 없었다. `project_topic` 은 테이블도 있고 조인할 수도 있었지만 응답에 실을
곳이 없었고, 고를 화면도 없었다. 계약에 자리를 만들고, 편집 화면에 주제 선택을 붙이고,
공개 조회가 ACTIVE 인 주제만 고른 순서대로 내보내게 한다.

활동 위치 열기 — 그 링크는 활동의 관련 기록을 따라간다. 백엔드는 처음부터 그 경로를
유도하고 있었지만, 활동을 만들 때 기록을 고를 방법이 없어 모든 줄이 관련 기록 없이
저장됐다. 활동 추가 폼에 유형·일시·제목·요약·연결할 기록·노출을 모두 둔다.

Case 목차 — 오른쪽 목차가 어떤 문서에서도 나오지 않았다. Case 렌더러가 둘이었고
어느 쪽을 쓸지 `publicPath === "/cases/collection-fetch-join-pagination"` 이라는 슬러그
비교가 정했다. 설계 픽스처 문서 하나만 breadcrumb·목차가 있는 완성된 배치를 받고, 실제로
작성한 Case 는 전부 축약본으로 떨어졌다. 배치를 하나로 합치고, 목차는 본문에 제목이
있을 때만 그린다.

Studio UI — 새로 만든 화면들이 기존 디테일을 따라가지 않고 있었다. `<label htmlFor>` +
별도 컨트롤 대신 편집기가 쓰는 `<label><span>` 묶음을, 임시로 만든 폼 클래스 대신
`studio-editor-section`·`studio-field-grid` 를 쓴다. 릴리즈 목록 행에는 유형 라벨이
빠져 격자 첫 열이 비어 있었고(제목이 당겨져 다른 목록과 줄이 맞지 않았다) 버튼 둘이
묶이지 않은 채였다. 프로젝트 행에는 단계·공개 범위를 한국어로 적고 "내용 편집" 을
다음 행동으로 둔다.

로그인 화면 — "인증 연동 지점을 확인하기 위한 보호 라우트" 는 이 뼈대를 만들던 사람에게
하는 말이었다. 이 화면을 보는 사람은 글을 쓰러 온 작성자다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 21:22:58 +09:00
DongHyeonkaandClaude Opus 5 16e5b9f4ec feat: 프로젝트를 편집하고 활동을 남길 수 있게 한다
홈의 집중 카드가 제목만 보여 주고 "현재 목표"·"다음 작업" 칸이 비어 있었다.
프로젝트 페이지의 "활동" 도 늘 비어 있었다.

프로젝트를 만들 수는 있었지만 고칠 화면이 없었다. 그래서 이름과 slug 말고는 아무
값도 가질 수 없었고, 그 값을 읽는 공개 화면들은 빈칸을 그렸다 — 백엔드의
`updateProject` 는 처음부터 구현돼 있었고 채울 화면만 없었다.

`/studio/projects/:id` 를 연다. 프로젝트 필드 전부와 활동 목록을 한 화면에 둔다 —
프로젝트 밖의 활동은 존재하지 않고, 무엇이 공개 타임라인에 실리는지를 프로젝트 필드와
같은 자리에서 보는 편이 낫다.

이미 공개된 프로젝트는 저장한 뒤 투영도 함께 갱신한다. 투영은 게시할 때 세워지므로,
저장만 하면 공개 화면에는 예전 값이 남는다.

단계와 활동 유형의 선택지는 DB CHECK 제약과 같은 목록이다. 화면이 더 많은 값을
보여 주면 저장이 제약에서 터지고, 작성자는 왜 안 되는지 알 수 없다.

라우트가 하나 늘어 CI 게이트가 함께 움직였다 — FE-GATE-009 는 설치된 라우트마다 수동
접근성 증거를 하나씩 요구하고 그 집합이 정확히 일치하지 않으면 거절한다. 아티팩트
기준선 132→133, 증거 개수 111→112, 게이트 형태 다이제스트 재계산(이전 상수 187dbd96…
을 이전 gates.json 에서 먼저 재현해 계산 방법을 확인했다), 서빙 패턴 하나 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 19:45:00 +09:00
DongHyeonkaandClaude Opus 5 0eb3c86839 fix: 새 목록 항목의 id 가 계약을 건너갈 수 있게 한다
관계를 고르고 저장하면 400 이 돌아왔고, 편집기에는 "saveStudioDocument broke its
contract." 한 줄만 남았다. 저장은 통째로 실패했다.

새 관계·규칙·선택지·문장의 id 를 `createLocalId` 로 만들고 있었다. 그 함수는 접두사를
붙여 `relation-<uuid>` 를 돌려준다 — React key 나 Idempotency-Key 로는 맞지만 계약이
그 자리에 요구하는 것은 uuid 다. 서버는 파싱조차 못 하고 InvalidFormatException 으로
거절했다.

목록에 고를 대상이 하나도 없던 동안에는(catalog RELATION/EVIDENCE 가 스텁이었다) 아무도
이 경로를 지나지 않아 드러나지 않았다. 두 결함이 서로를 가리고 있었다.

Case 는 목록 항목이 없어 무사했다. Reference 의 규칙, Question 의 선택지, Decision 의
consequences 는 모두 같은 이유로 저장되지 않았을 것이다.

`createNewItemId` 로 나눈다. 서버는 자기가 소유하지 않은 id 를 어차피 새로 부여하므로
(`StudioRelationStore.replace`) 이 값은 "이 줄은 새것"이라는 표시일 뿐이다 — 지켜야 할
것은 형식뿐이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 19:03:27 +09:00
DongHyeonkaandClaude Opus 5 c87e0a2338 fix: home focus 경로를 계약과 맞춘다
계약이 `/api/v1/studio/home-focus` 에서 `/api/v1/studio/home/focus` 로 옮겼다 —
케밥 세그먼트가 백엔드의 AIP-122 규칙을 위반했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 18:20:32 +09:00
DongHyeonkaandClaude Opus 5 b3aa304975 feat: 프로젝트를 공개할 수 있게 하고, 홈이 무엇을 앞에 둘지 고를 수 있게 한다
공개 화면 다섯 곳이 조용히 비어 있었다. 원인은 하나씩 달랐지만 모두 "값을 채울
방법이 없었다"는 같은 모양이었다.

홈의 "지금 집중하는 것" — `home_focus_config` 는 마이그레이션이 빈 행 하나만
넣어 두었고, 계약에 선언된 `getHomeFocus`/`updateHomeFocus` 는 구현이 없었다.
세 슬롯이 모두 비면 홈은 그 영역을 아예 그리지 않으므로, 운영에서는 한 번도
나타난 적이 없다. Studio 대시보드에 고르는 화면을 둔다.

홈의 "최근 기록" — 화면이 공개된 프로젝트를 하나씩 돌며 타임라인을 조립했다.
그래서 게시한 문서라도 그 프로젝트가 공개되어 있지 않으면 목록에서 통째로
빠졌고, 실제로 릴리스 한 줄만 남았다. 무엇이 최근인지는 공개 투영이 이미 알고
있으므로 그것을 그대로 읽는다. 프로젝트마다 요청을 보내던 N+1 도 사라진다.

프로젝트 공개 — 프로젝트는 `RecordKind` 에 없어 문서 게시 파이프라인을 타지
못하는데, 공개 화면들(프로젝트 목록·프로필의 "현재 프로젝트"·홈 focus)은 전부
`public_resource_projection` 의 PROJECT 행을 가시성 관문으로 쓴다. 그 행을
세우는 경로가 없었으므로 프로젝트는 영원히 비공개였다. 계약에 이미 있던
`publishProject`/`unpublishProject` 를 구현하고 주제·프로젝트 화면에 버튼을 둔다.

문서 사이 관계 연결 — `JdbcCatalogQueryAdapter` 의 RELATION/EVIDENCE 가
`List.of()` 스텁이라 어떤 기록도 연결 대상 목록을 채울 수 없었다. RELATION 은
작성 중에 고르는 것이므로 작업본까지 포함하고, EVIDENCE 는 읽는 사람이 따라갈
수 있어야 하므로 공개된 것만 포함한다.

본문 너비 — 문서 한 편이 세 폭으로 갈라져 있었다. 머리말 920px, 유형·프로젝트
줄은 shell 전체 1180px, 본문은 672px 를 가운데 정렬. 셋을 같은 폭·같은 왼쪽
끝에 세우고 읽는 단을 56rem 으로 넓힌다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 17:38:49 +09:00
DongHyeonka c03b0c77b8 fix: 오류 수정 2026-08-22 14:40:49 +09:00
DongHyeonka 1801414592 fix: show the reason the server gave for a failed action
Every failure in the management screens printed a guess. Deleting a working
copy said "it may be published, or something may reference it, or someone may
have edited it first" — three maybes, while the server had answered with
exactly one: "이 기록을 참조하는 곳이 있어 삭제할 수 없습니다". A version
conflict read as "in use" because the same sentence covered both, and an author
watching some deletions succeed and others fail had no way to tell them apart.

The gateway now carries the server's client-safe message and the screens show
it. The canned sentences remain only as a fallback for a failure that never
reached the server.

The topic status label said "사용 중" for every active topic, including one
created seconds earlier that nothing references. Next to a refusal about
records that use a topic, the two read as the same statement. It says "활성"
now, which is what the status means.

Publishing also stops demanding a finished document — the mock validator moves
with the real one, so what an author sees against fixtures matches production.
2026-08-21 19:29:04 +09:00
DongHyeonka 7345500ef3 feat: show the way to publish, and what is blocking it, in the editor
Publishing was reachable only by walking the whole chain blind. The editor
offered one link — "저장본 검증" — and the word "게시" appeared nowhere until
three screens later, so an author with a finished draft could not tell how to
publish it. The working-copy list already named the next step, but the name was
plain text with nowhere to go.

The editor now shows the whole path: 검증 → 미리보기 → 게시, each a link. The
list's next step is a link to that step. Neither weakens the gates — an
unvalidated document is still refused at preview, an unpreviewed one at
publish. What changes is that the order stops being a secret.

What is blocking publication now appears where it gets fixed. The validation
report lived on its own screen, so an author read the list, navigated back, and
had to remember which field each item meant. The editor shows the same issues
above the fields, in red, and says plainly when they describe an older saved
version rather than the current one.

The clock is read during render, not captured as an effect dependency. It is a
new function on every render of the provider, so depending on it refetched the
document endlessly — the editor never settled, and a tab click did not even
register. A value you are asking about now does not belong in a dependency
array.
2026-08-21 18:43:10 +09:00
DongHyeonka fb478f951b fix: say which version a stale validation judged, before showing its errors
The validation screen presented a report from an earlier version with the same
weight as a current one — same status badge, same full error list — and marked
the difference with two small words. An author read a version 2 report on a
version 5 document and understood those errors as the document's present state.
Every error in that list had already been fixed.

The report now leads with what it is: which version it judged, that the
document has changed since, and where to re-run it. The badge stops claiming a
verdict and the issue list recedes, because a judgement about an older version
is not a verdict about this one.
2026-08-21 18:10:51 +09:00
DongHyeonka 7289ce97bb fix: stop the smoke sweep reporting an expected 404 as a failure
A document with no preview yet answers 404 when the screen asks for its current
one, and the screen turns that into "make a preview". The sweep counted it as a
failure, so every run ended with the same red line under a healthy deployment.

A check that cries wolf on every run stops being read, and a real failure would
have sat unnoticed beside it. The session probe's 401 before sign-in is the
same kind of expected answer and is excluded on the same terms; every other
4xx and 5xx still fails the sweep.
2026-08-21 18:00:14 +09:00
DongHyeonka 348420618d fix: stop claiming a 1x1 size for an image whose dimensions are unknown
An uploaded figure never appeared, and no request for it was ever made. The
resolver substituted 1x1 when an asset carried no dimensions, and a 1x1 box
with `loading="lazy"` never enters the viewport — so the browser had no reason
to fetch it. The image was not failing to load; it was never asked for.

Unknown dimensions now say so. The figure omits the attributes and loads
eagerly, letting the browser size the image from the file, and reserves layout
space only when the size is actually known. Guessing a number to fill an
attribute is what turned a missing measurement into a missing picture.
2026-08-21 17:57:10 +09:00
DongHyeonka 89a73c13c6 feat: delete a decision, manage assets while writing, and sweep before deploying
Four things an author could not do, and the check that should have caught them.

A Decision could not be deleted. Case, Reference and Question all could, so an
author who opened a decision draft had no way to close it. The contract gained
the operation and the list now offers it for every kind. Its path carries the
project because a decision belongs to one; a row with no project says so rather
than failing.

Assets could only be managed by leaving the document. The picker now deletes
one in place — the server still refuses an asset a document uses — so a
mistaken upload does not cost the author their editing session.

Zoom was decided for the author and could not be changed: only a DIAGRAM got
it, so a screenshot uploaded as an image or attachment went in with zoom off
and no way to turn it on. It now defaults on for images and the picker offers
the choice. The toggle is a picker control, not a document field, and carries
its own class — wearing the field class put it in the editor's field list.

`scripts/smoke/production-sweep.ts` walks every public and Studio screen and
the document flow, reporting console errors, failed API calls and error text.
It exists because verifying only the screen I had just changed is what let
broken screens reach production repeatedly; this runs before a deploy, not
after a report.
2026-08-21 17:20:42 +09:00
DongHyeonka 21f8425f1e fix: keep line breaks in the fields that are not Markdown
The earlier fix covered the Markdown body and stopped there, so the preview
still ran lines together — which is exactly what it looked like from the
outside: nothing had changed.

Summary, problem, conclusion, environment and the rest are plain text. They
never pass through the Markdown parser, so their newlines sit in a text node
and HTML collapses them, and the renderer that now emits <br> for the body was
never asked about them.

They render through the same rule now. One helper, one behaviour: a line the
author broke stays broken, wherever they typed it.
2026-08-21 15:54:28 +09:00
DongHyeonka 7093d84ab5 fix: give a document a slug, and say so when saving fails
Validation reported a slug the author could see on screen as missing. Both
halves of that were the editor's fault.

The document slug must match `^[a-z0-9]+(?:-[a-z0-9]+)*$`, which the editor
never said and never helped with. A Korean slug was rejected by the server with
a 422 carrying no details — and the editor answered that only through
`setRequestAnnouncement`, which is an aria-live region and shows a sighted
author nothing at all. The value stayed in the field, so it looked saved. Then
validation, which reads the saved version by design, correctly reported no
slug, and the author read that as the tool contradicting itself.

An empty slug is now derived from the title, romanizing Hangul the same way
topic slugs do, so a Korean title produces a valid slug and the author never
has to learn the rule. A slug that cannot work is refused before the request,
naming the rule instead of letting the server answer with an unexplained 422.
Every save failure now renders where the author is looking, not only where a
screen reader would hear it.

The rail shows one message rather than two: a conflict already says what to do,
so it outranks the server's wording, and everything else shows the server's
reason.
2026-08-21 15:35:23 +09:00
DongHyeonka d2c289c650 fix: keep the line breaks an author typed
Text written across several lines rendered as one run-on line. Markdown reads a
single newline as a space that joins a paragraph, the parser leaves that
newline inside the text node, and HTML then collapses it — so the break the
author pressed Enter for disappeared at the last step.

This was never a preview artifact: the Studio preview and the public page go
through the same renderer, so a published record ran its lines together too.

Newlines inside a paragraph now render as <br>. Paragraphs separated by a blank
line are already two paragraphs by the time they reach here, so this only
affects the breaks an author put inside one.
2026-08-21 15:10:27 +09:00
DongHyeonka 5cffe30200 fix: derive a topic slug that survives a Korean name
Creating a topic failed intermittently — "a topic with that slug already
exists" — and worked when the author retried with a different name. The rule
was never intermittent, only invisible: the slug kept `[a-z0-9]` and dropped
everything else, so a Korean name contributed nothing. Whatever Latin word or
number happened to be in it became the entire slug.

Two ways that goes wrong, and the author hit both. `인증` reduced to an empty
string, which the form refused before a request was ever sent. `Redis 캐시` and
`Redis 클러스터` both reduced to `redis`, so the second one collided with the
first — a real conflict, reported honestly, about a slug the author never chose
and could not see.

Hangul is now romanized rather than discarded. Syllables decompose
arithmetically into initial, medial and final jamo, so this needs no table and
is deterministic: `백엔드 아키텍처` becomes `baekendeu-akitekcheo`. Only the
jamo mapping from Revised Romanization is applied — the sound-change rules are
deliberately left out, because a slug is read, not pronounced, and those rules
would make one name produce different slugs in different contexts.

The output keeps the shape document slugs already use
(`^[a-z0-9]+(?:-[a-z0-9]+)*$`), so the repository has one slug rule rather than
two, and the tests assert exactly that.
2026-08-21 14:56:03 +09:00
DongHyeonka 197b2c7e72 chore: pick up the regenerated Question input contract
`resolution` leaves the input's required list, so the generated type makes it
optional. The editor already sent null for an unresolved question; nothing in
the frontend changes behaviour.
2026-08-21 14:32:14 +09:00
DongHyeonka c5e8735041 feat: read the profile's topics from Studio, and add working-copy deletion
Two things an author could not control from Studio.

The profile's "주요 관심 주제" was four strings in the JSX. Creating or removing
a topic in Studio changed nothing, and correcting the list meant a rebuild and
a redeploy. It now renders the published topic list. The old literal opened
with "Backend Architecture", which no record in the catalogue actually carries
— the profile was advertising a topic that did not exist, and nothing could
have caught that while the list lived in the markup.

The working-copy list gained a delete control. It routes by kind because the
contract and the storage both do: Case and Reference share one table split by
type, Question is its own. Decision has no delete — its lifecycle is accept,
reject, supersede, which records what happened rather than erasing it — so the
control does not appear for it.

The list summary carries no version, so deletion reads the working copy first
and uses the version it finds. A stale version from a list left open should
fail as a conflict, not delete whatever is there now.
2026-08-21 13:30:37 +09:00
DongHyeonka ab8c6c14db fix: derive the Studio serving patterns from the route contract
`/studio/releases` answered a plain-text 404 from nginx. The route existed, the
chunk was built, and the SPA could reach the screen by client-side navigation —
but a hard load or a reload never got that far, because the web server had
never been told the path exists.

The public half of the serving contract derives its patterns from the route
registry. The Studio half was a hand-maintained array, and it failed the way
hand-maintained arrays fail: the comment above `^/studio/assets$` records that
exact bug being fixed once already, and adding a route repeated it immediately.
Both halves now come from the same source, so a Studio route that exists is
served without anyone having to remember.

Deriving them yields one pattern per route rather than the old alternation that
folded the four document sub-screens together. Same matched set, and it no
longer needs a human to keep the grouping honest.
2026-08-21 03:29:47 +09:00
DongHyeonka 3754269118 feat: add the Studio release editor and point the footer at the changelog
The public site shipped a Releases page and a footer link to a release, and
neither could ever have content: the read path existed, the write path did not.
This adds the seven release operations to the contract contribution and the
gateway, and a Studio screen that can actually write one.

The editor is six markdown fields rather than one, because that is what the
contract models and what a release note is — why, what, what a reader notices,
what it leaves in the code, how it was verified, what is still open. Publishing
is separate from saving: a draft saves in any state, but the public query keys
on PUBLISHED alone, so publish is where completeness is demanded.

No new CSS. The screen reuses the document editor's field classes and the
working-copy list's row classes, so it inherits Studio's spacing and type
instead of introducing a second look.

The footer previously linked `/releases/0.1.0` — a version that did not exist,
so the link 404'd, and one that would have gone stale at 0.2.0 anyway. It now
points at the changelog index, which is the only place that knows what the
latest release is and which reads correctly when there are none.

Route inventory, navigation order, message catalog, manual accessibility
evidence, artifact baseline, and the pinned gate-shape digest all move with the
new route. The digest was recomputed by first reproducing the previous constant
from the previous gates.json, so the computation is known to be the one it was
pinned under.
2026-08-21 03:08:41 +09:00
DongHyeonka 760071156d fix: give each API surface its own error-code enum
The public site answered every screen with the terminal error surface. Three
defects stacked, and each one hid the next.

The first refused the request outright: `attachCredentials` asks the Studio
helper, which returns null for a profile it does not own, and the fallback
below read the session and rejected anything not authenticated. Public reads
declare the ANONYMOUS profile, so a signed-out visitor — the public site's
entire audience — never got a request out of the browser. An anonymous profile
carries no credentials by definition and must never consult the session.

With requests flowing, the second surfaced: `envelopeError()` pinned
`ApiError.code` to the Studio enum and all three surfaces shared it. Public and
Management each declare their own enum in their own contract, so every error
they returned failed validation and arrived as a CONTRACT_VIOLATION — an
unclassifiable transport fault — rather than the domain error it was. A strict
enum checked against the wrong surface's contract still looks strict, which is
why no gate caught it. Each surface now passes its own contract's codes.

The third was the not-found path: it read `status` and `code` off the problem
body, but the envelope has no `status` and names the code for its surface
(PUBLIC_RESOURCE_NOT_FOUND, not NOT_FOUND). The HTTP status from the transport
is the authoritative signal and the only one that holds across both shapes.

The regression test composes the real runtime adapters against the deployed
backend's actual 404 body. Neither the gateway tests (which stub the executor)
nor the screen tests (which stub the gateway) cover this seam, and the whole
outage lived in it.

Two page-level fixes came out of the same investigation: the profile page asked
for two project slugs that only ever existed in the static fixture, and the
index pages held their fixed header copy behind a request that had nothing to
do with it. Headers now paint immediately; only the sections that are actually
waiting show a fallback, and an empty list says so instead of rendering blank.
2026-08-21 01:14:20 +09:00
DongHyeonka 03986da3d6 fix: let a signed-out visitor read the public site
Every public screen rendered its terminal error surface, and the network log
explained why: no request to /api/v1/public ever left the browser.

The credential collaborator asks the Studio helper first, which returns null
for any profile it does not own — "not mine, use your own logic". Below that,
the fallback reads the session and refuses anything that is not authenticated.
The public operations declare the ANONYMOUS profile, so they fell into that
fallback, and a signed-out visitor is exactly who the public site is for.

An anonymous profile carries no credentials by definition — the registry
refuses to install one that even allows a credential header — so it must never
consult the session. It now short-circuits with an empty credential patch,
keyed on the profile's transport rather than a profile id, so any anonymous
operation is covered rather than one named surface.

This could only appear once the public source became HTTP; until this week
that path had never run in a browser. The suites did not catch it because they
exercise the gateway and the screens, not the composition root's credential
decision — that seam has no test, and this is what it costs.
2026-08-21 00:43:54 +09:00
DongHyeonka 31dca00857 fix: keep the public screens usable on an empty site, and centre the dialogs
The published site showed one line of text — "요청한 문구를 표시할 수 없습니다."
— instead of any UI. Two independent faults produced it.

The screens ask for the whole catalogue by calling searchPublicContent(""). The
fixture answered that with everything it had, and four screens lean on it: the
home timeline, the project index, the release index, and the explore filter.
The contract has no such meaning — `q` is required, and an empty one answers
400 — so all four turned into error surfaces the moment the source became HTTP.
The adapter now assembles that catalogue from the list endpoints the contract
does provide, and only sends a real query to the search endpoint. Filtering
client-side instead would have been the other option, and it would silently
lose every result past the first page.

The message that surfaced was missing too. Twenty-two of the thirty-eight
failure kinds had no copy, so `errorMessage` fell through to
`common.unavailable` — which says nothing about what failed or what to do.
That is a systemic gap, not one absent key, so all twenty-two are written, in
both catalogues. They are phrased for the reader: what did not happen and what
to try, not the internal classification.

The Studio dialogs opened against the top-left corner. A modal dialog centres
through the UA's `margin: auto`, which is not surviving in this build; the
public `.search-dialog` already states `position/inset/margin` explicitly for
the same reason. The three that did not — unsaved-changes, asset upload, and
the publication flow — now follow it, with a max-height so a long dialog
scrolls rather than running off the screen.
2026-08-21 00:25:51 +09:00
DongHyeonka 11c2713139 feat: let Studio create the topics and projects publishing requires
Publishing needs a topic and nothing could create one. The backend now owns
that surface; this is its consumer — the management contract vendored, a
gateway over its nine operations, and one Studio screen that lists, creates,
and deletes topics and projects.

The screen adds no CSS. It reuses the classes the working-copy list already
uses, so it inherits Studio's spacing, type, and colour rather than growing a
second visual vocabulary beside them. Scope stops at list/create/delete:
renaming, phase changes, and visibility are implemented in the backend and
declared in the contract, but their screens are a separate design.

Two real defects surfaced while making the public port async, and both would
have shipped:

The search page and the header search dialog shared a query key. With an empty
query, `["tech-log","search",""]` was identical for both, so react-query
handed one surface the other's cache — different shapes — and the page died
reading a field that was not there. Keys now name the surface.

The explore filter's selects are uncontrolled and read `defaultValue`, which
React applies once. Their options arrive later now, so the first render had
nothing to match and the value stayed empty: a topic in the URL no longer
showed as selected. The form key includes whether the catalog has arrived, so
it remounts with the options present. Controlled inputs would be the other
answer, but this form submits to build a URL — the URL owns the value.

The route brought its own bookkeeping: a build chunk, a manual accessibility
evidence file, and the CI artifact baseline that counts them. The gate pins a
digest of its own shape precisely so a new route cannot slip in without that
count being reviewed.

Test harnesses that render public screens now assemble the query providers and
await the settled paint, because the screens they render became async.
2026-08-20 23:40:15 +09:00
DongHyeonka 4b62bf3b1f chore: re-vendor both contracts from the merged design package
Only the recorded source revision moves (55a9599 -> b98eaf9). The vendored
specs and the generated types are byte-identical, which is the useful part:
the envelope redefinition landed on a merge commit, not a schema change, so
nothing downstream has to move with it.
2026-08-20 18:42:04 +09:00
DongHyeonka 6784eb1ce6 fix: serve the public routes the router declares, not the slugs the build saw
The serving contract enumerated every public path the bundled fixture happened
to contain, and the generated nginx published exactly those as `location =`
blocks. A record published after the build — the entire point of having a
backend — answered 404 at the edge before the SPA was ever asked, and no amount
of correct routing inside the bundle could recover it. Twenty-seven frozen
paths, and any twenty-eighth was unreachable.

The route contract already declares which paths exist; the catalog only decides
which of them currently resolve, and that is the SPA's call rather than the web
server's. So the contract now emits one regex per registered Public route,
derived from the router, the way the Studio half has always worked.

A parameter matches one segment and never a slash, so /cases/a/b stays a 404
instead of quietly rendering a case page. The catch-all route is dropped rather
than translated: serving index.html for every unmatched URL would turn an edge
404 into a soft 200 and hide broken links from crawlers and from us.

Verified against a built image: /cases/a-brand-new-slug now answers 200 while
/nope and /cases/a/b still answer 404.

schemaVersion goes to 2 because the field changed shape, not just contents —
a consumer reading publicSpaPaths would otherwise see an absent key rather than
a version it can refuse.
2026-08-20 17:48:39 +09:00
DongHyeonka 24c01aedf2 feat: give the public surface an HTTP adapter, and a switch to reach it
The public read port had one implementation and no way to add another. This is
the second one: the 18 operations of the public contract, mapped to the nine
methods the screens call.

The contract and the screens disagree about shape, and translating here is what
keeps the presentation components untouched. The server speaks in what it
stores — timestamps, one markdown body, relations grouped by why they relate.
The screens were built against a catalog that spoke in what a page renders —
formatted labels, titled sections, one flat relation list whose group name is
the reason. Neither is wrong. Where the contract has no counterpart the value is
left empty and the gap is named where it happens rather than guessed at: a
Case's verification line, a decision's consequences, a question's options.

Sections are split from markdown here rather than through the Studio parser.
That parser produces the canonical render-block union the editor needs — inline
marks, evidence directives, tables — which is a richer tree than RecordSection
can hold, so reusing it would mean flattening away exactly the blocks that made
it worth using.

A 404 is unwrapped, not thrown. A slug that is not published is an answer the
port already has a shape for, and throwing would put a terminal-error surface on
a page whose real state is "this does not exist".

`listRecords` is one method over two endpoints, because the contract pages and
filters knowledge separately from questions. Only the unfiltered call fans out:
asking for one kind must not pay for the other.

The operations declare the ANONYMOUS auth profile, which forbids credentials
outright. That is the point — a later change that starts sending the session
cookie on a public read fails the profile check instead of quietly making a
cache-friendly surface user-specific.
2026-08-20 17:36:18 +09:00
DongHyeonka 4566f2d7a8 refactor: make the public read port async so a network adapter can implement it
`PublicContentQueries` returned arrays, not promises. That signature is only
implementable by something already in memory, so the port could hold exactly
one adapter — the bundled fixture — and no amount of configuration could put
the public site on the backend. Turning it async is the change that makes a
second adapter possible; the adapter itself follows.

The markup is untouched. Every page reads a value and hands it to a
presentational component, so the shape those components receive is mapped at
the adapter boundary and nothing below the page changes.

Screens load through one query, not one per read. Several pages read in a loop
— the home timeline walks every project for its activity, the explore filter
walks search results to resolve titles — and a hook per read would mean a
variable number of hooks per render, which React forbids. `usePublicContent`
takes the whole screen's reads as one loader, where a loop is a loop and
`Promise.all` is available; the loops that used to be N sequential lookups now
issue together.

Two places deliberately do not show the loading surface. The explore filter
sits inside a page that already renders one, so a second skeleton would move
the layout under it — it keeps its structure and fills its options in when they
arrive. The search dialog is a type-ahead: re-querying per keystroke would
replace the results with a skeleton on every key, so it loads the catalog once
and applies the same predicate locally.

`usePublicContent` requires an object because `undefined` is how the query
layer says "no result yet". A loader returning the record itself would make a
missing slug indistinguishable from a request in flight, and the page would sit
on a skeleton instead of rendering its not-found route.

Studio's `resolvePublishedLabel` stays synchronous. It is called from inside
the public renderer, so making it async would push awaits through the render
tree; the shell loads the catalog once and the callback remains a lookup.

The component tests now assemble the query providers the running app assembles.
Without them the render throws "No QueryClient set" — not a harness quirk, but
the same failure the app would produce if it were mounted without its query
layer.
2026-08-20 16:53:51 +09:00
DongHyeonka c362ec6100 feat: vendor the public read contract, and give it its own source switch
The public surface — 17 of the 28 registered routes — reads from a 29KB
TypeScript fixture and never touches the backend. `TECH_LOG_STUDIO_SOURCE`
only ever switched the Studio gateways; `publicContent` was wired to the
static adapter unconditionally, so no configuration could make the public
site show published content. This is the first half of closing that: the
contract and the switch, with the adapter still to come.

The generator now vendors both canonical contracts instead of one. They are
independent — different services on different schedules — so each carries
its own digest and operation list, and updating one leaves the other's drift
gate quiet.

`TECH_LOG_PUBLIC_SOURCE` is deliberately a second flag rather than a rename
of the Studio one. The combination that matters right now is exactly the one
a single flag cannot express: the authoring backend is live while the public
read API does not exist yet. production stays on MOCK for that reason —
pointing it at HTTP today would empty the live site — and moves when the
backend serves /api/v1/public.

Also records the compatibility evidence the registry gate wanted for the
Studio access change in fff5e6f. That gate has been failing since, which is
on me: the change was real and breaking, and it shipped without the note
explaining that route ids and schemas are untouched and only the access
classification moves.
2026-08-20 16:19:53 +09:00
DongHyeonka 83409bef7a feat: give the frontend a deployment artifact, and show its logo
The repository had no container image and no production-shaped serving
configuration. `dist/server.mjs` is a preview server that applies neither
the security headers nor the cache policy `config/hosting/` declares, so a
deployment had nothing correct to run.

`scripts/generate-nginx-config.ts` derives the server block from
`dist/tech-log-serving-contract.json` plus the two hosting policy files, so
the served headers and cache lifetimes cannot drift from what the contract
declares. It emits no TLS and no proxy blocks: the edge terminates TLS and
routes /api, and baking a backend address into the image would tie the
bundle to one deployment. Static surfaces use `alias` because a base-path
build serves /dev/assets/... out of dist/assets/..., which `root` plus URI
would look for one directory too deep.

The image copies that config next to the bundle and normalises permissions:
the build writes config.json 0600, which nginx cannot read, so the container
came up healthy and answered 403 for the one file the SPA needs to boot.

index.html never referenced public/favicon.svg. The file shipped and nginx
served it, but browsers asked for /favicon.ico, got a 404, and fell back to
the default icon. `%BASE_URL%` rather than an absolute path so a prefixed
deployment points at its own copy.

development.json moves to the HTTP Studio source; the mock source has no
backend to authenticate against, which is the whole point of that profile.
2026-08-20 16:14:09 +09:00
DongHyeonkaandClaude Opus 5 5e2b1a5586 fix: remove the footer layout shift, and hide the Studio chrome from signed-out visitors
CLS was 0.192 on every public route, and one element accounted for all of
it: the footer moved at t≈482ms, right when the lazily loaded route chunk
arrived. The site frame laid the footer out in normal flow, so before the
content existed it sat at the bottom edge of the viewport — visible — and
then dropped out of view when the page grew to 2400px.

The frame is a flex column now with the footer pinned by `margin-top:
auto`, and the content slot holds a viewport of height so the footer
starts below the fold and only ever moves further out of sight. The slot
is `main` once the route renders and `section.ui-page` while Suspense is
pending; covering only the first left the shift in place, which is what
the intermediate measurements showed.

  /  /explore  /projects  /releases  /search  /profile   0.1924 → 0.0001
  360 / 768 / 1440 across four routes: no horizontal overflow

Separately, the route gate stopped the Studio page but not the Studio
shell, so a signed-out visitor who typed /studio still got the whole
workspace navigation — 작업본, 게시 기록, 새 문서, by name. No data crosses
an href, but "비로그인 사용자가 Studio 화면을 볼 수 없다" is not satisfied by
hiding the contents of a screen while showing the screen. The header is
drawn only for an authenticated session; `children` is already the
router's sign-in surface, which is the whole of what such a visitor gets.

  signed out   h1 "세션이 필요합니다."   0 nav, 0 studio links
  signed in    h1 "작업 흐름"           2 nav, 8 links, sign-out present

Three test updates follow from behaviour that changed rather than broke:
the frozen route inventory now derives `access` from each route's own
layoutGroup instead of asserting "public" for all 28 — so a Studio route
added without a gate fails that table too — and the router and shell
harnesses supply the session the Studio surface now reads. The router
suite also gains a test that a signed-out visitor gets neither the Studio
heading nor its navigation, which is the regression the gate exists for.

test:all is 1811 passed; the eight remaining failures are the three
load-dependent flake families (ci-artifact-contract,
provider-guardian-transaction, security-followup), all of which pass in
isolation and reference none of the changed files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 11:01:29 +09:00
DongHyeonkaandClaude Opus 5 f1498feee5 feat: let the SPA see the backend session, and give it a way out
Two gaps that only showed up once the backend's BFF login worked.

The SPA could not tell it was signed in. `AUTH_MODE: "external"` delegates
the session to whoever hosts the bundle, via
`window.__CA_FRONTEND_AUTH_OWNER__`; nothing installed one, so the runtime
fell back to `createUnavailableSessionAdapter` and a browser holding a
valid TECHLOG_SESSION cookie still saw "로그인 연동이 필요합니다".

Tech Log's host is its own backend. The session is an httpOnly cookie the
SPA cannot read, so the only way to observe it is to ask — which is what
`getStudioSession` already is, the contract's bootstrap operation that
issues the CSRF token. The owner probes it on creation and publishes the
result: 200 authenticated, 401/403 unauthenticated, anything else
integration-failed (claiming "signed out" on a 5xx would push the user
through a login they do not need). It starts at `recovery-pending` so a
signed-in user does not get a sign-in flash on every reload, and signs in
by navigating the browser to the authorization endpoint — the code flow is
a redirect chain an XHR cannot follow.

Installed from create-runtime-composition, before the adapters resolve it,
and only for AUTH_MODE=external with the HTTP Studio: MOCK has no backend
to ask and demo keeps its own adapter.

There was also no way to sign out. `signOut` is wired all the way to the
port and the label exists in both catalogs, but the button lives in the
template's AppShell, which TechLog never renders — it supplies its own
public and studio shells. The Studio header now carries it, drawn only
when authenticated so it does not duplicate the sign-in the auth gate
already offers.

Sign-out sends the CSRF header it caches from the session probe, and
reports failure instead of swallowing it. The first attempt did neither:
`/logout` is a mutation, answered 403 without the header, and the owner
published `unauthenticated` from a `finally` — so the cookie survived
while the UI claimed the user was out. That is the one failure someone on
a shared machine would never think to check, so a sign-out that did not
happen now throws and leaves the state alone.

Verified against the running backend with a production-profile build:

  /studio signed out   401 probe → sign-in surface → Keycloak
  after login          session 200, dashboard 200, real data rendered
  sign out             204, TECHLOG_SESSION cleared, /studio/documents
                       back to the sign-in surface

check:types, lint, check:architecture, check:tech-log-contract,
check:dev-release-manifest and check:browser-security all pass. test:all
is 1817 passed with two load-dependent flakes that pass in isolation
(provider-guardian-transaction, security-followup) and reference none of
the changed files — security-followup kills process groups, which is also
what was killing the Gradle daemon when both suites ran at once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 02:24:09 +09:00
DongHyeonkaandClaude Opus 5 5fe355483e docs: complete the locally-verifiable half of the release checklist
Second pass. The first pass called several sections impossible; most were
not. The Studio mock implements all 18 contract operations with real
semantics (optimistic locking, validation staleness, preview expiry,
warning acknowledgement, idempotency), so the whole authoring flow is
exercisable without a backend, and the Public surface's UI behaviour is
testable against its static content.

Corrections to the first pass:
  - prod refusing to boot on the committed .env is the design working,
    not a defect: five startup validators reject development values, two
    of which were observed firing in order. The real gap is that no
    production value set exists anywhere yet.
  - ddl-auto=validate failing is a constraint, not a blocker -- prod
    accepts none as well, which is how this run booted.
  - two first-pass findings were false positives: the "Studio exposure"
    hits were release-note body text (zero /studio links on any public
    page), and the missing code block was a test artifact (no static
    document contains one; injecting one renders correctly).

New defects found:
  - no way to log out: the session button lives in the template's
    AppShell, which TechLog never renders -- it supplies its own shells.
  - duplicate relations are not prevented, at the contract level, so a
    backend implementation would inherit the same hole.
  - CLS 0.192, from a single footer shift at t=538ms.
  - no index on navigation_path: 236ms seq scan over 20k rows for the
    slug lookup the checklist names as a query pattern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 17:28:05 +09:00
DongHyeonkaandClaude Opus 5 44caa477e3 docs: record the release-gate verification run against a live backend
Runs both repositories locally -- backend on PostgreSQL 16 behind a real
Keycloak realm, frontend as a production-profile build -- and records what
each checklist section actually did, with the command output behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 16:50:42 +09:00
DongHyeonkaandClaude Opus 5 fff5e6f59e fix: gate Studio routes behind a session and repair the broken main build
Three defects found while running the release checklist against a live
backend, all on main.

1. Every TechLog route registered `access: "public"`, including the whole
   Studio surface. `decideRouteAccessForDefinition` was therefore a no-op
   for Studio: a signed-out visitor who typed /studio, /studio/documents,
   or /studio/assets got the Studio shell rendered, and the page went on
   to issue Studio API calls. Access is now derived from the spec's own
   `layoutGroup`, so a newly added Studio route is gated by construction
   rather than by remembering to restate it.

   Verified against a production-profile build: /studio* now renders the
   sign-in surface, /  and /explore are unchanged, and after signing in
   the router returns to the originally requested Studio screen.

2. `public/release-manifest.json` still declared the contract set at
   2.0.0 while the vendored contract had moved to 3.0.0 (eb86708). Boot
   verification fails closed on that mismatch, so `pnpm dev` served a
   blank screen. Regenerated from the same producer `dist/` uses.

3. `release-manifest.test.ts` asserted the same stale 2.0.0. The literal
   is deliberately independent of `EXPECTED_CONTRACT_SET_PACKAGES` (see
   the comment above it), so it is updated in place, not derived.

Also drops a dead `= null` initializer that failed `no-useless-assignment`.

check:types, lint, check:architecture, check:tech-log-contract and
check:dev-release-manifest all pass. test:all is 1818 passed with one
pre-existing load-dependent flake (provider-guardian-transaction, passes
in isolation, untouched by this change).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 16:34:31 +09:00